Chapter 18 — DNS: turning names into addresses
In one sentence
DNS is a giant, distributed, cached phone book that turns example.com into
93.184.216.34 — and it is involved in more outages than almost anything else.
The problem
You remember example.com. The network needs 93.184.216.34. Something must
translate — for hundreds of millions of names, changing constantly, for billions
of queries per second, with no single machine holding the list.
The idea, in plain language
Delegate. Nobody knows everything; everybody knows who to ask next.
You: "Who is www.example.com?"
ROOT SERVER: "No idea. But I know who runs .com -- ask them."
.COM SERVER: "No idea. But I know who runs example.com -- ask them."
EXAMPLE.COM SERVER: "That's me. www.example.com is 93.184.216.34."
Three questions, each to a more specific authority. Exactly like finding a person by asking the country, then the city, then the street.
The name hierarchy
Read a domain name right to left:
www . example . com .
| | | |
| | | +-- the ROOT (usually invisible; the trailing dot)
| | +------ TOP-LEVEL DOMAIN (TLD): com, org, uk, io
| +--------------- the DOMAIN somebody registered
+---------------------- a HOST or subdomain within it
The four players
| Role | What it does | Example |
|---|---|---|
| Stub resolver | The tiny client inside your OS. Asks one server, caches a little. | Your laptop |
| Recursive resolver | Does all the legwork (root → TLD → authoritative), caches everything | Your router, your ISP, 1.1.1.1, 8.8.8.8 |
| Root servers | Know who runs each TLD. 13 logical addresses, hundreds of physical machines. | a.root-servers.net |
| Authoritative server | Holds the real answers for a specific domain | ns1.example.com |
Your device almost never talks to root servers. It asks one recursive resolver, which does everything else.
Caching is what makes it work
Every answer comes with a TTL (time to live) — how long you may remember it.
First lookup of example.com: 3 or 4 round trips, maybe 100+ ms
Next lookup within the TTL: 0 round trips -- served from cache
Caching happens at every level: your browser, your OS, your router, your ISP's resolver. This is why the internet doesn't collapse.
The operational consequence you must know: if you change a DNS record, the old answer stays cached everywhere until its TTL expires. So before a migration, lower the TTL (to e.g. 60 seconds) and wait for the old TTL to pass. Then make the change. Then raise it again.
The record types you'll actually meet
| Type | Meaning | Example |
|---|---|---|
| A | name → IPv4 address | example.com. A 93.184.216.34 |
| AAAA | name → IPv6 address | example.com. AAAA 2606:2800:... |
| CNAME | this name is an alias for that name | www.example.com. CNAME example.com. |
| MX | mail servers for this domain | example.com. MX 10 mail.example.com. |
| NS | which servers are authoritative | example.com. NS ns1.example.com. |
| TXT | arbitrary text — used for SPF, DKIM, domain verification | |
| PTR | address → name (reverse lookup) | |
| SOA | admin info for the zone, including the negative-cache TTL |
Practice (35 min) — this is the most useful tool chapter in Part 4
A. Basic lookups:
dig example.com # full output -- read the ANSWER section
dig example.com +short # just the answer
dig example.com A +short
dig example.com AAAA +short
dig example.com MX +short
dig example.com NS +short
dig example.com TXT +short
B. Watch the whole delegation chain. This is the single best DNS learning command:
dig +trace example.com
You will literally see it start at the root servers, get referred to .com, then
to example.com's own servers, then get the answer. Run this now. It makes
the whole chapter concrete.
C. See caching in action:
dig example.com | grep -E "Query time|ANSWER SECTION" -A2
# run it again immediately
dig example.com | grep "Query time"
The second query time should be dramatically lower — and watch the TTL count down between runs.
D. Ask a specific server (bypasses your local cache):
dig @1.1.1.1 example.com +short # Cloudflare
dig @8.8.8.8 example.com +short # Google
dig @ns1.example.com example.com +short # straight to the source (authoritative)
If different servers give different answers, you've found a propagation or split-horizon issue.
E. Reverse lookup:
dig -x 93.184.216.34 +short
F. Watch DNS on the wire:
sudo tcpdump -i any -n 'udp port 53'
# in another terminal:
dig example.com
You'll see the query go out and the response come back — usually one packet each.
G. See your own resolver settings:
cat /etc/resolv.conf # Linux
scutil --dns | head -20 # macOS
resolvectl status # Linux with systemd-resolved
DNS over UDP, TCP, and encrypted
- Normally UDP port 53 — one small packet each way, fast.
- If the answer is too big, the server sets a "truncated" flag and the client retries over TCP port 53. You must allow TCP/53 through firewalls; not doing so causes bizarre intermittent failures.
- DoT (DNS over TLS, port 853) and DoH (DNS over HTTPS, port 443) encrypt your queries. DoH is hard to distinguish from normal web traffic — good for privacy, awkward for network administrators who rely on internal DNS.
The classic DNS problems
| Symptom | Likely cause |
|---|---|
| "It works on my machine but not theirs" | DNS cached differently; different resolver |
| Site works by IP but not by name | DNS failure — test with dig |
| Change made but not visible | TTL hasn't expired; check with dig against multiple resolvers |
| Intermittent slow page loads | One of the configured DNS servers is dead; each lookup waits ~5 s before trying the next |
| Works externally, fails internally (or vice versa) | Split-horizon DNS, or a VPN not pushing internal DNS |
| "DNS_PROBE_FINISHED_NXDOMAIN" | The name genuinely doesn't exist (or a typo) |
A useful habit: when anything network-related breaks, test DNS first with
dig, and test raw connectivity separately with an IP address. That immediately
splits the problem in half.
Build it in Go (60 min) — a DNS client from raw bytes
dig is a wrapper around a surprisingly small protocol. This program builds a
DNS query byte by byte, sends it in one UDP datagram, and decodes the
reply, including the "message compression" pointers that confuse everyone the
first time. It uses no DNS library: only encoding/binary and a UDP socket.
The wire format you're implementing (RFC 1035):
HEADER (12 bytes) ID | flags (QR, RD, RA, TC, rcode) | QDCOUNT | ANCOUNT | NSCOUNT | ARCOUNT
QUESTION name as labels: 7 e x a m p l e 3 c o m 0 | QTYPE (A=1) | QCLASS (IN=1)
ANSWER records name (often a 2-byte POINTER: 0xC0 0x0C) | TYPE | CLASS | TTL | RDLENGTH | RDATA
Save as dnsquery/main.go:
// dnsquery: build a DNS query by hand, send it over UDP, and decode the answer.
// No DNS library -- just the bytes RFC 1035 describes.
//
// go run ./dnsquery example.com
// go run ./dnsquery -type AAAA -server 8.8.8.8 example.com
// go run ./dnsquery -type CNAME www.github.com
package main
import (
"encoding/binary"
"errors"
"flag"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"time"
)
var qtypes = map[string]uint16{"A": 1, "NS": 2, "CNAME": 5, "MX": 15, "TXT": 16, "AAAA": 28}
func main() {
server := flag.String("server", "1.1.1.1", "recursive resolver to ask")
qtype := flag.String("type", "A", "record type: A, AAAA, CNAME, NS, MX, TXT")
flag.Parse()
if flag.NArg() != 1 {
fmt.Println("usage: dnsquery [-server IP] [-type T] name")
os.Exit(2)
}
t, ok := qtypes[strings.ToUpper(*qtype)]
if !ok {
fmt.Println("unsupported type", *qtype)
os.Exit(2)
}
id := uint16(rand.N(1 << 16))
query := buildQuery(id, flag.Arg(0), t)
fmt.Printf("query: %d bytes % x ...\n", len(query), query[:12])
conn, err := net.Dial("udp", net.JoinHostPort(*server, "53"))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second)) // UDP has no retries: we own the timeout
start := time.Now()
if _, err := conn.Write(query); err != nil {
fmt.Println(err)
os.Exit(1)
}
buf := make([]byte, 1232) // the EDNS-era "safe" UDP DNS size
n, err := conn.Read(buf)
if err != nil {
fmt.Println("no answer:", err)
os.Exit(1)
}
fmt.Printf("reply: %d bytes from %s in %v\n\n", n, *server, time.Since(start).Round(time.Microsecond))
if err := printReply(buf[:n], id); err != nil {
fmt.Println("bad reply:", err)
os.Exit(1)
}
}
// buildQuery writes the 12-byte header plus one question.
func buildQuery(id uint16, name string, qtype uint16) []byte {
b := make([]byte, 12, 512)
binary.BigEndian.PutUint16(b[0:], id)
binary.BigEndian.PutUint16(b[2:], 0x0100) // flags: RD=1 ("recursion desired")
binary.BigEndian.PutUint16(b[4:], 1) // QDCOUNT = 1 question
// a name is a sequence of length-prefixed labels: 7example3com0
for _, label := range strings.Split(strings.TrimSuffix(name, "."), ".") {
b = append(b, byte(len(label)))
b = append(b, label...)
}
b = append(b, 0)
b = binary.BigEndian.AppendUint16(b, qtype)
b = binary.BigEndian.AppendUint16(b, 1) // class IN (internet)
return b
}
func printReply(msg []byte, wantID uint16) error {
if len(msg) < 12 {
return errors.New("short header")
}
if id := binary.BigEndian.Uint16(msg[0:]); id != wantID {
return fmt.Errorf("ID mismatch: sent %d got %d (spoofed or stale reply?)", wantID, id)
}
flags := binary.BigEndian.Uint16(msg[2:])
rcode := flags & 0xF
qd, an := binary.BigEndian.Uint16(msg[4:]), binary.BigEndian.Uint16(msg[6:])
fmt.Printf("flags: QR=%d AA=%d TC=%d RD=%d RA=%d rcode=%s answers=%d\n",
flags>>15&1, flags>>10&1, flags>>9&1, flags>>8&1, flags>>7&1, rcodeName(rcode), an)
if flags>>9&1 == 1 {
fmt.Println("TC=1: answer truncated -- a real resolver now retries over TCP")
}
off := 12
for i := 0; i < int(qd); i++ { // skip the echoed question
_, next, err := readName(msg, off)
if err != nil {
return err
}
off = next + 4
}
for i := 0; i < int(an); i++ {
name, next, err := readName(msg, off)
if err != nil {
return err
}
if next+10 > len(msg) {
return errors.New("truncated record header")
}
typ := binary.BigEndian.Uint16(msg[next:])
ttl := binary.BigEndian.Uint32(msg[next+4:])
rdlen := int(binary.BigEndian.Uint16(msg[next+8:]))
rdata := next + 10
if rdata+rdlen > len(msg) {
return errors.New("truncated rdata")
}
fmt.Printf("%-30s TTL=%-6d %-5s %s\n", name, ttl, typeName(typ), rdataString(msg, typ, rdata, rdlen))
off = rdata + rdlen
}
return nil
}
// readName decodes a possibly-compressed name. A byte >= 0xC0 starts a
// 2-byte pointer to an earlier offset: that is DNS "message compression".
func readName(msg []byte, off int) (string, int, error) {
var labels []string
next := -1 // where parsing continues after the name (set at first pointer)
for hops := 0; hops < 20; hops++ {
if off >= len(msg) {
return "", 0, errors.New("name runs past end")
}
l := int(msg[off])
switch {
case l == 0:
if next < 0 {
next = off + 1
}
return strings.Join(labels, ".") + ".", next, nil
case l&0xC0 == 0xC0:
if off+1 >= len(msg) {
return "", 0, errors.New("bad pointer")
}
if next < 0 {
next = off + 2
}
off = int(binary.BigEndian.Uint16(msg[off:]) & 0x3FFF)
default:
if off+1+l > len(msg) {
return "", 0, errors.New("label runs past end")
}
labels = append(labels, string(msg[off+1:off+1+l]))
off += 1 + l
}
}
return "", 0, errors.New("compression loop")
}
func rdataString(msg []byte, typ uint16, off, n int) string {
switch typ {
case 1, 28: // A, AAAA
return net.IP(msg[off : off+n]).String()
case 2, 5: // NS, CNAME
name, _, err := readName(msg, off)
if err != nil {
return "?"
}
return name
case 15: // MX: preference + name
name, _, _ := readName(msg, off+2)
return fmt.Sprintf("%d %s", binary.BigEndian.Uint16(msg[off:]), name)
case 16: // TXT: one or more length-prefixed strings
var parts []string
for i := off; i < off+n; {
l := int(msg[i])
if i+1+l > off+n {
break
}
parts = append(parts, fmt.Sprintf("%q", msg[i+1:i+1+l]))
i += 1 + l
}
return strings.Join(parts, " ")
}
return fmt.Sprintf("% x", msg[off:off+n])
}
func typeName(t uint16) string {
for k, v := range qtypes {
if v == t {
return k
}
}
return fmt.Sprint(t)
}
func rcodeName(r uint16) string {
switch r {
case 0:
return "NOERROR"
case 2:
return "SERVFAIL"
case 3:
return "NXDOMAIN"
case 5:
return "REFUSED"
}
return fmt.Sprint(r)
}
Real output:
$ go run ./dnsquery example.com
query: 29 bytes 49 8d 01 00 00 01 00 00 00 00 00 00 ...
reply: 61 bytes from 1.1.1.1 in 17.618ms
flags: QR=1 AA=0 TC=0 RD=1 RA=1 rcode=NOERROR answers=2
example.com. TTL=179 A 172.66.147.243
example.com. TTL=179 A 104.20.23.154
$ go run ./dnsquery www.github.com
www.github.com. TTL=3040 CNAME github.com.
github.com. TTL=8 A 20.207.73.82
$ go run ./dnsquery nonexistent-zzzz.example
flags: QR=1 AA=0 TC=0 RD=1 RA=1 rcode=NXDOMAIN answers=0
What to notice:
- The whole query is 29 bytes. DNS is fast because it's one small datagram each way, with no handshake.
AA=0: the answer came from the resolver's cache, not from the authoritative server. Run it twice and watch the TTL count down. That's the cache ageing in real time.- The ID check matters for security. A reply with the wrong ID is rejected. Off-path DNS spoofing attacks (Kaminsky, 2008) work by guessing this 16-bit ID, which is why resolvers also randomise the source port.
- The CNAME chain is in one reply. The resolver followed
www.github.com → github.comfor you and returned both records. TC=1means the answer didn't fit in the UDP packet; real clients retry over TCP port 53. Our 1232-byte buffer is the size the DNS community agreed in 2020 avoids IP fragmentation on almost every path.
Exercises:
- Point it at a server that doesn't exist (
-server 192.0.2.1). You get a timeout, not an error, because UDP can't tell "no server" from "slow server". This is why a dead resolver in/etc/resolv.confadds a fixed 5-second delay (the "suspiciously round number" from Chapter 26). - Query an authoritative server directly: find it with
go run ./dnsquery -type NS example.com, resolve it, then ask it with-server. Check thatAA=1. - Add retries with exponential backoff (1 s, 2 s, 4 s) and query two servers in parallel, using whichever answers first. That is what your OS stub resolver does.
- Stretch: implement the iterative algorithm. Start at a root server
(
198.41.0.4), follow NS referrals in the authority section, and reproducedig +traceyourself.
Across the series: the HTTPS guide's DNS chapter
follows this lookup from the browser's point of view (browser cache, OS
cache, DoH, HTTPS records). The Go standard library's resolver is
net.Resolver; Chapter 55's dnsdiff lab uses it to query many servers at
once.
Common confusions
- "DNS is slow, that's why my site is slow." Usually only the first lookup.
Check with the
curl -wtiming from Chapter 4 —time_namelookuptells you exactly. - "
nslookupvsdig?"digis better: more detail, more scriptable.nslookupis the Windows default and is fine for quick checks. - "Changing DNS servers makes the internet faster." It can reduce lookup latency slightly, but it does not increase bandwidth. The main reasons to change are privacy, filtering, and reliability.
- "A CNAME at the top of my domain doesn't work." Correct — a CNAME can't
coexist with other records, and the zone apex needs NS and SOA records.
Providers offer
ALIAS/ANAMEworkarounds.
Check yourself
- Explain the delegation chain from root to
www.example.com. - What is a TTL and why does it matter before a migration?
- What's the difference between a recursive resolver and an authoritative server?
- When does DNS use TCP?
- What does
dig +traceshow you?
Further reading
- Zine (best beginner resource, genuinely): "How DNS Works" — Julia Evans (jvns.ca). She also has an excellent free comic/zine on DNS. Start here.
- Interactive:
messwithdns.net— Julia Evans' free playground where you create real DNS records and see what happens. Spend 30 minutes here; it's the fastest way to make DNS click. - Video (~15 min): "DNS Explained" — Practical Networking, or Computerphile's "How DNS Works".
- Article: "What is DNS?" — Cloudflare Learning Center (their whole DNS section is excellent).
- Book: DNS and BIND — Cricket Liu. The definitive reference if you go deep.
End of Part 4 — Milestone check
- I can name the four DHCP messages and what each does
- I know what a
169.254.x.xaddress means instantly - I have watched DORA in tcpdump
- I can explain the DNS delegation chain
- I have run
dig +traceand understood the output - I know why you lower a TTL before a migration