Model the network as a single injectable function query(bytes) -> bytes so the whole resolver is testable against exact message bytes and canned responses, no sockets required. Every lesson is one concrete spec with real wire bytes: the RD flag landing in the right bit, a QNAME ending in its zero root label, a 0xC0 0x0C pointer resolving to offset 12, a rejected pointer loop, four RDATA bytes becoming 93.184.216.34, a CNAME target that itself uses compression, and a response whose ID must echo the query it answered.
Over 30 lessons you build a working DNS resolver from scratch, entirely offline. The trick that keeps every step exactly testable is to treat DNS as what it really is - a byte format - and to model the network as a single injectable function, query(bytes) -> bytes. Because the transport is injected, you drive the whole resolver with scripted nameserver responses held in memory: no sockets, no flakiness, and the same resolver runs identically in any language.
You start at the bottom of the stack and climb. First the 12-byte header: the ID, the flag bits (QR, Opcode, AA, TC, RD, RA, Z, RCODE) packed across two bytes, and the four section counts, encoded and decoded to exact bytes. Then domain names as length-prefixed labels ending in a zero root, the question section, and a complete query message with a fixed ID. Next the classic tricky bit - name compression, following a 0xC0 pointer to an earlier offset, chasing a pointer chain, and guarding against pointer loops. Then the resource records: the RR wire format bounded by RDLENGTH, and the common RDATA types A, AAAA, NS, CNAME, MX, TXT, and SOA. Finally the resolver itself: build a query and parse a full response, honor the RCODE and the truncation bit, follow a CNAME chain, read NS referrals and glue, and run the iterative root to TLD to authoritative algorithm - all through the injectable transport. The capstone resolves www.example.com against a scripted root, .com, and authoritative server and returns its A record.
This is a teaching-grade resolver built around the message format in RFC 1035. The graded core - the wire-format codec and the resolution logic - is complete and exact, proven end to end against scripted responses. On top of it a small command-line tool adds the real network layer: a stub-resolver mode that queries a configured server over UDP resolves real A, AAAA, NS, CNAME, MX, TXT, and SOA records and reports NXDOMAIN, SERVFAIL, timeout, and truncation cleanly. It deliberately stops short of the pieces the live root demands: there is no EDNS0, so full iterative resolution against real root servers truncates, and there is no TCP fallback, DNSSEC validation, or caching. That honest core is exactly what production resolvers like Unbound and BIND extend with those transports, security, and a cache.
DNS is a byte format, and almost every field in it is a 16-bit number stored big-endian - most significant byte first. Today you build the two tiny helpers that put a 16-bit value into two bytes and read it back, the foundation every later lesson reuses.
Write a 16-bit integer to two big-endian bytes and read it back.
Everything in a DNS message is packed into bytes in a fixed order, and the single most common unit is the 16-bit unsigned integer stored big-endian: the high byte comes first, the low byte second. The message identifier, every section count, the record type, the class, and the data length are all 16-bit big-endian values. So the very first thing to build is a reliable way to move a 16-bit number onto two bytes and back off them.
The value 1234 is 0x04D2 in hex, so its high byte is 0x04 and its low byte
is 0xD2. Writing high-then-low and reading hi<<8 | lo is the whole idea. It is
deliberately tiny, but you will call these two helpers in nearly every lesson from
here on, so getting the byte order right now saves a great deal of confusion
later.
// big-endian: high byte first, low byte secondfunc putUint16(v uint16) []byte {return []byte{byte(v >> 8), byte(v)}}func uint16At(b []byte, off int) uint16 {// combine the two bytes back into one valuereturn uint16(b[off])<<8 | uint16(b[off+1])}
The stub resolver reliably resolves real A, AAAA, NS, CNAME, MX, TXT, and SOA records against any configured DNS server end to end, with graceful handling of NXDOMAIN, SERVFAIL, timeout, and truncation - but the iterative root-to-authoritative mode, while correctly wired to real per-server UDP, fails against live root servers today because there is no EDNS0 support, so their referral responses exceed the legacy 512-byte UDP limit and truncate.
The authoritative wire-format reference and the backbone of this project. Section 4 gives the exact byte layout of the header, question, and resource records; section 4.1.4 is the message-compression pointer scheme you implement in chapter three.
The companion concepts document: the domain namespace as a tree, zones and delegation, and the iterative resolution algorithm (start at the root, follow NS referrals down to the authoritative server) that chapter five turns into code.
A friendly, hands-on guided project that builds a toy resolver from the wire format up - header, questions, records, compression, and iterative resolution. The closest companion to this project, well worth reading alongside it.
A short illustrated tour of what happens when you look up a name - resolvers, root servers, TLD servers, and authoritative servers. Read it first for the big picture the resolver chapter implements.
Defines the AAAA record (type 28) whose 16-byte RDATA you parse into an IPv6 address in chapter four.
The classic operational reference for the domain name system: zones, delegation, record types, and how real resolvers and authoritative servers behave. Good background for the concepts the lessons implement in miniature.