RustDNS: Writing a Recursive DNS Resolver in Rust, Byte by Byte

A zero-dependency recursive DNS resolver in about 980 lines of Rust: hand-parsed packets, compression pointers, a walk from the root servers down to the answer, and the two bugs the live internet found when I pointed dig at it.

TL;DR: RustDNS is a recursive DNS resolver written in plain Rust with zero dependencies, just std::net::UdpSocket and a 512-byte array. It parses DNS packets by hand, follows compression pointers, starts every lookup at a root server and walks the tree down to the answer, then serves that answer to dig on port 2053. This post goes through the code byte by byte, and then through what broke when I ran it against the real internet.

git clone https://github.com/ravikisha/rustdns && cd rustdns
cargo run --release

🔗 GitHub · 🐛 Issues


Table of Contents

  1. Why I built this
  2. DNS in two minutes
  3. Hello, resolver
  4. The map of the code
  5. A 512-byte cursor
  6. Twelve bytes of header
  7. Names, labels and compression pointers
  8. One enum per record type
  9. Walking the tree: recursive resolution
  10. Serving dig on port 2053
  11. Breaking it on purpose
  12. How fast is it?
  13. What I learned
  14. What's next
  15. Try it

Why I built this

Every network request you have ever made started with a question: what is the IP address of this name? You type github.com, and before a single byte of HTML moves, something somewhere has to turn those ten characters into 20.207.73.82.

For years that "something" was a black box to me. I knew the words (resolver, root server, TTL, A record) the way you know the names of planets. I'd never touched one.

Cover of a 1928 Swedish national telephone directory, the Rikstelefonkatalogen

The usual analogy is that DNS is the internet's phone book. It's a good analogy, but it hides the interesting part. A phone book is one big printed list. DNS is a distributed phone book: no single machine knows every name, so a resolver has to ask a chain of servers, each of which only knows who to ask next.

I wanted to see that chain with my own eyes, and I wanted to do it in Rust, because DNS is a binary protocol full of bit fields, offsets and length prefixes. That's exactly the kind of code where Rust's Result and bounds checks earn their keep. Malformed packets are not hypothetical on the open internet.

I built RustDNS by working through Emil Hernvall's excellent dnsguide, and if you want a step-by-step tutorial, that guide is the place to go. This post is the other half: a tour of the finished code, plus what happened when I ran it against the real internet two years later.

DNS in two minutes

DNS names are a tree, read right to left. The invisible dot at the end of google.com. is the root. Below it sit the top-level domains (.com, .org, .in), below those the domains people register, and below those whatever names the owner creates.

graph TD
  ROOT[". (root)<br/>13 named root servers, a to m"] --> COM[".com<br/>gTLD servers"]
  ROOT --> ORG[".org"]
  ROOT --> IN[".in"]
  COM --> G["google.com<br/>ns1 to ns4.google.com"]
  COM --> GH["github.com<br/>NS1 + AWS Route 53"]
  G --> W["www.google.com"]
  G --> MX["mail.google.com"]
  GH --> WWW["www.github.com<br/>CNAME to github.com"]

Each level is delegated. The root servers don't know Google's IP. They only know which servers are in charge of .com. The .com servers don't know it either; they know which servers are in charge of google.com. Only those last servers, the authoritative ones, have the actual answer.

Black-and-white photo of telephone operators at a long Bell System switchboard, patching calls by hand

A recursive resolver is the operator in this picture. You ask it one question, and it makes all the calls on your behalf: root, then TLD, then authoritative, then it hands you the answer. Your laptop normally uses one run by your ISP, Google (8.8.8.8) or Cloudflare (1.1.1.1). RustDNS is a tiny one you can run yourself.

Hello, resolver

Build and run it. There are no dependencies to download, so it compiles in seconds:

cargo run --release
# listening on 0.0.0.0:2053 (UDP)

Port 2053 instead of 53 is deliberate: ports below 1024 need root on Linux, and this way you don't have to sudo a learning project. In a second terminal:

dig @127.0.0.1 -p 2053 google.com

No dig on Windows? Neither did I while writing this post, so here is a 12-line client in Python that builds a query packet by hand. It's also a nice preview of the wire format we're about to dissect:

import socket, struct, sys

name, qtype = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 1
q = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)   # header: id, flags (RD=1), 1 question
for label in name.split("."):
    q += bytes([len(label)]) + label.encode()             # [6]google[3]com
q += b"\0" + struct.pack(">HH", qtype, 1)                 # end of name, QTYPE, QCLASS=IN

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(20)
s.sendto(q, ("127.0.0.1", 2053))
print(s.recvfrom(4096)[0].hex())

The server prints every step of its search. This is a real run from my machine, unedited:

Received query: DnsQuestion { name: "google.com", qtype: A }
attempting lookup of A google.com with ns 198.41.0.4
attempting lookup of A google.com with ns 192.41.162.30
attempting lookup of A google.com with ns 216.239.34.10
Answer: A { domain: "google.com", addr: 142.250.29.100, ttl: 300 }
Answer: A { domain: "google.com", addr: 142.250.29.101, ttl: 300 }
Answer: A { domain: "google.com", addr: 142.250.29.138, ttl: 300 }
Answer: A { domain: "google.com", addr: 142.250.29.113, ttl: 300 }
Answer: A { domain: "google.com", addr: 142.250.29.139, ttl: 300 }
Answer: A { domain: "google.com", addr: 142.250.29.102, ttl: 300 }

Three lines, three servers: 198.41.0.4 is a.root-servers.net, 192.41.162.30 is l.gtld-servers.net (one of the .com servers), and 216.239.34.10 is ns2.google.com. That's the whole tree from the diagram above, walked in about half a second.

A CNAME chain works too. Asking for www.github.com comes back with an alias and the address it points to:

Answer: CNAME { domain: "www.github.com", host: "github.com", ttl: 3600 }
Answer: A { domain: "github.com", addr: 20.207.73.82, ttl: 60 }
Authority: NS { domain: "github.com", host: "dns1.p08.nsone.net", ttl: 900 }
Authority: NS { domain: "github.com", host: "ns-421.awsdns-52.com", ttl: 900 }
...

So how does it do that?

The map of the code

The whole server lives in one file, src/main.rs, and Cargo.toml has an empty [dependencies] table. It layers neatly from raw bytes up to a running server:

graph LR
  MAIN["main()<br/>UDP :2053 loop"] --> HQ["handle_query()"]
  HQ --> RL["recursive_lookup()"]
  RL --> L["lookup()<br/>one UDP round trip"]
  L --> P["DnsPacket"]
  HQ --> P
  P --> H["DnsHeader"]
  P --> Q["DnsQuestion"]
  P --> R["DnsRecord enum"]
  H --> B["BytePacketBuffer<br/>[u8; 512] + cursor"]
  Q --> B
  R --> B

Everything that touches the wire goes through BytePacketBuffer. Everything above it works with plain Rust structs and enums. Let's go bottom-up.

A 512-byte cursor

The original DNS spec (RFC 1035) caps a UDP message at 512 bytes. So the buffer is just a fixed array and a position:

pub struct BytePacketBuffer {
    /// Buffer for holding the packet contents
    pub buf: [u8; 512],
    /// Field for keeping track of where we are
    pub pos: usize,
}

No heap allocation, no Vec, no growing. Reading is a cursor that walks forward and refuses to fall off the end:

fn read(&mut self) -> Result<u8> {
    if self.pos >= 512 {
        return Err("End of the buffer".into());
    }
    let res = self.buf[self.pos];
    self.pos += 1;
    Ok(res)
}

DNS is big-endian ("network byte order"), so multi-byte integers are assembled most-significant byte first:

fn read_u16(&mut self) -> Result<u16> {
    let res = ((self.read()? as u16) << 8) | (self.read()? as u16);
    Ok(res)
}

The ? after every read() is the quiet hero here. A truncated or malicious packet can end at any byte, and every single read propagates that as an error instead of reading garbage. Writing mirrors reading: write_u8, write_u16, write_u32, each splitting the integer back into bytes with shifts.

There are two more primitives that matter later: get(pos) peeks at a byte without moving the cursor, and set_u16(pos, val) overwrites two bytes we already wrote. Hold on to both.

Twelve bytes of header

Every DNS message, query or response, starts with the same 12-byte 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|AD|CD|   RCODE   |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    QDCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ANCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    NSCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ARCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
Field Bits Meaning
ID 16 Chosen by the client, copied into the reply so answers can be matched to questions
QR 1 0 = query, 1 = response
OPCODE 4 0 = standard query
AA 1 Authoritative answer: the server owns this name
TC 1 Truncated: the answer didn't fit, retry over TCP
RD / RA 1 + 1 Recursion desired (client asks) / available (server offers)
AD / CD 1 + 1 DNSSEC: authenticated data / checking disabled
RCODE 4 0 NOERROR, 2 SERVFAIL, 3 NXDOMAIN, …
QDCOUNT … ARCOUNT 16 each How many entries follow in each of the four sections

The flags live in the middle 16 bits. DnsHeader::read splits them into a high byte a and low byte b and picks them apart with masks:

let flags = buffer.read_u16()?;
let a = (flags >> 8) as u8;
let b = (flags & 0xFF) as u8;
self.recursion_desired    = (a & (1 << 0)) > 0;
self.truncated_message    = (a & (1 << 1)) > 0;
self.authoritative_answer = (a & (1 << 2)) > 0;
self.opcode               = (a >> 3) & 0x0F;
self.response             = (a & (1 << 7)) > 0;

self.rescode              = ResultCode::from_num(b & 0x0F);
self.checking_disabled    = (b & (1 << 4)) > 0;
self.authed_data          = (b & (1 << 5)) > 0;
self.z                    = (b & (1 << 6)) > 0;
self.recursion_available  = (b & (1 << 7)) > 0;

Bit-twiddling is much easier to feel than to read. Click any bit below to flip it and watch the decoded header change. It starts at 0x8180, the flags of a normal successful response: QR, RD and RA set, RCODE 0. Try setting the low bits to 0011 to make an NXDOMAIN.

let flags = 0x8180;
const names = ['QR','OP','OP','OP','OP','AA','TC','RD','RA','Z','AD','CD','RC','RC','RC','RC'];
const RC = ['NOERROR','FORMERR','SERVFAIL','NXDOMAIN','NOTIMP','REFUSED'];
let bw = 36, x0 = 0;
const y0 = 40;
function setup() { createCanvas(windowWidth, 270); textFont('monospace'); }
function windowResized() { resizeCanvas(windowWidth, 270); }
function draw() {
  background(22, 24, 29);
  bw = min(40, (width - 32) / 16);
  x0 = (width - bw * 16) / 2;
  textAlign(CENTER, CENTER);
  for (let i = 0; i < 16; i++) {
    const on = (flags >> (15 - i)) & 1;
    const x = x0 + i * bw;
    noStroke();
    fill(on ? color(231, 111, 81) : color(44, 49, 58));
    rect(x, y0, bw - 3, bw - 3, 4);
    fill(on ? 20 : 170); textSize(min(14, bw / 2.4));
    text(on, x + (bw - 3) / 2, y0 + (bw - 3) / 2);
    fill(140); textSize(min(10, bw / 3.2));
    text(names[i], x + (bw - 3) / 2, y0 - 12);
  }
  stroke(110); strokeWeight(1);
  line(x0 + 8 * bw - 1.5, y0 - 22, x0 + 8 * bw - 1.5, y0 + bw + 4);
  noStroke(); fill(110); textSize(10);
  text('byte a', x0 + 4 * bw, y0 + bw + 14);
  text('byte b', x0 + 12 * bw, y0 + bw + 14);
  const a = flags >> 8, b = flags & 0xff;
  const rc = RC[b & 0x0f] || 'NOERROR (from_num fallback)';
  const ts = constrain(width / 44, 10, 14);
  textAlign(LEFT, TOP); textSize(ts); fill(230);
  const lines = [
    'flags = 0x' + hex(flags, 4) + '   a = 0x' + hex(a, 2) + '   b = 0x' + hex(b, 2),
    'response (QR)       = ' + ((a & 0x80) > 0) + '   opcode = ' + ((a >> 3) & 0x0f),
    'authoritative (AA)  = ' + ((a & 0x04) > 0) + '   truncated (TC) = ' + ((a & 0x02) > 0),
    'recursion_desired   = ' + ((a & 0x01) > 0) + '   recursion_available = ' + ((b & 0x80) > 0),
    'rescode             = ' + rc
  ];
  lines.forEach((l, i) => text(l, 16, y0 + bw + 34 + i * (ts + 7)));
  fill(120); textSize(11);
  text('click a bit to flip it', 16, height - 20);
}
function mousePressed() {
  if (mouseY < y0 || mouseY > y0 + bw) return;
  const i = floor((mouseX - x0) / bw);
  if (i >= 0 && i < 16) flags ^= 1 << (15 - i);
}

Writing the header is the same thing in reverse: shift each boolean back into position and OR the pieces together into two bytes.

A real packet, byte by byte

The repo keeps two captured packets, query_packet.txt and response_packet.txt. The query is 28 bytes:

79 88 01 20 00 01 00 00 00 00 00 00   header: id 0x7988, RD=1, 1 question
06 67 6f 6f 67 6c 65 03 63 6f 6d 00   [6]google[3]com[0]
00 01 00 01                           QTYPE A, QCLASS IN

The response is 44. The sketch below steps through every field. Watch byte 28: it's where things get clever.

const bytes = [0x79,0x88,0x81,0x80,0x00,0x01,0x00,0x01,0x00,0x00,0x00,0x00,
  0x06,0x67,0x6f,0x6f,0x67,0x6c,0x65,0x03,0x63,0x6f,0x6d,0x00,
  0x00,0x01,0x00,0x01,
  0xc0,0x0c,0x00,0x01,0x00,0x01,0x00,0x00,0x01,0x2c,0x00,0x04,0x8e,0xfa,0xc3,0xee];
const fields = [
  {s:0,  e:1,  name:'ID',        val:'0x7988, copied from the query so the client can match the reply', c:[244,162,97]},
  {s:2,  e:3,  name:'FLAGS',     val:'0x8180: QR=1 (response), RD=1, RA=1, RCODE=0 NOERROR', c:[231,111,81]},
  {s:4,  e:5,  name:'QDCOUNT',   val:'1 question follows', c:[42,157,143]},
  {s:6,  e:7,  name:'ANCOUNT',   val:'1 answer record follows', c:[42,157,143]},
  {s:8,  e:9,  name:'NSCOUNT',   val:'0 authority records', c:[42,157,143]},
  {s:10, e:11, name:'ARCOUNT',   val:'0 additional records', c:[42,157,143]},
  {s:12, e:23, name:'QNAME',     val:'[6]google[3]com[0], length-prefixed labels: google.com', c:[138,177,125]},
  {s:24, e:25, name:'QTYPE',     val:'1 = A (IPv4 address)', c:[233,196,106]},
  {s:26, e:27, name:'QCLASS',    val:'1 = IN (internet)', c:[233,196,106]},
  {s:28, e:29, name:'NAME (pointer)', val:'0xC00C: top two bits set, so this is a pointer. Jump to offset 0x0C = 12 and read the name there', c:[181,131,224]},
  {s:30, e:31, name:'TYPE',      val:'1 = A', c:[233,196,106]},
  {s:32, e:33, name:'CLASS',     val:'1 = IN', c:[233,196,106]},
  {s:34, e:37, name:'TTL',       val:'0x0000012C = 300 seconds a resolver may cache this', c:[100,181,246]},
  {s:38, e:39, name:'RDLENGTH',  val:'4 bytes of record data follow', c:[100,181,246]},
  {s:40, e:43, name:'RDATA',     val:'8e fa c3 ee = 142.250.195.238', c:[239,71,111]}
];
const COLS = 12, X0 = 46, Y0 = 16, CH = 34;
let cur = 0, t = 0, cw = 44;
function setup() { createCanvas(windowWidth, 340); textFont('monospace'); }
function windowResized() { resizeCanvas(windowWidth, 340); }
function fieldOf(i) { return fields.findIndex(f => i >= f.s && i <= f.e); }
function center(i) {
  return [X0 + (i % COLS) * cw + (cw - 4) / 2, Y0 + floor(i / COLS) * CH + (CH - 6) / 2];
}
function draw() {
  background(22, 24, 29);
  cw = min(48, (width - X0 - 12) / COLS);
  t++;
  if (t % 170 === 0) cur = (cur + 1) % fields.length;
  const f = fields[cur];
  const pointing = f.name.startsWith('NAME');
  textAlign(CENTER, CENTER); noStroke();
  for (let r = 0; r < 4; r++) {
    fill(110); textSize(10);
    text('0x' + hex(r * COLS, 2), X0 / 2, Y0 + r * CH + (CH - 6) / 2);
  }
  for (let i = 0; i < bytes.length; i++) {
    const fi = fieldOf(i), g = fields[fi];
    const on = fi === cur, target = pointing && fi === 6;
    const x = X0 + (i % COLS) * cw, y = Y0 + floor(i / COLS) * CH;
    fill(g.c[0], g.c[1], g.c[2], on ? 255 : target ? 170 : 50);
    rect(x, y, cw - 4, CH - 6, 5);
    fill(on || target ? 18 : 205); textSize(min(14, cw / 3));
    text(hex(bytes[i], 2).toLowerCase(), x + (cw - 4) / 2, y + (CH - 6) / 2);
  }
  if (pointing) {
    const [ax, ay] = center(28), [bx, by] = center(12);
    noFill(); stroke(181, 131, 224); strokeWeight(2);
    bezier(ax, ay - 12, ax, ay - 60, bx + 30, by - 60, bx, by - 12);
    const p = (t % 60) / 60;
    const px = bezierPoint(ax, ax, bx + 30, bx, p);
    const py = bezierPoint(ay - 12, ay - 60, by - 60, by - 12, p);
    noStroke(); fill(255, 209, 102); circle(px, py, 9);
  }
  const yb = Y0 + 4 * CH + 16;
  noStroke(); textAlign(LEFT, TOP);
  fill(f.c[0], f.c[1], f.c[2]); textSize(16);
  text('bytes ' + f.s + '-' + f.e + '   ' + f.name, 16, yb);
  fill(225); textSize(constrain(width / 44, 11, 14));
  text(f.val, 16, yb + 28, width - 32);
  fill(110); textSize(11);
  text('click to step through the fields', 16, height - 22);
}
function mousePressed() { cur = (cur + 1) % fields.length; t = 1; }

Names, labels and compression pointers

Domain names on the wire aren't dotted strings. They're a sequence of labels, each prefixed by its length, ending with a zero-length label:

www.google.com  →  [3] w w w [6] g o o g l e [3] c o m [0]

A length byte can only use 6 of its 8 bits, which is where the famous label limit comes from: 2^6 - 1 = 63 characters.

The top two bits are reserved for something much better. If both are set (0b11xxxxxx), the byte isn't a length at all. It's the start of a compression pointer: the remaining 14 bits are an offset elsewhere in the packet where the rest of the name can be found.

\text{offset} = \big((b_0 \mathbin{\&} \texttt{0x3F}) \ll 8\big) \mathbin{|} b_1

That's exactly what c0 0c at byte 28 of the response means: offset 0x000C, byte 12, where google.com was already written in the question. Two bytes instead of twelve. In a response with ten records about the same domain, that saving is the difference between fitting in 512 bytes and not.

Here's the flow of read_qname:

flowchart TD
  S(["start at buffer.pos"]) --> J{"jumps > 5 ?"}
  J -- yes --> ERR(["Err: jump limit exceeded"])
  J -- no --> LEN["len = get(pos)"]
  LEN --> PTR{"top two bits set?"}
  PTR -- yes --> FIRST["first jump: seek buffer past these 2 bytes"]
  FIRST --> OFF["pos = 14-bit offset"]
  OFF --> J
  PTR -- no --> ZERO{"len == 0 ?"}
  ZERO -- yes --> DONE(["done: if no jump, seek to pos"])
  ZERO -- no --> APP["push delimiter + lowercase label"]
  APP --> NEXT["pos += 1 + len"]
  NEXT --> J

And the heart of the code:

fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
    let mut pos = self.pos();   // local cursor, independent of the buffer's
    let mut jumped = false;
    let max_jumps = 5;
    let mut jumps_performed = 0;
    let mut delim = "";

    loop {
        // A crafted packet can contain a pointer loop. Never trust the wire.
        if jumps_performed > max_jumps {
            return Err(format!("Limit of {} jumps exceeded", max_jumps).into());
        }

        let len = self.get(pos)?;

        if (len & 0xC0) == 0xC0 {
            // Move the *shared* cursor past the pointer, but only once
            if !jumped {
                self.seek(pos + 2)?;
            }
            let b2 = self.get(pos + 1)? as u16;
            let offset = (((len as u16) ^ 0xC0) << 8) | b2;
            pos = offset as usize;

            jumped = true;
            jumps_performed += 1;
            continue;
        } else {
            pos += 1;
            if len == 0 {
                break;
            }
            outstr.push_str(delim);
            let str_buffer = self.get_range(pos, len as usize)?;
            outstr.push_str(&String::from_utf8_lossy(str_buffer).to_lowercase());
            delim = ".";
            pos += len as usize;
        }
    }

    if !jumped {
        self.seek(pos)?;
    }
    Ok(())
}

Two details I really like here:

  • Two cursors. The function walks the name with a local pos, but the buffer's shared cursor has to end up right after the name as it appears in this record. After a jump, those are different places. So the shared cursor is moved exactly once, at the first pointer, and never touched again.
  • The jump limit. A malicious server can send a pointer that points to itself. Without max_jumps, read_qname would spin forever and one UDP packet would take down the resolver. Five jumps is plenty for any honest packet.

One enum per record type

Answers, authorities and additionals are all resource records with the same envelope: name, type, class, TTL, data length, then type-specific data. Rust's enums model that perfectly:

pub enum DnsRecord {
    UNKNOWN { domain: String, qtype: u16, data_len: u16, ttl: u32 }, // anything else
    A       { domain: String, addr: Ipv4Addr, ttl: u32 },              // 1
    NS      { domain: String, host: String, ttl: u32 },                // 2
    CNAME   { domain: String, host: String, ttl: u32 },                // 5
    MX      { domain: String, priority: u16, host: String, ttl: u32 }, // 15
    AAAA    { domain: String, addr: Ipv6Addr, ttl: u32 },              // 28
}

QueryType has an UNKNOWN(u16) variant too, so a record type the server has never heard of doesn't crash the parser. It reads data_len and skips over the data:

QueryType::UNKNOWN(_) => {
    buffer.step(data_len as usize)?;
    Ok(DnsRecord::UNKNOWN { domain, qtype: qtype_num, data_len, ttl })
}

Reading an A record is four bytes turned into an Ipv4Addr. AAAA is sixteen bytes turned into eight u16 segments. NS, CNAME and MX contain names, so they reuse read_qname, compression pointers and all.

Writing: backpatching the length

Writing has one fun problem. Every record carries an RDLENGTH field before its data, but for a name-valued record you don't know the length until you've written the name. So RustDNS writes a placeholder, writes the data, measures, and goes back to patch it in:

DnsRecord::CNAME { ref domain, ref host, ttl } => {
    buffer.write_qname(domain)?;
    buffer.write_u16(QueryType::CNAME.to_num())?;
    buffer.write_u16(1)?;       // class IN
    buffer.write_u32(ttl)?;

    let pos = buffer.pos();
    buffer.write_u16(0)?;       // placeholder RDLENGTH

    buffer.write_qname(host)?;

    let size = buffer.pos() - (pos + 2);
    buffer.set_u16(pos, size as u16)?;   // backpatch
}

That's what set_u16 was for. It's the same trick assemblers use for forward jumps, and it shows up any time a format puts a length before the thing being measured.

Walking the tree: recursive resolution

Everything so far is a parser. This is the part that makes it a resolver.

lookup() does one round trip: build a packet with one question, send it to one server over UDP, parse whatever comes back:

fn lookup(qname: &str, qtype: QueryType, server: (Ipv4Addr, u16)) -> Result<DnsPacket> {
    let socket = UdpSocket::bind(("0.0.0.0", 43210))?;

    let mut packet = DnsPacket::new();
    packet.header.id = 6666;
    packet.header.questions = 1;
    packet.header.recursion_desired = true;
    packet.questions.push(DnsQuestion::new(qname.to_string(), qtype));

    let mut req_buffer = BytePacketBuffer::new();
    packet.write(&mut req_buffer)?;
    socket.send_to(&req_buffer.buf[0..req_buffer.pos], server)?;

    let mut res_buffer = BytePacketBuffer::new();
    socket.recv_from(&mut res_buffer.buf)?;

    DnsPacket::from_buffer(&mut res_buffer)
}

(Keep an eye on that fixed port 43210 and fixed ID 6666. We'll come back to them.)

recursive_lookup() calls it in a loop, starting at a root server and following referrals downward:

flowchart TD
  START(["ns = 198.41.0.4 (a.root-servers.net)"]) --> LK["lookup(qname, qtype, ns)"]
  LK --> ANS{"answers and NOERROR?"}
  ANS -- yes --> RET(["return response"])
  ANS -- no --> NX{"NXDOMAIN?"}
  NX -- yes --> RET
  NX -- no --> GLUE{"NS record with a glue A record?"}
  GLUE -- yes --> SET["ns = glue IP"] --> LK
  GLUE -- no --> NAME{"any NS name at all?"}
  NAME -- no --> RET
  NAME -- yes --> SUB["recursive_lookup(ns_name, A)"]
  SUB --> GOT{"got an A record?"}
  GOT -- yes --> SET2["ns = that IP"] --> LK
  GOT -- no --> RET

A referral is a response with no answer, but with NS records in the authority section ("ask these servers instead") and, usually, matching A records in the additional section. Those A records are called glue, and they save a round trip: the root doesn't just say "ask l.gtld-servers.net", it also tells you its IP.

Finding the next server with glue is a neat little iterator chain:

pub fn get_resolved_ns(&self, qname: &str) -> Option<Ipv4Addr> {
    self.get_ns(qname)                      // (domain, host) pairs from NS records
        .flat_map(|(_, host)| {
            self.resources.iter().filter_map(move |record| match record {
                DnsRecord::A { domain, addr, .. } if domain == host => Some(addr),
                _ => None,
            })
        })
        .map(|addr| *addr)
        .next()                             // first NS that has glue wins
}

When there's no glue (the nameserver lives in a different zone, like GitHub's ns-421.awsdns-52.com), the resolver has to stop, resolve the nameserver's name from scratch, then continue. That's literally recursion: recursive_lookup calls itself.

let new_ns_name = match response.get_unresolved_ns(qname) {
    Some(x) => x,
    None => return Ok(response),
};

// Down the rabbit hole: a whole new lookup in the middle of this one.
let recursive_response = recursive_lookup(&new_ns_name, QueryType::A)?;

if let Some(new_ns) = recursive_response.get_random_a() {
    ns = new_ns;
} else {
    return Ok(response);
}

Here's the google.com run from earlier as a sequence, with the real servers from the log:

sequenceDiagram
  participant C as dig
  participant R as rustdns :2053
  participant Root as a.root-servers.net 198.41.0.4
  participant TLD as l.gtld-servers.net 192.41.162.30
  participant NS as ns2.google.com 216.239.34.10
  C->>R: A google.com? (RD=1)
  R->>Root: A google.com?
  Root-->>R: no answer, NS for com + glue
  R->>TLD: A google.com?
  TLD-->>R: no answer, NS ns1-4.google.com + glue
  R->>NS: A google.com?
  NS-->>R: 6 A records, TTL 300, AA=1
  R-->>C: 6 A records, RA=1

And here it is moving. The upstream legs are slowed down so you can follow them. Click the canvas to switch to what a cache would do (more on that later; RustDNS doesn't have one yet):

let nodes = {}, step = 0, prog = 0, ms = 0, cache = false, pause = 0;
const FULL = [
  ['client', 'rd', 'dig asks rustdns: A google.com?', 1],
  ['rd', 'root', 'ask the root, 198.41.0.4', 90],
  ['root', 'rd', 'referral: try the .com servers (+ glue IPs)', 90],
  ['rd', 'tld', 'ask .com, 192.41.162.30', 90],
  ['tld', 'rd', 'referral: google.com is served by ns1-4.google.com', 90],
  ['rd', 'auth', 'ask ns2.google.com, 216.239.34.10', 90],
  ['auth', 'rd', 'answer: 6 A records, TTL 300', 90],
  ['rd', 'client', 'rustdns replies to dig', 1]
];
const CACHED = [
  ['client', 'rd', 'dig asks rustdns: A google.com?', 1],
  ['rd', 'client', 'cache hit, TTL not expired: answer locally', 0.05]
];
function setup() { createCanvas(windowWidth, 330); textFont('monospace'); build(); }
function windowResized() { resizeCanvas(windowWidth, 330); build(); }
function build() {
  const w = width;
  nodes = {
    client: { x: w * 0.10, y: 165, label: 'dig' },
    rd:     { x: w * 0.38, y: 165, label: 'rustdns :2053' },
    root:   { x: w * 0.80, y: 70,  label: 'a.root-servers.net' },
    tld:    { x: w * 0.80, y: 165, label: 'l.gtld-servers.net' },
    auth:   { x: w * 0.80, y: 260, label: 'ns2.google.com' }
  };
}
function seq() { return cache ? CACHED : FULL; }
function draw() {
  background(22, 24, 29);
  const s = seq();
  const cur = s[step];
  stroke(58); strokeWeight(1.5);
  line(nodes.client.x, nodes.client.y, nodes.rd.x, nodes.rd.y);
  for (const k of ['root', 'tld', 'auth']) line(nodes.rd.x, nodes.rd.y, nodes[k].x, nodes[k].y);
  noStroke(); textAlign(CENTER, CENTER);
  for (const k in nodes) {
    const n = nodes[k];
    const active = cur && (cur[0] === k || cur[1] === k);
    fill(active ? color(231, 111, 81) : color(52, 58, 70));
    circle(n.x, n.y, 26);
    fill(205); textSize(constrain(width / 55, 9, 12));
    text(n.label, n.x, n.y + 26);
  }
  textAlign(LEFT, TOP); textSize(constrain(width / 45, 10, 14));
  if (cur) {
    prog += cur[3] > 10 ? 0.018 : 0.04;
    const A = nodes[cur[0]], B = nodes[cur[1]];
    fill(255, 209, 102);
    circle(lerp(A.x, B.x, min(prog, 1)), lerp(A.y, B.y, min(prog, 1)), 12);
    fill(235);
    text((step + 1) + '. ' + cur[2], 16, 12, width - 32);
    if (prog >= 1) {
      prog = 0; ms += cur[3]; step++;
      if (step >= s.length) pause = 120;
    }
  } else {
    fill(235);
    text('done: answer delivered', 16, 12);
    pause--;
    if (pause <= 0) { step = 0; ms = 0; prog = 0; }
  }
  fill(170); textSize(12);
  text('network time ~ ' + round(ms) + ' ms' + (cache ? '' : '   (measured cold lookups: 533-568 ms)'), 16, height - 40);
  fill(110); textSize(11);
  text('click to toggle cache: ' + (cache ? 'ON (roadmap)' : 'OFF (rustdns today)'), 16, height - 20);
}
function mousePressed() { cache = !cache; step = 0; prog = 0; ms = 0; pause = 0; }

Serving dig on port 2053

The last layer turns the resolver into a server. main() is a single loop:

fn main() -> Result<()> {
    let socket = UdpSocket::bind(("0.0.0.0", 2053))?;
    loop {
        match handle_query(&socket) {
            Ok(_) => {}
            Err(e) => eprintln!("An error occurred: {}", e),
        }
    }
}

An error in one query is logged and the loop moves on, so one bad packet never kills the server. handle_query receives a packet, resolves it, and builds a response:

flowchart TD
  RX["recv_from on :2053"] --> PARSE["DnsPacket::from_buffer"]
  PARSE --> Q{"question present?"}
  Q -- no --> FE["rescode = FORMERR"]
  Q -- yes --> RL["recursive_lookup(name, qtype)"]
  RL -- Ok --> COPY["copy rescode, answers,<br/>authorities, additionals"]
  RL -- Err --> SF["rescode = SERVFAIL"]
  FE --> W["packet.write into 512-byte buffer"]
  COPY --> W
  SF --> W
  W --> TX["send_to the client"]
let mut packet = DnsPacket::new();
packet.header.id = request.header.id;        // echo the client's ID
packet.header.recursion_desired = true;
packet.header.recursion_available = true;
packet.header.response = true;

if let Some(question) = request.questions.pop() {
    if let Ok(result) = recursive_lookup(&question.name, question.qtype) {
        packet.questions.push(question.clone());
        packet.header.rescode = result.header.rescode;
        packet.answers.extend(result.answers);       // (the repo uses for-loops with logging)
        packet.authorities.extend(result.authorities);
        packet.resources.extend(result.resources);
    } else {
        packet.header.rescode = ResultCode::SERVFAIL;
    }
} else {
    packet.header.rescode = ResultCode::FORMERR;
}

The response codes are the polite part of the protocol. A failed upstream lookup becomes SERVFAIL ("I tried, it broke"), and a packet with no question becomes FORMERR ("that's not a valid query"). The client always gets something back.

At least, that was the theory.

Breaking it on purpose

Close-up of a server rack labelled A, with a column of 1U servers and neatly bundled white network cables

I wrote RustDNS in September 2024. To write this post, I built it again and threw real queries at it. Most worked. Two did not, and both are great lessons in why protocols look the way they do.

Bug 1: the MX query that never came back

Received query: DnsQuestion { name: "gmail.com", qtype: MX }
attempting lookup of MX gmail.com with ns 198.41.0.4
attempting lookup of MX gmail.com with ns 192.41.162.30
attempting lookup of MX gmail.com with ns 216.239.34.10
Answer: MX { domain: "gmail.com", priority: 5, host: "gmail-smtp-in.l.google.com", ttl: 3600 }
...   (5 MX answers, then 5 A and 5 AAAA additional records)
An error occurred: End of the buffer

The resolution succeeded. Google's server sent five MX records plus glue for every mail host, and that response fit comfortably in 512 bytes because Google compresses names with pointers.

RustDNS doesn't compress when it writes. write_qname spells every name out in full, so the same response grows:

Part Bytes, uncompressed
Header + question 27
5 MX answers 275
5 A additionals 230
5 AAAA additionals 290
Total 822

The writer runs out of room partway through the A records, write() returns Err("End of the buffer"), handle_query bails out with ?, and the error is logged. Nothing is sent. The client just waits until it times out. My Python client gave up after 20 seconds.

The fix is a ladder of increasingly blunt tools, and real resolvers use all of them:

  1. Compress names on write. Keep a map of name → offset while writing and emit a pointer the second time a name appears. That alone would shrink this response well under 512 bytes.
  2. Drop the additional section. Glue records are optional hints. Header, question and answers here are only 302 bytes.
  3. Set the TC bit. If even that doesn't fit, send what you can with truncated_message = true, which tells the client to retry over TCP. (RustDNS would then also need a TCP listener.)
  4. EDNS(0), from RFC 6891, lets client and server agree on UDP payloads larger than 512 bytes.

A sketch of step 2, which is a few lines:

// Sketch, not in the repo yet
let mut res_buffer = BytePacketBuffer::new();
if packet.write(&mut res_buffer).is_err() {
    packet.resources.clear();                  // additionals are optional
    res_buffer = BytePacketBuffer::new();
    if packet.write(&mut res_buffer).is_err() {
        packet.answers.clear();
        packet.authorities.clear();
        packet.header.truncated_message = true; // "retry over TCP"
        res_buffer = BytePacketBuffer::new();
        packet.write(&mut res_buffer)?;
    }
}

Bug 2: an NXDOMAIN that lies about itself

Asking for a name that doesn't exist looked fine in the logs:

Received query: DnsQuestion { name: "doesnotexist-xyz123.com", qtype: A }
attempting lookup of A doesnotexist-xyz123.com with ns 198.41.0.4
attempting lookup of A doesnotexist-xyz123.com with ns 192.41.162.30
Authority: UNKNOWN { domain: "com", qtype: 6, data_len: 61, ttl: 900 }
Skipping record: UNKNOWN { domain: "com", qtype: 6, data_len: 61, ttl: 900 }

Type 6 is SOA, the record a server attaches to an NXDOMAIN so resolvers know how long to cache the "no". RustDNS doesn't model SOA, so it parses it as UNKNOWN, and when writing, UNKNOWN writes nothing at all. But DnsPacket::write sets the header counts from the vectors before writing:

self.header.authoritative_entries = self.authorities.len() as u16; // 1

So the response says "one authority record follows", and then the packet ends:

12 34 81 83 00 01 00 00 00 01 00 00  13 64 6f 65 73 ...
         ^ RCODE=3         ^ NSCOUNT = 1, but zero bytes of records follow

A lenient client shrugs and reads the NXDOMAIN rcode. A strict one rejects the whole packet as malformed. The quick fix is one line: packet.authorities.retain(|r| !matches!(r, DnsRecord::UNKNOWN { .. })) before writing. The proper fix is adding an SOA variant to DnsRecord. Either way, a header count is a promise, and a writer must never count something it doesn't write.

Smaller things I found reading the code again

  • Label length check. write_qname rejects labels longer than 0x34, which is 52, but the error message (and the spec) say 63. It should be 0x3F.
  • Off-by-one in get_range. It errors when start + len >= 512, so a range ending exactly at byte 512 is rejected. That should be >.
  • get_random_a isn't random. It returns the first A record. Harmless, but the name over-promises.
  • Zone matching with ends_with. get_ns keeps NS records where qname.ends_with(domain), so notgoogle.com "ends with" google.com. Comparing on a label boundary (qname == domain or qname.ends_with(&format!(".{}", domain))) is the correct check.

The serious one: spoofing

Remember lookup()? Every upstream query goes out from port 43210 with ID 6666, and the reply's ID is never checked. An attacker who can send UDP packets to the resolver only has to guess... nothing. They can race a forged answer for any name and it will be believed.

This is the class of attack Dan Kaminsky made famous in 2008, and the industry's response was to make forgeries expensive to guess: a random 16-bit ID and a random source port, roughly 2^{16} \times 2^{16} \approx 4.3 \times 10^9 combinations instead of one. It's a small change:

// Sketch, not in the repo yet
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::time::Duration;

fn random_u16() -> u16 {
    RandomState::new().build_hasher().finish() as u16 // fine for a toy; use getrandom for real
}

let socket = UdpSocket::bind(("0.0.0.0", 0))?;           // OS picks a random ephemeral port
socket.set_read_timeout(Some(Duration::from_secs(2)))?;  // a dead server can't hang us forever
let id = random_u16();
// ... send, receive ...
if response.header.id != id {
    return Err("response ID mismatch".into());
}

The read timeout fixes a quieter problem too: today, if an upstream server never answers, recv_from blocks forever and the whole server freezes with it.

How fast is it?

Every query I sent was a cold lookup, because RustDNS doesn't cache anything. Each one walked root → TLD → authoritative, and took between 533 and 568 ms end to end on my connection.

That's almost entirely network time. Cold latency is roughly the sum of the round trips:

T_{\text{cold}} \approx \sum_{i=1}^{h} \mathrm{RTT}_i \qquad (h = 3 \text{ for google.com})

Parsing a 44-byte packet takes microseconds; waiting on three servers in a row takes hundreds of milliseconds. Which is why every real resolver caches. With a hit rate p:

\mathbb{E}[T] = p \cdot T_{\text{hit}} + (1 - p) \cdot T_{\text{miss}}

At a modest 90% hit rate, with a hit costing about 1 ms and a miss 550 ms, the average drops from 550 ms to about 56 ms. The TTL on each record (300 seconds for Google's A records, 3600 for the gmail MX records) is the server telling you exactly how long you're allowed to keep it.

There's a second, sneakier cost. The server handles one query at a time. While it spends half a second walking the tree for one client, every other client waits in the socket's queue. And because lookup() binds a fixed port, you couldn't simply spawn a thread per request: the second thread would fail with "address in use". The spoofing fix above (port 0) happens to unblock concurrency too.

What I learned

  • Binary protocols are honest. There is no "kind of parsed". Either the 12 bytes are a header or they aren't, and Rust's Result plus ? on every read made each failure path explicit rather than a crash waiting to happen.
  • Every limit has a story. 512 bytes, 63-character labels, 14-bit pointers, 16-bit IDs. Each one is a design decision from 1987 that the internet has spent decades working around with compression, TCP fallback and EDNS.
  • Parsing and writing are not symmetric. RustDNS reads compression pointers perfectly and never writes one. That asymmetry is invisible on small answers and fatal on large ones.
  • Header counts are promises. Counting a record you then skip produces a packet that's wrong in a way only a strict parser notices.
  • Never trust the wire. The jump limit in read_qname is the line I'm proudest of, and the fixed query ID is the one I'd fix first. Same lesson, both directions.
  • DNS is a cache with a protocol attached. Resolution itself is a handful of round trips. Everything that makes it fast in practice is caching and TTLs.

What's next

  • TTL cache. A HashMap<(String, QueryType), (Vec<DnsRecord>, Instant)> behind a Mutex, respecting each record's TTL, including negative caching from the SOA.
  • Concurrency. A thread per request with socket.try_clone(), or a move to tokio, once upstream sockets use ephemeral ports.
  • Spoofing defences. Random IDs and source ports, ID verification and read timeouts, as sketched above.
  • Name compression on write, plus graceful truncation with the TC bit.
  • More record types: SOA, TXT, SRV and PTR.
  • TCP and EDNS(0) for responses bigger than 512 bytes.
  • A config.toml for the listen address, root hints and an optional upstream forwarder.
  • Tests built on the captured packets in the repo, so every fix above comes with a regression test.

Try it

git clone https://github.com/ravikisha/rustdns && cd rustdns
cargo run --release

# in another terminal
dig @127.0.0.1 -p 2053 google.com
dig @127.0.0.1 -p 2053 www.github.com
dig @127.0.0.1 -p 2053 gmail.com MX   # spoiler: see "Breaking it on purpose"

It's a single file with no dependencies, so it's a nice codebase to read in one sitting. If you've never looked inside DNS, I'd recommend opening main.rs next to RFC 1035 and a packet capture; it all clicks surprisingly fast.

If you liked this kind of build-it-to-understand-it post, I've done the same for link checkers in Rust and for calling Rust from Node.js.

⭐ Star it on GitHub, and if you'd like to pick up anything from the "What's next" list, open an issue or a PR. The NXDOMAIN fix is a great first contribution.

Happy resolving! 🦀


Photo credits: cover and rack photos by Derrick Coetzee, CC0, via Wikimedia Commons. Bell System switchboard, public domain, U.S. National Archives. 1928 Rikstelefonkatalogen, public domain, Televerket Sverige.

Open to hard problems

Got a system worth
building right?

Distributed backends, AI agents, or performance work that needs to go fast. Let's talk.