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.

mermaid diagram

Diagram from the original.

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:

mermaid diagram

Diagram from the original.

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.

p5 visualisation

A frame from the interactive sketch in the original — watch it run.

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.

p5 visualisation

A frame from the interactive sketch in the original — watch it run.

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: 26−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:

mermaid diagram

Diagram from the original.

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:

mermaid diagram

Diagram from the original.

A referr...