Building a Recursive DNS Resolver

Friday, 29 July 2022

Recently I’ve been thinking about how DNS really works under the hood. You ask a resolver for an IP, but how does the resolver know what IP maps to a domain? I figured the best way to learn was to build a resolver myself, and it turned out to be fairly simple and instructive.

But first, some background!

The Domain Name System

The domain name system (or DNS) underpins the internet, converting the domain names you’d typically use to connect to a website, to IP addresses for the servers those websites are hosted on. Conceptually you can think of the entire domain name system as a distributed key-value store mapping domains (and sub-domains) to IP addresses:

Domain IP Address
news.ycombinator.com 50.112.136.166
wikipedia.org 91.198.174.192
en.wikipedia.org 91.198.174.192

When you type something like “google.com” into your browser and hit enter, your device must query a known recursive resolver to find google.com’s IP address. Recursive resolvers are provided by most ISPs, but resolvers like 1.1.1.1 or 8.8.8.8 exist as well.

You can query a resolver manually using something like dig or dog:

❯ dig @1.1.1.1 news.ycombinator.com

; <<>> DiG 9.18.4-2-Debian <<>> @1.1.1.1 news.ycombinator.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 9149
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;news.ycombinator.com.		IN	A

;; ANSWER SECTION:
news.ycombinator.com.	0	IN	A	50.112.136.166

;; Query time: 24 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; WHEN: Thu Jul 28 01:31:59 EDT 2022
;; MSG SIZE  rcvd: 65

The ANSWER SECTION contains an IP address for the domain we requested. How does this work? How does a recursive resolver know what IP a domain uses?

The short answer is that resolvers aren’t in charge of the domain->IP mapping at all. Resolvers are simply a querying interface to DNS' distributed database, which is stored on nameservers. If you think of the entire DNS namespace as a tree, each nameserver is an authority for a given subtree, and each such subtree is called a zone.

Any given domain has a single unique path through this tree, starting at any node and walking up to the root. Here’s news.ycombinator.com, for example:

Note that the root node is denoted by a ., so the fully-qualified version (or FQDN) of a domain name ends with a . – the FQDN for “news.ycombinator.com” is actually news.ycombinator.com.

Recursive resolvers must also walk this tree to resolve a given domain, but starting at the root node. 13 root nameservers exist (which have authority over the root zone .), and have well-known IPs:

Root Server IP
a.root-servers.net 198.41.0.4
b.root-servers.net 199.9.14.201
c.root-servers.net 192.33.4.12
d.root-servers.net 199.7.91.13
e.root-servers.net 192.203.230.10
f.root-servers.net 192.5.5.241
g.root-servers.net 192.112.36.4
h.root-servers.net 198.97.190.53
i.root-servers.net 192.36.148.17
j.root-servers.net 192.58.128.30
k.root-servers.net 193.0.14.129
l.root-servers.net 199.7.83.42
m.root-servers.net 202.12.27.33

Recursive Resolution By Hand

Let’s see if we can resolve news.ycombinator.com by walking this tree manually, starting at one of these root servers:

❯ dig @192.36.148.17 news.ycombinator.com

; <<>> DiG 9.18.4-2-Debian <<>> @192.36.148.17 news.ycombinator.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 57339
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 13, ADDITIONAL: 27
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: a4d4b6701afda21d0100000062e3db51b71dabe7bfe43d28 (good)
;; QUESTION SECTION:
;news.ycombinator.com.		IN	A

;; AUTHORITY SECTION:
com.			172800	IN	NS	f.gtld-servers.net.
com.			172800	IN	NS	g.gtld-servers.net.
com.			172800	IN	NS	e.gtld-servers.net.
com.			172800	IN	NS	k.gtld-servers.net.
com.			172800	IN	NS	j.gtld-servers.net.
com.			172800	IN	NS	b.gtld-servers.net.
com.			172800	IN	NS	a.gtld-servers.net.
com.			172800	IN	NS	m.gtld-servers.net.
com.			172800	IN	NS	d.gtld-servers.net.
com.			172800	IN	NS	i.gtld-servers.net.
com.			172800	IN	NS	h.gtld-servers.net.
com.			172800	IN	NS	c.gtld-servers.net.
com.			172800	IN	NS	l.gtld-servers.net.

;; ADDITIONAL SECTION:
m.gtld-servers.net.	172800	IN	A	192.55.83.30
l.gtld-servers.net.	172800	IN	A	192.41.162.30
k.gtld-servers.net.	172800	IN	A	192.52.178.30
j.gtld-servers.net.	172800	IN	A	192.48.79.30
i.gtld-servers.net.	172800	IN	A	192.43.172.30
h.gtld-servers.net.	172800	IN	A	192.54.112.30
g.gtld-servers.net.	172800	IN	A	192.42.93.30
f.gtld-servers.net.	172800	IN	A	192.35.51.30
e.gtld-servers.net.	172800	IN	A	192.12.94.30
d.gtld-servers.net.	172800	IN	A	192.31.80.30
c.gtld-servers.net.	172800	IN	A	192.26.92.30
b.gtld-servers.net.	172800	IN	A	192.33.14.30
a.gtld-servers.net.	172800	IN	A	192.5.6.30
m.gtld-servers.net.	172800	IN	AAAA	2001:501:b1f9::30
l.gtld-servers.net.	172800	IN	AAAA	2001:500:d937::30
k.gtld-servers.net.	172800	IN	AAAA	2001:503:d2d::30
j.gtld-servers.net.	172800	IN	AAAA	2001:502:7094::30
i.gtld-servers.net.	172800	IN	AAAA	2001:503:39c1::30
h.gtld-servers.net.	172800	IN	AAAA	2001:502:8cc::30
g.gtld-servers.net.	172800	IN	AAAA	2001:503:eea3::30
f.gtld-servers.net.	172800	IN	AAAA	2001:503:d414::30
e.gtld-servers.net.	172800	IN	AAAA	2001:502:1ca1::30
d.gtld-servers.net.	172800	IN	AAAA	2001:500:856e::30
c.gtld-servers.net.	172800	IN	AAAA	2001:503:83eb::30
b.gtld-servers.net.	172800	IN	AAAA	2001:503:231d::2:30
a.gtld-servers.net.	172800	IN	AAAA	2001:503:a83e::2:30

;; Query time: 28 msec
;; SERVER: 192.36.148.17#53(192.36.148.17) (UDP)
;; WHEN: Thu Jul 28 02:22:47 EDT 2022
;; MSG SIZE  rcvd: 876

The root nameserver has not provided us with an answer (no ANSWER SECTION). We’re provided instead with the names and IPs of the nameservers in charge of the com. zone. Let’s see if one of these nameservers knows the IP for news.ycombinator.com:

❯ dig @192.41.162.30 news.ycombinator.com

; <<>> DiG 9.18.4-2-Debian <<>> @192.41.162.30 news.ycombinator.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 52169
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 4, ADDITIONAL: 2
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;news.ycombinator.com.		IN	A

;; AUTHORITY SECTION:
ycombinator.com.	172800	IN	NS	ns-225.awsdns-28.com.
ycombinator.com.	172800	IN	NS	ns-556.awsdns-05.net.
ycombinator.com.	172800	IN	NS	ns-1914.awsdns-47.co.uk.
ycombinator.com.	172800	IN	NS	ns-1411.awsdns-48.org.

;; ADDITIONAL SECTION:
ns-225.awsdns-28.com.	172800	IN	A	205.251.192.225

;; Query time: 32 msec
;; SERVER: 192.41.162.30#53(192.41.162.30) (UDP)
;; WHEN: Thu Jul 28 02:23:25 EDT 2022
;; MSG SIZE  rcvd: 202

Still no ANSWER SECTION, but we’re getting closer! An authoritative nameserver for the zone com. has given us the names and IPs of nameservers that have authority over the ycombinator.com. zone. Let’s recurse one level deeper:

❯ dig @205.251.192.225 news.ycombinator.com

; <<>> DiG 9.18.4-2-Debian <<>> @205.251.192.225 news.ycombinator.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 17560
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 4, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;news.ycombinator.com.		IN	A

;; ANSWER SECTION:
news.ycombinator.com.	1	IN	A	50.112.136.166

;; AUTHORITY SECTION:
ycombinator.com.	172800	IN	NS	ns-1411.awsdns-48.org.
ycombinator.com.	172800	IN	NS	ns-1914.awsdns-47.co.uk.
ycombinator.com.	172800	IN	NS	ns-225.awsdns-28.com.
ycombinator.com.	172800	IN	NS	ns-556.awsdns-05.net.

;; Query time: 56 msec
;; SERVER: 205.251.192.225#53(205.251.192.225) (UDP)
;; WHEN: Thu Jul 28 02:24:33 EDT 2022
;; MSG SIZE  rcvd: 202

We now finally have an ANSWER SECTION, which tells us that 50.112.136.166 is the IP address for news.ycombinator.com.. Let’s compare by asking a known recursive resolver:

❯ dig +short @1.1.1.1 news.ycombinator.com
50.112.136.166

Here’s an illustration of what we just did:

Assuming no prior caching, we only know the IPs of the root servers, so we start from there and recurse downwards until we get to a nameserver that’s an authority for the zone the target domain is in.

Writing a Recursive Resolver

At its core, a recursive resolver does what we just did by hand: start at a root nameserver, and recurse downward until it receives an answer.

The best way to understand this properly is to actually build a simple recursive resolver; let’s do that using Go. We’ll use the dns library to serialize to/from the DNS wire format so we can focus on the actual recursive operation of the resolver.

To start, query a root nameserver, look in the response' ADDITIONAL SECTION for the IP addresses of the nameservers one level lower in the tree, and repeat until we receive an ANSWER SECTION:

func resolve(name string) ([]dns.RR, error) {
  nameserver := "198.41.0.4"
  c := new(dns.Client)

  for {
    // Prepare a message asking for an A record (an IP address) for `name`
    m := new(dns.Msg)
    m.SetQuestion(dns.Fqdn(name), dns.TypeA)

    // Send the DNS request to the IP in `nameserver`
    fmt.Printf("Asking %s about %s\n", nameserver, name)
    resp, _, err := c.Exchange(m, fmt.Sprintf("%s:53", nameserver))
    if err != nil {
      return nil, err
    }

    // If an ANSWER SECTION exists, we're done
    if len(resp.Answer) > 0 {
      return resp.Answer, nil
    }

    // If an ADDITIONAL SECTION exists, look in it for an A record for the
    // next-level nameserver. If one doesn't exist, we have to error out
    found := false
    for _, rr := range resp.Extra {
      record, ok := rr.(*dns.A)
      if ok {
        nameserver = record.A.String()
        found = true
        break
      }
    }
    if !found {
      return nil, fmt.Errorf("break in the chain")
    }

    // ... and recurse!
  }
}

And that’s pretty much it! There are some cases this doesn’t handle (which we’ll cover below), but this is all you need to recursively resolve basic DNS requests. Let’s wire this up by accepting CLI arguments:

func main() {
  if len(os.Args) != 2 {
    fmt.Fprintf(os.Stderr, "usage: dns <name>\n")
    os.Exit(1)
  }

  name := os.Args[1]
  answer, err := resolve(name)
  if err == nil {
    for _, record := range answer {
      fmt.Println(record)
    }
  } else {
    fmt.Fprintf(os.Stderr, "Failed to resolve %s\n", name)
    os.Exit(1)
  }
}

And give it a shot:

❯ go run dns.go news.ycombinator.com
Asking 198.41.0.4 about news.ycombinator.com
Asking 192.12.94.30 about news.ycombinator.com
Asking 205.251.192.225 about news.ycombinator.com
news.ycombinator.com.	1	IN	A	50.112.136.166

It works! The resolver found the IP address for news.ycombinator.com with no prior knowledge except the addresses of the root nameservers. Let’s address a couple of edge cases next.

Additional Sections and CNAMEs

In the example above, each nameserver in the chain provided the IP addresses (A records) of all the nameservers in the next level of the tree. This isn’t always the case, though:

❯ go run dns.go twitter.com
Asking 198.41.0.4 about twitter.com
Asking 192.12.94.30 about twitter.com
Failed to resolve twitter.com
exit status 1

Running the final query manually:

❯ dig @192.12.94.30 twitter.com

; <<>> DiG 9.18.4-2-Debian <<>> @192.12.94.30 twitter.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 27129
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 8, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;twitter.com.			IN	A

;; AUTHORITY SECTION:
twitter.com.		172800	IN	NS	ns3.p34.dynect.net.
twitter.com.		172800	IN	NS	ns4.p34.dynect.net.
twitter.com.		172800	IN	NS	d01-01.ns.twtrdns.net.
twitter.com.		172800	IN	NS	d01-02.ns.twtrdns.net.
twitter.com.		172800	IN	NS	a.r06.twtrdns.net.
twitter.com.		172800	IN	NS	b.r06.twtrdns.net.
twitter.com.		172800	IN	NS	c.r06.twtrdns.net.
twitter.com.		172800	IN	NS	d.r06.twtrdns.net.

;; Query time: 104 msec
;; SERVER: 192.12.94.30#53(192.12.94.30) (UDP)
;; WHEN: Thu Jul 28 02:57:55 EDT 2022
;; MSG SIZE  rcvd: 211

This nameserver sent us a list of nameservers that are authoritative for twitter.com., but didn’t include their IP addresses in an ADDITIONAL SECTION.

What do we do? Luckily, we just built a system to convert domain names to IP addresses! We can resolve one of these names to an IP address and use that as the next-level nameserver:

// If the ADDITIONAL SECTION is empty and the AUTHORITY SECTION is not, resolve
// one of the names in the AUTHORITY SECTION and have that be the nameserver
if len(resp.Extra) == 0 && len(resp.Ns) != 0 {
  ns := resp.Ns[0].(*dns.NS)
  nsIP, err := resolve(ns.Ns)
  if err != nil {
    return nil, fmt.Errorf("break in the chain")
  }
  nameserver = nsIP[0].(*dns.A).A.String()
}

Trying it out:

❯ go run dns.go twitter.com
Asking 198.41.0.4 about twitter.com
Asking 192.12.94.30 about twitter.com
Asking 198.41.0.4 about ns3.p34.dynect.net.
Asking 192.12.94.30 about ns3.p34.dynect.net.
Asking 108.59.161.136 about ns3.p34.dynect.net.
Asking 108.59.163.34 about twitter.com
twitter.com.	1800	IN	A	104.244.42.129
twitter.com.	1800	IN	A	104.244.42.65

So far so good. But what about CNAMEs? These DNS records allow aliasing one name to another name. Here pages.github.com is an alias for github.github.io:

❯ go run dns.go pages.github.com
Asking 198.41.0.4 about pages.github.com
Asking 192.12.94.30 about pages.github.com
Asking 205.251.193.165 about pages.github.com
pages.github.com.	3600	IN	CNAME	github.github.io.

A recursive resolver should resolve the CNAME and provide its IP addresses, so let’s implement that:

if len(resp.Answer) > 0 {
  // If an ANSWER SECTION exists and contains a CNAME, recurse
  if cname, ok := resp.Answer[0].(*dns.CNAME); ok {
    return resolve(cname.Target)
  }

  // If an ANSWER SECTION exists, we're done
  return resp.Answer, nil
}

And now we can resolve CNAMEs:

❯ go run dns.go pages.github.com
Asking 198.41.0.4 about pages.github.com
Asking 192.12.94.30 about pages.github.com
Asking 205.251.193.165 about pages.github.com
Asking 198.41.0.4 about github.github.io.
Asking 65.22.161.17 about github.github.io.
Asking 198.41.0.4 about dns1.p05.nsone.net.
Asking 192.12.94.30 about dns1.p05.nsone.net.
Asking 198.51.44.1 about dns1.p05.nsone.net.
Asking 198.51.44.5 about github.github.io.
github.github.io.	3600	IN	A	185.199.108.153
github.github.io.	3600	IN	A	185.199.109.153
github.github.io.	3600	IN	A	185.199.110.153
github.github.io.	3600	IN	A	185.199.111.153

Wrapping Up

That’s it! This resolver isn’t anywhere near complete (error handling, caching, other record types, and IPv6, to name just a few omissions), but I hope it helps you understand how DNS resolution really works under the hood!

You can find the code for the entire resolver here.

Edit