Understanding the basics of DNS

In this article, we will explore how DNS resolution works on a high-level. This blog post is intended for developers and IT folks.
Firstly, let's motivate the understanding of how DNS works by looking at common use cases we may encounter.
- Let's say you're trying to host a website. You're trying to run a small website for your company, and you bought a domain. How will you make the domain point to your servers?

- Your application is down, and it says something about DNS. Where do you go looking?

- Your team is an incident response team and every single application is unreachable from the Internet. What do you do?

- What is the difference between an A record and a CNAME record? If you search online for the differences, you'll probably get an explanation like this.

I recently realized I just muddle through anything related to DNS. While we all know it translates domain names like "www.example.com" to IP addresses like "192.168.1.1", the deeper understanding often escapes us. This lack of knowledge stings when we encounter things like deciphering A vs CNAME vs MX records, setting up DNS on cloud platforms, or even hosting a website. So, let's take a look together at how DNS works by building a DNS resolver which will be able to resolve a domain (www.example.com) to an IP address.
At the end of this article, we should be able to understand how we could build a DNS resolver in our language of choice (Python). This should build our mental model of DNS and help us understand how DNS works!
Let's try to understand DNS in the simplest case.
Building up our own DNS Resolver
Firstly, our DNS resolver will work by asking (querying) other DNS servers if they have the information we need. Let's build up the code we'll need for this step by step, by taking a look at what we need to accomplish.
We want to query an IP address with our question (what is the IP address associated with www.example.com?) and receive a response.
For this, we'll need the following things:
- A network protocol to send the query
- The query
The Network Protocol
The protocols dealing with IP addresses are UDP and TCP.
We will use UDP to send our query, although DNS over TCP or DNS over HTTPS are also possible. Let's write a function to send our query to some IP address over UDP.
import socket
def send_udp_datagram(query, ip_addr):
# create a UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# send our query to an IP address at port 53. Port 53 is the DNS port.
sock.sendto(query, (ip_addr, 53))
# read the response. UDP DNS responses are usually less than 512 bytes
response, _ = sock.recvfrom(1024)
return response
Which IP address should we query?
Next, we need to identify who to make our query to. What IP address should we send our query to?
DNS exists as a hierarchy, with several root nameservers (authoritative DNS servers) which point to other nameservers. Starting from the root nameservers, you can eventually identify the IP address of any domain on the internet. These root nameservers have published IP addresses.
For example, "198.41.0.4" is the IP address for "a.root-servers.", a root nameserver. We'll be using this IP address for our purposes.

Building the query message
A DNS message is a series of bytes. For example, the query for the IP address of example.com would be the following:
D\xcb\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01
A DNS message contains two parts:
- Header
- Question
The DNS Header
The header contains some data about what the message is about, such as:
- If the message is a query or a response
- If the server is an authoritative server or not
- The number of domains we are querying about
Represented in Python code, the DNS Header might look something like this:
DNSHeader(
num_questions=1,
num_additionals=0,
num_authorities=0,
num_answers=0,
id=0x0111,
flags=0,
)
There's actually only one field we should keep an eye on, the question field. We can safely ignore the others for now. Since we're making a query about example.com
, we have 1 question, which we indicated in the header.
More information about the remaining fields
The id is a randomly generated 16-bit int.
The remaining fields (num_additionals
, flags, etc.) will be covered later when we look at DNS responses.
The DNS Question
DNSQuestion(
name="example.com",
type=A_RECORD,
class=CLASS_IN
)
The DNS question itself is simple. It contains the domain name we're querying (www.example.com
). It also contains the type of record. There are various types of DNS records, but the one most relevant to us is the A record, which identifies an IP address from a domain name.
More information about the remaining field
The class field is always set to 1 for DNS queries over the Internet. More information can be found in specification for DNS.
The DNS query
With both the DNS header and the DNS question, we can construct the DNS query.
@dataclass
class DNSQuery:
header: DNSHeader
question: DNSQuestion
def to_bytes(self):
return self.header.to_bytes() + self.question.to_bytes()
We'll gloss over the processing of encoding the query as bytes, but more detail is included at the bottom of the article if you're interested.
Our code now looks like this:
header = DNSHeader(
num_questions=1,
num_additionals=0,
num_authorities=0,
num_answers=0,
id=8721,
flags=0,
)
question = DNSQuestion(
name="example.com",
type_=A_RECORD,
class_=CLASS_IN
)
query = DNSQuery(header, question).to_bytes()
ROOT_NAMESERVER_IP = "198.41.0.4"
response = send_udp_datagram(query, ROOT_NAMESERVER_IP)
print(response)
b'"\x11\x80\x80\x00\x01\x00\x01\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x01L6\x00\x04]\xb8\xd8"
The response we received from the root nameserver
In a future post, we'll be going through deciphering the result. Stay tuned!
Appendix
Complete code used in this article
import dataclasses
import random
import socket
import struct
from dataclasses import dataclass
CLASS_IN = 1
A_RECORD = 1
def send_udp_datagram(query, ip_addr):
# create a UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# send our query to an IP address at port 53. Port 53 is the DNS port.
sock.sendto(query, (ip_addr, 53))
# read the response. UDP DNS responses are usually less than 512 bytes
response, _ = sock.recvfrom(1024)
return response
@dataclass
class DNSHeader:
id: int
flags: int
num_questions: int
num_answers: int
num_authorities: int
num_additionals: int
def to_bytes(self):
fields = dataclasses.astuple(self)
return struct.pack('!HHHHHH', *fields)
@dataclass
class DNSQuestion:
name: str
type_: int
class_: int
def encoded_name(self):
encoded = b''
for part in self.name.encode('ascii').split(b'.'):
encoded += bytes([len(part)]) + part
return encoded + b'\x00'
def to_bytes(self):
return self.encoded_name() + struct.pack('!HH', self.type_, self.class_)
@dataclass
class DNSQuery:
header: DNSHeader
question: DNSQuestion
def to_bytes(self):
return self.header.to_bytes() + self.question.to_bytes()
header = DNSHeader(
num_questions=1,
num_additionals=0,
num_authorities=0,
num_answers=0,
id=8721,
flags=0,
)
question = DNSQuestion(
name="example.com",
type_=A_RECORD,
class_=CLASS_IN
)
query = DNSQuery(header, question).to_bytes()
ROOT_NAMESERVER_IP = "198.41.0.4"
ROOT_NAMESERVER_IP = "1.1.1.1"
response = send_udp_datagram(query, ROOT_NAMESERVER_IP)
print(response)