~~~~~~~~~~~~~~~~~~~~~~
~~ theresa & tobias ~~
~~~~~~~~~~~~~~~~~~~~~~
Our blog subtitle goes here!

Learning about DNS

by: tobias
tech

Starting a new blog post always feels like a lot of commitment. But now I’m sitting at home alone, isolated, waiting for death Christmas, trying not to get infected. And I’m kind of bored. So I thought I’d try to learn something new and write about, just for fun.

I thought long and hard about what I would want to learn about and decided on my favorite application layer protocol: DNS. DNS (or Domain Name System), is pretty cool, it can do a lot of crazy stuff. Before I go on researching, I’ll just babble about what I already know. I’ll check for unknowns later.

First, the basic idea of DNS is the translation between human-readable domain names and IP addresses. Like theresa-tobias.website translates to 64.98.145.30 (at least at the time of writing this) and we don’t have to put in crazy numbers into our browser’s address bar. I found this out using the dig utility, which I really dig1:

$ dig theresa-tobias.website

; <<>> DiG 9.10.6 <<>> theresa-tobias.website
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 49015
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;theresa-tobias.website.		IN	A

;; ANSWER SECTION:
theresa-tobias.website.	60	IN	A	64.98.145.30

;; Query time: 311 msec
;; SERVER: 192.168.189.1#53(192.168.189.1)
;; WHEN: Sun Dec 20 14:46:33 CET 2020
;; MSG SIZE  rcvd: 67

Somewhere in there you’ll find the magic answer: theresa-tobias.website. 60 IN A 64.98.145.30. Our domain name, an A (because this is an “A” record that translates from domain name to IPv4 address), and the IP address.

Besides “A” records there are also “AAAA” records that give you IPv6 addresses, “MX” records that give you (incoming) e-mail servers, “TXT” records for extra information, and a bunch more (these are the ones I can remember from the top of my head right now). Anyway, the cool thing about DNS is that it’s decentralized and hierarchical. That’s the reason we have these cool dots in our domain names: Every domain name starts with a top-level domain (TLD), which is the last part. For theresa-tobias.website, this is website. And every individiual domain can be managed by a different DNS server. If we want to find out the IP address for theresa-tobias.website, we first need to find out who to ask. We start with the TLD website. We find out the DNS server for that TLD by querying the DNS root servers, of which there are 13 in the world (you kind of have to know at least one of the from the beginning or you’re fucked). That root server will tell you what the website DNS server’s address. We can then ask that DNS server for theresa-tobias.website and it will tell us that domains DNS server2. Now we can query that server for the IP address of our web server by asking it for the A records.

We could also do this a bunch more times, for example to find out about home-videos.theresa-tobias.website or something3. That’s not the whole story, though. If billions of devices were to make requests to the root DNS servers all the time, they’d have a lot of work to do. That’s why we introduce local DNS servers in networks that take care of that and cache answers. For example, your local router has an integrated DNS server that is configured for your computer through DHCP (a story for another time). Your computer asks that local DNS server and that server either responds directly or queries its DNS server and so forth. That’s why DNS records have TTLs, or time-to-live(s) attached to them, which tell the downstream DNS servers how long a record can be cached. Longer TTLs mean that the load on the original DNS server is reduced, but shorter ones makes it easier to update your records.

Besides making the Internet a whole lot more accessible for us monkeys who can’t remember more than two words at a time, DNS can do a lot of other cool stuff. For example, we can use it to prove we own a domain to get some free swag, authenticate our e-mail (which feels like a terrible idea, how is there not a better way to do this?), or build load balancers to spread our load (🍆💦).

So, why am I telling you all of this?4 Well, I might have some basic knowledge about what DNS can do but I have no idea how it works. OK, I know how it works, but how does it really work? What are the underlying protocols? How does a request look like and stuff? Wouldn’t that be cool to know?5

To find out, I’ll be doing some research, with the goal of implementing my own DNS server. Just a basic one, though. I want it to be able to do one simple thing: answer a dig request for the domain name theresa.is.cute and have it return an A record with 1.2.3.4 as an IPv4 address. Sounds simple enough I guess?

I’ll be using Go (obviously), so let’s just create a new Go project and get started.

mkdir go-dns-server
cd go-dns-server
go mod init github.com/pfandzelter/go-dns-server
touch main.go

I’m guessing there are libraries out there that I could just import and configure to my liking, but that’s not the point of this. Instead, I want to do the following:

  1. open a socket (I’ll have to find out transport layer protocol and port)
  2. on a connection to this socket, read the request and parse it (assuming it’s a DNS request)
  3. if the request is for an A record for theresa.is.cute, answer a DNS compliant 1.2.3.4

A lot of things to find out. Let’s start with the correct transport layer protocol and port. According to Wikipedia (my primary source for this article), DNS uses the User Datagram Protocol (UDP) on port 53. So in the main function of our program, we will open a UDP socket on that port and listen. Thanks to Go’s amazing standard library, this is pretty easy6.

package main

import "net"

func main() {
	// create a "listen" address
	// by specifying just the port (DNS port 53), our programm will use all IP addresses of the computer
	addr, err := net.ResolveUDPAddr("udp", ":53")

	if err != nil {
		print(err.Error())
		return
	}

	// listen on that address
	c, err := net.ListenUDP("udp", addr)

	if err != nil {
		print(err.Error())
		return
	}

	for {
		// create a buffer to get what people send us
		// let's make it 64 bytes long for now?
		var buf [64]byte

		// read from the UDP connection
		c.ReadFromUDP(buf[0:])

		// just print the bytes we got
		for _, b := range buf {
			print(b)
			print(" ")
		}

		print("\n")

	}
}

Obviously, this doesn’t do much yet. It only prints out the bytes it gets and doesn’t answer the client.

If we try it out with dig @localhost theresa.is.cute we get 128 98 1 32 0 1 0 0 0 0 0 1 7 116 104 101 114 101 115 97 2 105 115 4 99 117 116 101 0 0 1 0 1 0 0 41 16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 on our command line. Not really telling us a lot, is it? We need to parse it to find out what it means! Thankfully, the DNS message format is super simple. Let’s have a look at RFC1035 (pp. 24ff) to learn how it works. There is only one message type, it contains five fields: header, question, answer, authority, additional. Super simple stuff.

So, after a break from making spätzle, let’s put this message format to use. Let’s start with the header:

                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      ID                       |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    QDCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ANCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    NSCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ARCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

That means we have the following fields:

  • ID for the query (two bytes)
  • QR, which tells us whether the message is a query (0) or a response (1) (one bit)
  • OPCODE, 0 for a standard query (one bit)
  • AA, is this name server an authority for the domain name in question? (one bit)
  • TC, was this message truncated? (one bit)
  • RD, “Recursion Desired” (one bit)
  • RA, “Recursion Available” (one bit)
  • Z, reserved for future use (four bits)
  • RCODE, the “response code”, useful for errors (four bits)
  • QDCOUNT, number of entries in the “question” field
  • ANCOUNT, number of records in the “answer” field
  • NSCOUNT, number of Boris Palmer’s supporters records in “authority” field
  • ARCOUNT, number of records in the “additional” field

Pretty straightforward, let’s parse the header in our program!

for {
    // we only have 12 bytes to read for the header field
    var buf [12]byte

    // read from the UDP connection
    c.ReadFromUDP(buf[0:])

    fmt.Printf("ID: %x\n", (buf[0]<<8)+buf[1])
    fmt.Printf("QR: %x\n", buf[2]&0x80>>7)
    fmt.Printf("Opcode: %x\n", buf[2]&0x78>>3)
    fmt.Printf("AA: %x\n", buf[2]&0x4>>2)
    fmt.Printf("TC: %x\n", buf[2]&0x2>>1)
    fmt.Printf("RD: %x\n", buf[2]&0x1)
    fmt.Printf("RA: %x\n", buf[3]&0x80>>7)
    fmt.Printf("Z: %x\n", buf[3]&0x70>>4)
    fmt.Printf("RCODE: %x\n", buf[3]&0xF)
    fmt.Printf("QDCOUNT: %x\n", (buf[4]<<8)+buf[5])
    fmt.Printf("ANCOUNT: %x\n", (buf[6]<<8)+buf[7])
    fmt.Printf("NSCOUNT: %x\n", (buf[8]<<8)+buf[9])
    fmt.Printf("ARCOUNT: %x\n", (buf[10]<<8)+buf[11])
}

I’ll just show the excerpted version here, the rest stays the same. We’re putting our twelve byte header into an array and working with it. You’ll see weird things here: bit shifts and bitwise operation. Take the QR field, for instance. We have our header split into bytes because that’s how we get them from the UDP datagram. Let’s say the third byte, which QR is part of, is 11001011. We need to find the first bit of that, so we first set everything else to zero using the bitwise AND:

    11001011
&   10000000 # = 0x80 in hex
=   10000000

Now, we have a big number, but we need to shift this right by 7 bits so we get 00000001 from 10000000. Simple, eh? I think this is called masking or something. Anyway, for the ID field, we don’t have to mask, but we have to add two bytes together to make it a longer number. We take the first part, multiply it by 256 (by shifting it 8 bits to the left), and add the second part (using the bitwise OR). Let’s say the two parts of our ID are 10011000 and 00010000.

            10011000
<<8 1001100000000000
|           00010000
=   1001100000010000

Super simple bit stuff. I think. I might also have gotten it wrong.

So, now we finally have our header figured it out, this is the output we get for dig @localhost theresa.is.cute:

ID: 7e
QR: 0
Opcode: 0
AA: 0
TC: 0
RD: 1
RA: 0
Z: 2
RCODE: 0
QDCOUNT: 1
ANCOUNT: 0
NSCOUNT: 0
ARCOUNT: 1

Makes sense, I guess. We have an ID, the QR is 0 for “question” and our QDCOUNT indicates one question. Let’s read that question:

                                   1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                     QNAME                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QTYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QCLASS                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

We have a QNAME with a domain name represented as a series of labels, a QTYPE to specify the type of the query, and a QCLASS for the query class. The hard part is that QNAME can be of variable size. That’s why we get a byte that tells us the length of a label, then the label, then the next length byte, etc., until we reach a length byte that is all zeroes to indicate we’re at the end (the “root” label is of length zero). Should be doable, I guess?

// we'll make it 512 bytes long now because we don't know how long the content is
// actually, according to RFC1035 §4.2.1 the messages carried are restricted to 512 bytes
var buf [512]byte

// read from the UDP connection
c.ReadFromUDP(buf[0:])

// --- QUESTION FIELD ---

// our helpful pointer to guide us through the field
// starting after the 12 byte header
i := 12
for {
    // get the length of the next label from the length byte
    length := int(buf[i])

    // if we have a length of size 0, we have reached the root
    if length == 0 {
        i = i + 1
        break
    }

    // read as far as the length has told us
    label := ""
    for j := i + 1; j <= i+length; j++ {
        label += string(buf[j])
    }
    fmt.Printf("%s\n", label)

    i = i + length
    i = i + 1
}

fmt.Printf("QTYPE: %x\n", (buf[i]<<8)|buf[i+1])
i = i + 2
fmt.Printf("QCLASS: %x\n", (buf[i]<<8)|buf[i+1])
i = i + 2

Let’s check our output again:

ID: d9
QR: 0
Opcode: 0
AA: 0
TC: 0
RD: 1
RA: 0
Z: 2
RCODE: 0
QDCOUNT: 1
ANCOUNT: 0
NSCOUNT: 0
ARCOUNT: 1
theresa
is
cute
QTYPE: 1
QCLASS: 1

Do you see that??? It works! We get the individual labels of theresa.is.cute, even in order! No idea what the QTYPE and QCLASS things are for but who cares?

Now all that’s left to do is build our response. We’ll have to get the address of our sender and send them a well-built response message. This is what the resource record format looks like:

                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                                               /
    /                      NAME                     /
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     CLASS                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TTL                      |
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                   RDLENGTH                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
    /                     RDATA                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

We have the following things to take care of:

  • the NAME, which is the domain name (duh) (variable length)
  • the TYPE, one of the RR type codes (two bytes)
  • CLASS, class of the data (two bytes)
  • TTL, time-to-live for the record (four bytes)
  • RDLENGTH, number of bytes in the RDATA field (two bytes)
  • RDATA, the actual data (variable length)

I don’t quite understand how the client knows how long the NAME field is supposed to be but maybe it just remembers? With these fields in mind, let’s go!

package main

import (
	"fmt"
	"net"
)

func main() {
	// create a "listen" address
	// by specifying just the port (DNS port 53), our programm will use all IP addresses of the computer
	addr, err := net.ResolveUDPAddr("udp", ":53")

	if err != nil {
		print(err.Error())
		return
	}

	// listen on that address
	c, err := net.ListenUDP("udp", addr)

	if err != nil {
		print(err.Error())
		return
	}

	for {
		// we'll make it 512 bytes long now because we don't know how long the content is
		var que [512]byte

		// read from the UDP connection
		// also save the host that sent use the query
		// thanks host!
		_, host, err := c.ReadFromUDP(que[0:])

		if err != nil {
			print(err.Error())
			return
		}

		// --- HEADER ---
		fmt.Printf("ID: %x\n", (que[0]<<8)|que[1])
		fmt.Printf("QR: %x\n", que[2]&0x80>>7)
		fmt.Printf("Opcode: %x\n", que[2]&0x78>>3)
		fmt.Printf("AA: %x\n", que[2]&0x4>>2)
		fmt.Printf("TC: %x\n", que[2]&0x2>>1)
		fmt.Printf("RD: %x\n", que[2]&0x1)
		fmt.Printf("RA: %x\n", que[3]&0x80>>7)
		fmt.Printf("Z: %x\n", que[3]&0x70>>4)
		fmt.Printf("RCODE: %x\n", que[3]&0xF)
		fmt.Printf("QDCOUNT: %x\n", (que[4]<<8)|que[5])
		fmt.Printf("ANCOUNT: %x\n", (que[6]<<8)|que[7])
		fmt.Printf("NSCOUNT: %x\n", (que[8]<<8)|que[9])
		fmt.Printf("ARCOUNT: %x\n", (que[10]<<8)|que[11])

		// --- QUESTION FIELD ---
		// our helpful pointer to guide us through the field
		// starting after the 12 byte header
		i := 12

		// this is where we store the labels that are requested
		labels := make([]string, 0)

		for {
			// get the length of the next label from the length byte
			length := int(que[i])

			// if we have a length of size 0, we have reached the root
			if length == 0 {
				i = i + 1
				break
			}

			// read as far as the length has told us
			label := ""
			for j := i + 1; j <= i+length; j++ {
				label += string(que[j])
			}
			fmt.Printf("%s\n", label)

			labels = append(labels, label)

			i = i + length
			i = i + 1
		}

		fmt.Printf("QTYPE: %x\n", (que[i]<<8)|que[i+1])
		i = i + 2
		fmt.Printf("QCLASS: %x\n", (que[i]<<8)|que[i+1])
		i = i + 2

		// --- LOGIC ---
		// let's check if the domain name we are queried about is the one we know about
		// if it's not "theresa.is.cute", we just abort and don't send anything in return
		if len(labels) != 3 || labels[0] != "theresa" || labels[1] != "is" || labels[2] != "cute" {
			continue
		}

		// seems to be fine!
		// let's get our IP address "1.2.3.4" ready
		// we need to put it into 32 bits, since it's IPv4
		// (32 bits is 4 bytes)
		addr := []byte{0x1, 0x2, 0x3, 0x4}

		// -- RESPONSE FIELD --
		// create a new byte array for the response
		// we actually need most of the data from the query, so let's use it again
		res := que[:]
		// but only the part until after the question field! not any additional fields
		// set the ARCOUNT to 0 and everything after the query field as well
		res[10] = 0x0
		res[11] = 0x0

		// let's use our trusty pointer i again
		for j := i; j < len(res); j++ {
			res[j] = 0x0
		}

		// but don't forget to set our QR to 1 now!
		res[2] = res[2] | 0x80
		// also pretend we're authoritative
		res[2] = res[2] | 0x4
		// we have one answer, so set ANCOUNT to 1
		res[6] = 0x1 >> 8
		res[7] = 0x1

		// we have our NAME field first
		for _, l := range labels {
			// write in the length of the label
			res[i] = byte(len(l))
			i = i + 1
			// now write the individual characters
			// we actually need it to be a byte array instead of a string
			// because strings are made up of runes in Go, which are 4 bytes each
			for _, c := range []byte(l) {
				res[i] = byte(c)
				i = i + 1
			}
		}
		// remember the root label with length 0? this is him now:
		res[i] = 0x0
		i = i + 1

		// now the TYPE field!
		// we have an IPv4 address so we need the "A" type, which has the value 1
		res[i] = 0x1 >> 8
		res[i+1] = 0x1
		i = i + 2

		// and the CLASS!
		// this one has weird values:
		// IN	1 the Internet
		// CS	2 the CSNET class (Obsolete - used only for examples in
		//                 some obsolete RFCs)
		// CH	3 the CHAOS class
		// HS	4 Hesiod [Dyer 87]
		// let's use 1 again?
		res[i] = 0x1 >> 8
		res[i+1] = 0x1
		i = i + 2

		// for the TTL, we can get creative
		// let's go for 42
		// but remember that this is 64 bits!
		res[i] = 0x2A >> 32
		res[i+1] = 0x2A >> 16
		res[i+2] = 0x2A >> 8
		res[i+3] = 0x2A
		i = i + 4

		// now the RDLENGTH
		// for A records, RDATA is simply the IPv4 address, which is pretty easy
		// since IPv4 addresses are always 32 bits long, we always have the same RDLENGTH of 4 bytes
		res[i] = 0x4 >> 8
		res[i+1] = 0x4
		i = i + 2

		// and finally: the RDATA!
		res[i] = addr[0]
		res[i+1] = addr[1]
		res[i+2] = addr[2]
		res[i+3] = addr[3]
		i = i + 4

		// now just send it off!
		_, err = c.WriteToUDP(res[:i], host)

		if err != nil {
			print(err.Error())
			return
		}
	}
}

And here is our final program! Let’s see if it works with dig @localhost theresa.is.cute again:

$ dig @localhost theresa.is.cute

; <<>> DiG 9.10.6 <<>> @localhost theresa.is.cute
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 52635
;; flags: qr aa rd ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available

;; QUESTION SECTION:
;theresa.is.cute.		IN	A

;; ANSWER SECTION:
theresa.is.cute.	42	IN	A	1.2.3.4

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Sun Dec 20 20:29:01 CET 2020
;; MSG SIZE  rcvd: 64

Amazing7 With these newly acquired skills we could easily build our very own DNS server from scratch. Or do something useful.


  1. nice ↩︎

  2. in our case, it will give us Hover’s DNS servers - remember how we used Hover to configure our domain? It’s all connected! ↩︎

  3. Disclaimer: not a real thing (yet?) ↩︎

  4. or myself, for that matter ↩︎

  5. a purely rhetorical question ↩︎

  6. I have no interest in rolling my own transport, I’m fine with using something available ↩︎

  7. ok, not really. It took me a lot of time to debug and get it working correctly. First I forgot about terminating the second DNS name with the 00 byte, then incorrectly parsing the question field resulted in an off-by-one error :( ↩︎