Introduction
I recently built a DNS Resolver.
Domain Name System (DNS) is like the phonebook of the internet. Humans access information online through domain names, like nytimes.com or espn.com. Web browsers interact through Internet Protocol (IP) addresses. DNS translates domain names to IP addresses so browsers can load Internet resources.
This process is called resolving the domain name.
DNS Hierarchy
Think of DNS as a tree with branches. At the top is the root domain, under which are top-level domains (TLDs) like .com, .net, .org. Under these are second-level domains, which are the names people can register (like "google" in "google.com").
Three different types of DNS servers
You have three different types of DNS servers:
Root Name Servers: Like an index in a library that directs you to different sections.
TLD Name Servers: Like a specific shelf in the library that holds books from one category.
Authoritative Name Servers: Like a specific book in the library that provides detailed information on a particular top
To simplify the flow for you:
You enter a domain name.
DNS Resolver receives the domain name.
It queries the root name server which tells the resolver the specific Top Level Domain (TLD) Nameserver to go to e.g. for ".com"
It queries the TLD server which tells the resolver the specific authoritative server to go to.
It queries the authoritative server. This is the one returning the IP address.
The bug
The result we get from the different DNS servers is in binary data. We have to parse the binary and extract data from it.
Typically, you will receive multiple resource records as response (in binary). A Resource Record (RR) in DNS is a basic information unit. It's a single entry in the DNS database that provides information about a domain. Each RR has a specific type and contains data relevant to that type.
There are different types, to mention a few:
A Record
NS Record
CNAME Record
Parsing an A Record will give you the end result, the final IP address.
The bug I had was when I was parsing the NS Record. It's obvious looking back, but it wasn't at the time. I was miscalculating the offset of the NS Record.
You would use an NS Record to query the authoritative name server for the domain.
To show you some code, here is parseRecord
function:
function parseRecord(buffer: Buffer, offset: number): DNSRecord {
// Extracts the domain name at the current offset for all record types, as every DNS record
// starts with the domain name it's associated with.
const domainNameData = parseDomainName(buffer, offset)
offset = domainNameData.newOffset
const type = buffer.readUInt16BE(offset) as DNSType
offset += 2 // Move past the type field
offset += 2 // Skip class field (2 bytes)
offset += 4 // Skip TTL field (4 bytes)
const dataLength = buffer.readUInt16BE(offset)
offset += 2 // Move past the data length field
let rdata: string
if (type === NS_RECORD_TYPE) {
// Additional call to parseDomainName for NS_RECORD_TYPE because in NS records, the rdata
// section itself contains a domain name that needs to be parsed. This is unlike A or AAAA
// records where rdata contains an IP address and not a domain name.
// This domain name will be used to query the authoritative name server for the domain.
const { domainName, newOffset } = parseDomainName(buffer, offset)
offset = newOffset
rdata = domainName
} else {
const rdataBuffer = buffer.slice(offset, offset + dataLength)
rdata = interpretRDataARecord({
rdata: rdataBuffer,
type,
})
offset += dataLength // Update offset to the end of rdata
}
return {
domainName: domainNameData.domainName,
type,
rdata,
offset,
}
}
The specific bug: I would calculate the final offset after parsing NS Records as I did for A Records: offset += dataLength
.
I knew it was wrong because I had a test in place. The Buffer size was 58, but offset 64. Offset was out of bounds, which shouldn't be the case.
My first approach to debugging
Because I'm calling different functions, my first quick approach was to begin putting console logs everywhere. I realized quickly it got hard to keep track of how the offset
changes throughout the parsing logic.
My second approach to debugging
I used the JavaScript Debugger in VS Code.
I set a breakpoint at the beginning of parseRecord
function. I ran the test inside the JavaScript Debug Terminal and the debugger stopped at the breakpoint. I stepped through the entire parsing logic step by step.
That's how I discovered we're done parsing the NS Record after having parsed the domain. This makes sense because we need to use the domain NS Record gives us to query the A Record.
The A Record will give us the actual IP address.
The JavaScript Debugger in VS Code
Open Terminal in VS Code.
Open the JavaScript Debug Terminal.
Set a breakpoint.
Run whatever command.
Step through the logic slowly while inspecting the data.
The solution
The parseDomainName
function returns the offset where it stopped parsing and the human readable domain name. Because NS Record gives us the domain we have to query to receive the A Record, it makes sense we're done parsing.
End
I hope you learned a thing or two.
If you want to dive deeper into DNS, watch this by ByteByteGo.