Build a DNS server from scratch. Parse binary DNS packets, resolve queries, handle concurrent requests. Applies Modules 6-9.
Build a DNS server from scratch. Parse binary DNS packets, resolve queries from a local zone file, and handle concurrent requests over UDP.
Your program should:
DNS is the backbone of all infrastructure. Every service discovery system, every load balancer, every cloud provider relies on DNS. Building one from scratch teaches you binary protocol parsing, UDP networking, and concurrent request handling — skills that transfer directly to infra development.
This is also a project that will stop interviewers in their tracks. "I built a DNS server in Go from scratch" changes the conversation.
# Start with a zone file
dnsserver --zone zones.conf --port 5353
# With upstream forwarding for unknown domains
dnsserver --zone zones.conf --port 5353 --upstream 8.8.8.8:53
# Test with dig
dig @localhost -p 5353 web.local A
dig @localhost -p 5353 mail.local MX
dig @localhost -p 5353 web.local TXT
Keep it simple — one record per line:
# zones.conf
# domain type value
web.local A 10.0.0.1
api.local A 10.0.0.2
redis.local A 10.0.0.3
db.local A 10.0.0.4
db.local A 10.0.0.5
web.local AAAA ::1
mail.local MX 10 smtp.local
smtp.local A 10.0.0.10
web.local TXT "v=spf1 include:_spf.local ~all"
cdn.local CNAME web.local
net.ListenPacket("udp", addr).encoding/binary.BigEndian.[7]example[3]com[0]). Handle pointer compression (bytes starting with 0xC0).map[string][]Record.| Type | Value | Format |
|---|---|---|
| A | 1 | 4-byte IPv4 address |
| AAAA | 28 | 16-byte IPv6 address |
| CNAME | 5 | Domain name in wire format |
| MX | 15 | 2-byte preference + domain name |
| TXT | 16 | Length-prefixed text strings |
| RCODE | Meaning | When |
|---|---|---|
| 0 | No Error | Record found |
| 3 | NXDOMAIN | Domain not in zone file |
| 4 | Not Implemented | Unsupported query type |
+--+--+--+--+--+--+--+--+
| Header | 12 bytes
+--+--+--+--+--+--+--+--+
| Question | Variable length
+--+--+--+--+--+--+--+--+
| Answer | Variable length (0 or more RRs)
+--+--+--+--+--+--+--+--+
Header (12 bytes):
Bytes 0-1: ID (transaction identifier)
Bytes 2-3: Flags (QR, Opcode, AA, TC, RD, RA, Z, RCODE)
Bytes 4-5: QDCOUNT (questions)
Bytes 6-7: ANCOUNT (answers)
Bytes 8-9: NSCOUNT (authority, set to 0)
Bytes 10-11: ARCOUNT (additional, set to 0)
Resource Record:
Name: Domain name (or pointer 0xC0 0x0C)
Type: 2 bytes (A=1, AAAA=28, CNAME=5, MX=15, TXT=16)
Class: 2 bytes (IN=1)
TTL: 4 bytes (seconds)
RDLength: 2 bytes (length of RDATA)
RDATA: Variable (the actual data)
dnsserver/
├── main.go ← CLI entry point, start UDP listener
├── server.go ← Accept loop, dispatch to handlers
├── parser.go ← Parse DNS query packets
├── parser_test.go ← Test parsing with known-good packets
├── builder.go ← Build DNS response packets
├── builder_test.go ← Test building with expected bytes
├── zone.go ← Load and query zone file
├── zone_test.go ← Zone file parsing tests
└── zones.conf ← Example zone file
Suggested approach:
- Start with zone file parsing — it's the easiest piece and testable in isolation
- Build the DNS header parser/builder — test with known byte sequences
- Add domain name encoding/decoding
- Wire up the UDP listener — read a packet, parse it, print what you got
- Build responses for A records only first
- Add NXDOMAIN for unknown domains
- Add support for other record types one at a time
- Add upstream forwarding last
# Query an A record
dig @localhost -p 5353 web.local A
# Expect:
# ;; ANSWER SECTION:
# web.local. 60 IN A 10.0.0.1
# Query a non-existent domain
dig @localhost -p 5353 nope.local A
# Expect: status: NXDOMAIN
# Capture a real DNS query to use as test input
dig @8.8.8.8 example.com A +noedns | xxd
Save the raw bytes and use them in tests to verify your parser handles real-world packets.
Test each layer independently:
func TestParseHeader(t *testing.T) {
// Known DNS header bytes
raw := []byte{0xAA, 0xBB, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
h := parseHeader(raw)
if h.ID != 0xAABB { t.Errorf("ID: got %x, want 0xAABB", h.ID) }
if h.QDCount != 1 { t.Errorf("QDCount: got %d, want 1", h.QDCount) }
}
func TestEncodeDomain(t *testing.T) {
got := encodeDomain("example.com")
want := []byte{7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}
if !bytes.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestZoneLoad(t *testing.T) {
zone, err := loadZone("testdata/zones.conf")
if err != nil { t.Fatal(err) }
records := zone.Lookup("web.local", TypeA)
if len(records) != 1 { t.Errorf("got %d records, want 1", len(records)) }
}
*.local wildcard entriesSkills Used: UDP networking, binary protocol parsing (encoding/binary), goroutines, maps, structs, file I/O, byte slice manipulation, bit operations (flags), net.IP parsing, concurrent request handling.