Building a Toy DNS Server
View the source code on GitHub
DNS is one of the foundational parts of networking that I usually take for granted. Just point a domain at an IP address, then don't think about it for a few years! But in my work projects, I've started using more dynamic DNS tools like Amazon Route 53 that add a complex system of health checks and latency-based routing to how DNS queries are resolved, opening my eyes to the idea that a lot of business logic could go into a DNS server. Around the same time, I also listened to “It’s not always DNS” on the Changelog podcast which really got me thinking that it might be fun to make a DNS server myself, just to understand how these things work.
There is nothing new under the sun, and that includes people making DNS servers. I wanted to use Go for this project, partly to brush up on that language but also because I really like using Go to work on low-level networking stuff. There are already very mature DNS implmentations in Go, like the miekg/dns library, which is what CoreDNS is built on. Lots of very smart people are already building DNS servers in Go.
I intentionally avoided referencing these projects while doing this experiment; I want to do everything from scratch and make lots of mistakes!
Making a UDP Server
DNS traditionally works over UDP, so I need to create a server that sends and receives UDP messages. I've never done this before, so even this first step is new territory! Luckily, making a UDP server in Go isn't much different from making a TCP-based HTTP server:
conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 5553})
for {
// standard DNS messages are 512 bytes max
msg := make([]byte, 512)
// `n` is the number of bytes read.
// `addr` is an address we can use to send a response back to the
// caller. This is different from TCP which has request/response built
// in to the protocol itself.
n, addr, err := conn.ReadFromUDP(msg[0:])
if err != nil {
fmt.Println(err.Error())
}
// Trim unused bytes from the message
msg = msg[0:n]
// Just print the raw message
fmt.Printf("%+v\n", msg)
}
Go sees the msg
as a byte slice which isn't very interesting to read, but here's an example query formatted like a hex dump:
0000000: f6 f5 01 20 00 01 00 00 00 00 00 01 05 68 65 6c ... .........hel
0000010: 6c 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 lo.example.com..
0000020: 01 00 01 00 00 29 10 00 00 00 00 00 00 00 .....)........
You can see hello.example.com
plainly in the request, but what are all the other bytes around it? Let's find out!
Reading a DNS Query
The DNS protocol is well-described in RFC 1035: “Domain names - implementation and specification”, and that was my main reference for this work.
DNS messages are split into four main sections:
- The header
- The question
- The answer (optional)
- Authority info (optional)
- Additional info (optional)
The header
The header is kind of a map for the rest of the message; It includes information about which of the optional sections are present. So, parsing the header is the first step towards being able to parse the rest of the message. The header has a predictable size and structure, and occupies the first 12 bytes of the message:
00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
16-bit ID number | |||||||||||||||
QR | Opcode | AA | TC | RD | TA | Unused | RCODE | ||||||||
Question count | |||||||||||||||
Answer count | |||||||||||||||
Authority count | |||||||||||||||
Additional count |
The ID number is just a random number and the record counts are also just numbers, so those parts of the header are pretty easy to parse:
type Message struct {
raw []byte
ID uint16
IsQuery bool
IsResponse bool
OpCode string
QuestionCount uint16
AnswerCount uint16
ServerCount uint16
AdditionalCount uint16
Questions []DnsQuestion
Answers []ResourceRecord
NameServers []ResourceRecord
Extra []ResourceRecord
}
m := &Message{raw: msg}
m.ID = binary.BigEndian.Uint16(m.raw[0:2])
m.QuestionCount = binary.BigEndian.Uint16(m.raw[4:6])
m.AnswerCount = binary.BigEndian.Uint16(m.raw[6:8])
m.ServerCount = binary.BigEndian.Uint16(m.raw[8:10])
m.AdditionalCount = binary.BigEndian.Uint16(m.raw[10:12])
The 3rd and 4th bytes of the header have a lot of information packed into them, and we'll need to read them bit-by-bit (literally, binary bits) to pull out all the information:
QR
is a boolean that indicates if this messages is a query (0) or a response (1).Opcode
is a 4-bit field that indicates what kind of query the message contains.0
is a regular query.1
is an inverse query.2
is a server status request.
AA
is a boolean that indicates whether this server is the authority for the domain name in the question.TC
is a boolean that indicates the message was truncated because it was longer than 512 bytes.RD
is a boolean that indicates the client wants to do a recursive query.RA
is a boolean that indicates whether the server agreed to do a recursive query.RCODE
is a 4-bit field that indicates the response status. It's used kind of like the status code in HTTP.
Extracting this information requires reading individual bits. For simplicity, I only bothered reading the QR
and Opcode
bits, and didn't go about it very elegantly:
header := binary.BigEndian.Uint16(m.raw[2:4])
m.IsQuery = header&0b1000000000000000 == 0b0000000000000000
m.IsResponse = !m.IsQuery
m.OpCode = "QUERY"
if header&0b0000100000000000 == 0b0000100000000000 {
m.OpCode = "IQUERY"
}
if header&0b0001000000000000 == 0b0001000000000000 {
m.OpCode = "STATUS"
}
We've now parsed enough of the header to guide us through parsing the rest of the query.
The question
The DNS query's header included the number of questions in the query. Similar to the header, each question has a predictable format:
00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 | ...more |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Query name | ||||||||||||||||
Query type | ||||||||||||||||
Query class |
The query name is the actual DNS name being queried, like hello.example.com.
. The length of this name can vary quite a bit, so the name is split into a series of labels. Each section of the domain name between .
characters is a label. In each label, the first byte indicates how long the label is, and is followed by that many bytes of ASCII characters. This continues until a zero-length label is found:
5 | h | e | l | l | o | 7 | e | x | a | m | p | l | e | 3 | c |
o | m | 0 |
The query type is a 16-bit number that corresponds to DNS record types you might be familiar with like A
, CNAME
, MX
, TXT
, etc.
The query class a 16-bit number that corresponds to the class of record, like IN
for "Internet" or CH
for "Chaos". It's almost always IN
.
Sending a DNS Response
The query I received included questions, but no answers. This is where I would do some interesting business logic to figure out some dynamic response to the DNS query and return an answer. For this experiment, I'm just reading some information from a text file.
When I respond to the DNS query, I actually modify the incoming message and append the answer information, then return the modified message back to the requester.
For each answer, I append an answer data to the message and increment the answer count number in the message's header. The format for the answers themselves varies depending on the query type.
General answer format
Each answer follows a similar format, although the lengths of the record name and its value are variable and depend on the type of record being described:
00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 | ...more |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Record name | ||||||||||||||||
Record type | ||||||||||||||||
Record class | ||||||||||||||||
Record TTL (bits 1-16) | ||||||||||||||||
Record TTL (bits 17-32) | ||||||||||||||||
Record data length | ||||||||||||||||
Record data |
type ResourceRecord struct {
Name string
Type uint16
Class uint16
TTL uint32
Data []byte
}
func (r *ResourceRecord) Serialize() []byte {
serialized := []byte{}
serialized = append(serialized, serializeLabels(strings.Split(r.Name, "."))...)
serialized = binary.BigEndian.AppendUint16(serialized, r.Type)
serialized = binary.BigEndian.AppendUint16(serialized, r.Class)
serialized = binary.BigEndian.AppendUint32(serialized, r.TTL)
serialized = binary.BigEndian.AppendUint16(serialized, uint16(len(r.Data)))
serialized = append(serialized, r.Data...)
return serialized
}
Serializing an A
answer
An A
record returns an IPv4 address. Although we normally read IPv4 addresses as four decimal numbers like 192.168.1.200
, each address is actually just a single 32-bit number. You could also think of it as four 8-bit numbers:
00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0-255 | 0-255 | ||||||||||||||
0-255 | 0-255 |
In my Go server, I needed to read an IP address as a string from a text file and serialize it according to the DNS spec, so I found it helpful to parse it as a series of four 8-bit numbers:
type AData struct {
IPAddr string
}
func (d AData) Serialize() ([]byte, error) {
octets := strings.Split(d.IPAddr, ".")
out := []byte{}
for _, o := range octets {
b, err := strconv.Atoi(o)
if err != nil {
return nil, err
}
out = append(out, uint8(b))
}
return out, nil
}
Serializing a CNAME
answer
A CNAME
returns an alias to another domain name, so its format is exactly the same as the series of labels the user sent when making their query. This means the length of a CNAME
answer depends on how long the domain name is. A CNAME
answer might look like this:
9 | c | a | n | o | n | i | c | a | l | 7 | e | x | a | m | p |
l | e | 3 | c | o | m | 0 |
In my Go server, I stored the domain name as a string and wrote a serialization function to split the string into a series of labels and serialize it as described above:
type CNAMEData struct {
Name string
}
func (d CNAMEData) Serialize() ([]byte, error) {
return serializeLabels(strings.Split(d.Name, ".")), nil
}
func serializeLabels(labels []string) []byte {
raw := []byte{}
for _, l := range labels {
raw = append(
raw, append([]byte{uint8(len(l))}, []byte(l)...)...,
)
}
return raw
}
I didn't write implementations for other any other record types, but their formats are equally well-documented in the RFC, and equally interesting!
Just scratching the surface
I was pleased at how easy it was to write a basic DNS server, but in DNS the devil is in the details. There are countless legacy client behaviors to take into account, IPv6 to handle, and modern security enhancements like DNS over HTTPS and DNS over TLS.
I was also very impressed with the Google Public DNS team's blog post about dealing with cache poisoning attacks at a worldwide scale. DNS itself may be easy, but doing it securely and reliably at scale is a clearly a big challenge!