TL;DR: RustDNS is a recursive DNS resolver written in plain Rust with zero dependencies, just
std::net::UdpSocketand 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 todigon 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
Table of Contents
- Why I built this
- DNS in two minutes
- Hello, resolver
- The map of the code
- A 512-byte cursor
- Twelve bytes of header
- Names, labels and compression pointers
- One enum per record type
- Walking the tree: recursive resolution
- Serving dig on port 2053
- Breaking it on purpose
- How fast is it?
- What I learned
- What's next
- 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.
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.
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.
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:
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.
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.
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: 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:
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_qnamewould 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:
Diagram from the original.
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:
Diagram from the original.
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):
A frame from the interactive sketch in the original — it does not render here.
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:
Diagram from the original.
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
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:
-
Compress names on write. Keep a map of
name → offsetwhile writing and emit a pointer the second time a name appears. That alone would shrink this response well under 512 bytes. - Drop the additional section. Glue records are optional hints. Header, question and answers here are only 302 bytes.
-
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.) - 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_qnamerejects labels longer than0x34, which is 52, but the error message (and the spec) say 63. It should be0x3F. -
Off-by-one in
get_range. It errors whenstart + len >= 512, so a range ending exactly at byte 512 is rejected. That should be>. -
get_random_aisn't random. It returns the first A record. Harmless, but the name over-promises. -
Zone matching with
ends_with.get_nskeeps NS records whereqname.ends_with(domain), sonotgoogle.com"ends with"google.com. Comparing on a label boundary (qname == domainorqname.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
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 :
$$
\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
Resultplus?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_qnameis 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 aMutex, respecting each record's TTL, including negative caching from the SOA. -
Concurrency. A thread per request with
socket.try_clone(), or a move totokio, 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.tomlfor 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.











Top comments (1)
Việc tự xử lý compression pointers trong DNS là một thử thách thực sự vì chỉ cần tính toán sai offset là toàn bộ parser sẽ crash hoặc tệ hơn là tạo ra lỗ hổng bảo mật. Mình từng gặp rắc rối khi xử lý các vòng lặp vô hạn (infinite loops) do các pointer trỏ ngược lại nhau trong các gói tin được cố tình tạo lỗi. Việc viết một resolver zero-dependency như thế này giúp hiểu cực sâu về cách dữ liệu thực sự di chuyển qua các layer, thay vì chỉ dựa vào các thư viện có sẵn đã được đóng gói quá kỹ. Rất mong được xem thêm phần bạn xử lý việc caching các bản ghi để tối ưu hiệu suất sau khi đã walk xong từ root servers.