Building a DNS resolver in Go from scratch - Part 2
Recap
In the last part, we went over the DNS components and outlined what the query's cycle looks like. In this part, we'll go over the format of a DNS packet and start building our toy resolver using Go & send a DNS query to the root nameserver.
Israel is committing a genocide against the people of Palestine.
Find out how you can help https://techforpalestine.org/learn-more/
Free Palestine 🇵🇸 End the occupation 🇵🇸
View the full code
DNS Packet Structure
DNS uses the same format for requests and responses. A message consists of 5 sections, a header followed by question, answer, authority & additional sections.
We’ll build the query by populating the header and question sections, the other sections will be empty, we’ll discuss them in detail later when we start parsing the response.
Header
A DNS header is 12 bytes long, it consists of 6 fields, each being 2 bytes long.
For us, the important fields and the values we’ll use are:
ID
, an identifier for the DNS query. The server copies this to the responseQR
, whether this message is a query or a responseOpcode
, type of query, set to 1 for standard queryQDCOUNT
, number of questions in message. we'll set this to 1ANCOUNT
, number of answer records in response, 0 in a queryNSCOUNT
, number of authority nameserver records in response, 0 in a queryARCOUNT
, number of additional records in response, 0 in a query
Question section
The question section contains the queries we're asking the nameserver.
Its fields are:
QNAME
, encoding of the domain name, a variable length fieldQTYPE
, type of domain name translation we’re asking for, set toA
to indicate we need an IPv4 addressQCLASS
, query class, alwaysIN
for our case
Encoding the domain name
The DNS protocol defines how to encode a domain name, because QNAME
is a bit is a variable length field we need to encode it in a special format. That way whoever parses the message can tell when it ends without needing to know the length.
We can encode the domain name by doing the following:
// Pseudocode
// array of bytes
encoded_domain = []
split the domain name by `.`
for each part
encoded_domain += <length of part> <part as bytes>
encoded_domain += 0x00 to indicate termination
For example, if we're encoding mail.google.com
, this encodes to
Building the packet
Now we're ready to start building the query packet
It will be a simple utility function, that accepts a string for the domain name and returns a slice of bytes to be sent.
// dns/message.go
package dns
import (
"strings"
)
func NewDNSMessage(query string) []byte {
message := []byte{}
header := []byte{
// Transaction ID
0xaa, 0xaa,
// Flags
0x00, 0x00,
// Queries, Answers, Authority Nameservers & Additional Count
0x00, 0x01,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
}
// Domain name, encoded as <PART_LENGTH><PART>
qname := []byte{}
parts := strings.Split(query, ".")
for _, p := range parts {
l := byte(len(p))
qname = append(qname, l)
qname = append(qname, p...)
}
// Termination byte
qname = append(qname, 0x00)
// QTYPE, 1 = A record (IPv4 address)
qtype := []byte{0x00, 0x01}
// QCLASS, 1 = IN (Internet)
qclass := []byte{0x00, 0x01}
message = append(message, header...)
message = append(message, qname...)
message = append(message, qtype...)
message = append(message, qclass...)
return message
}
We will use a dummy value for the transaction ID, this won't be a problem as we’ll only have 1 DNS query in flight at any moment.
For flags we'll use 0x0000
, this translates to the following properties on the query
Ideally, the message should be serialized from a DNS struct. But for what we're building a simple function gets the job done.
Asking the root server
Typically DNS works over UDP on port 53. We can use net.Dial
for this, it creates a net.Conn
, with it we can send and receive UDP packets.
The following code builds a DNS query packet, sends it to the root server, waits for the response and copies it into a slice of bytes. Later we will get to parsing the response and building a DNS message from it.
// dns/resolver.go
package dns
import (
"fmt"
"net"
"os"
)
func ResolveFromRoot(domain string, root string) string {
target := root
fmt.Printf("Asking: %s\n", target)
message := NewDNSMessage(domain)
c, err := net.Dial("udp", target+":53")
if err != nil {
fmt.Println("connection to server failed")
os.Exit(1)
}
defer c.Close()
c.Write(message)
b := make([]byte, 1024)
// Read response from socket to the slice of bytes
n, _ := c.Read(b)
fmt.Println("response bytes:", b[:5], "...", b[n-5:n])
return "TBC"
}
Take off
Putting everything together in main.go
to test what we have so far.
// main.go
package main
import (
"fmt"
"github.com/mostafaahmed97/rootwalk/dns"
)
var roots = map[string]string{
"a": "198.41.0.4",
"b": "170.247.170.2",
"c": "192.33.4.12",
"d": "199.7.91.13",
"e": "192.203.230.10",
"f": "192.5.5.241",
"g": "192.112.36.4",
"h": "198.97.190.53",
"i": "192.36.148.17",
"j": "192.58.128.30",
"k": "193.0.14.129",
"l": "199.7.83.42",
"m": "202.12.27.33",
}
func main() {
r := "a"
domain := "google.com"
fmt.Printf("Resolving: %s from Root Server: %s(%s)\n", domain, r, roots[r])
ip := dns.ResolveFromRoot(domain, roots[r])
fmt.Printf("%s is at %s\n", domain, ip)
}
and running with go run .
we get the following output:
We can also see our query and the root's response to it in Wireshark
In the next part, we will parse the response received and update the resolver to recursively ask nameservers until we get our answer.
Cool docs/articles
Subscribe to my newsletter
Read articles from Mostafa Ahmed directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mostafa Ahmed
Mostafa Ahmed
Free Palestine, end the genocide 🇵🇸 https://techforpalestine.org/learn-more/