Understanding the basics of DNS

Understanding the basics of DNS
Photo by Leonie Clough on Unsplash

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.

  1. 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?
It doesn't help that DNS registration always looks so confusing!
  1. Your application is down, and it says something about DNS. Where do you go looking?
How to Fix DNS Server Not Responding Error in WordPress
  1. Your team is an incident response team and every single application is unreachable from the Internet. What do you do?
Understanding how Facebook disappeared from the Internet
Today at 1651 UTC, we opened an internal incident entitled “Facebook DNS lookup returning SERVFAIL” because we were worried that something was wrong with our DNS resolver 1.1.1.1. But as we were about to post on our public status page we realized something else more serious was going on.
  1. 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.
Can you decipher what this is trying to say?

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:

  1. A network protocol to send the query
  2. 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.

What are root name servers? | Netnod
Picture from https://www.netnod.se/i-root/what-are-root-name-servers

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:

  1. Header
  2. Question

The DNS Header

The header contains some data about what the message is about, such as:

  1. If the message is a query or a response
  2. If the server is an authoritative server or not
  3. 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.

https://datatracker.ietf.org/doc/html/rfc1035#section-3.2.4

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)