← Back to DevBytes

Implementing DNS Protocol: From Theory to Practice

DNS Protocol Fundamentals

What Is the DNS Protocol?

The Domain Name System (DNS) is the internet's phonebook—a hierarchical, distributed database that maps human-friendly domain names (like example.com) to machine-routable IP addresses (like 93.184.216.34). At its core, DNS is a binary protocol operating over UDP (and sometimes TCP) on port 53. It uses a strict request–response model defined by a compact wire format, where every packet has a header, question section, answer section, authority section, and additional section. Understanding this wire format is essential for building anything from a custom resolver to a captive portal detection system.

Why DNS Matters for Developers

DNS sits at the foundation of every networked application. Beyond simple name resolution, modern development relies on DNS for:

Implementing DNS from scratch gives you complete control over resolution logic, bypasses OS stub resolver limitations, and deepens your understanding of internet infrastructure.

How DNS Resolution Works

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The Recursive Resolution Walk

When a client asks for www.example.com, a typical recursive resolver follows these steps:

The DNS wire format carries the same structure regardless of the step—only the server and the question change. A DNS packet consists of a fixed 12‑byte header, followed by variable‑length sections.

DNS Message Wire Format

Every DNS message (query and response) has the layout:


+---------------------+
|        Header       | 12 bytes: ID, flags, counts
+---------------------+
|       Question      | Variable: domain name, type, class
+---------------------+
|        Answer       | Variable: resource records
+---------------------+
|      Authority      | Variable: NS records, etc.
+---------------------+
|      Additional     | Variable: glue records, OPT
+---------------------+

The header contains:

Implementing a DNS Client from Scratch

Building a Query Packet in Python

We'll construct a raw DNS query for an A record of example.com. The domain name must be encoded with length‑prefixed labels (no dots), terminated by a zero byte. We'll use the struct module for binary packing.


import struct
import socket

def build_dns_query(domain: str, query_type: int = 1) -> bytes:
    # Header: ID=0x1234, flags=0x0100 (standard query, recursion desired)
    header = struct.pack('!HHHHHH',
        0x1234,  # ID
        0x0100,  # Flags: QR=0, OPCODE=0, AA=0, TC=0, RD=1, RA=0, Z=0, RCODE=0
        1,       # QDCOUNT (one question)
        0,       # ANCOUNT
        0,       # NSCOUNT
        0        # ARCOUNT
    )
    
    # Encode domain name: split into labels, prefix each with length byte
    labels = domain.split('.')
    question_name = b''
    for label in labels:
        question_name += bytes([len(label)]) + label.encode('ascii')
    question_name += b'\x00'  # terminating zero length
    
    # Question: QTYPE (A = 1), QCLASS (IN = 1)
    question = question_name + struct.pack('!HH', query_type, 1)
    
    return header + question

# Example usage
query = build_dns_query('example.com')
print(query.hex())

This produces a valid 29‑byte DNS query. The ID 0x1234 is arbitrary; real implementations generate a random ID to avoid spoofing. The flags field 0x0100 sets only the RD (Recursion Desired) bit.

Sending the Query and Receiving a Response

DNS uses UDP port 53. We'll send the query to Google's public resolver 8.8.8.8 and receive up to 512 bytes (the classic DNS maximum over UDP). Larger responses require TCP, but A record queries typically fit.


def send_query(query: bytes, server: str = '8.8.8.8', port: int = 53) -> bytes:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(3)
    try:
        sock.sendto(query, (server, port))
        response, _ = sock.recvfrom(512)  # standard UDP limit
        return response
    except socket.timeout:
        raise TimeoutError('DNS query timed out')
    finally:
        sock.close()

response = send_query(build_dns_query('example.com'))
print(f"Received {len(response)} bytes")

Parsing the DNS Response

A full parser handles compressed names (pointers) and multiple record types. For clarity, we'll extract the header fields and decode the first A record from the answer section.


def parse_dns_response(response: bytes):
    # Unpack header
    header = struct.unpack_from('!HHHHHH', response, 0)
    id_, flags, qdcount, ancount, nscount, arcount = header
    
    # Decode flags
    qr = (flags >> 15) & 1
    opcode = (flags >> 11) & 0xF
    rcode = flags & 0xF
    
    print(f"ID: {id_:#x}, QR: {qr}, Opcode: {opcode}, RCODE: {rcode}")
    print(f"Questions: {qdcount}, Answers: {ancount}")
    
    offset = 12  # skip header
    
    # Skip question section (assume one question with standard encoding)
    while response[offset] != 0:
        label_len = response[offset]
        offset += 1 + label_len
    offset += 1  # zero byte
    offset += 4  # QTYPE + QCLASS
    
    # Parse answer records
    for i in range(ancount):
        # Decode name (may use compression pointers)
        name, offset = decode_name(response, offset)
        rtype, rclass, ttl, rdlength = struct.unpack_from('!HHIH', response, offset)
        offset += 10
        rdata = response[offset:offset+rdlength]
        offset += rdlength
        
        if rtype == 1:  # A record
            ip = '.'.join(str(b) for b in rdata)
            print(f"A record: {name} -> {ip} (TTL: {ttl}s)")
        elif rtype == 28:  # AAAA record
            # format IPv6 address
            ip = ':'.join(f'{rdata[i]:02x}{rdata[i+1]:02x}' for i in range(0,16,2))
            print(f"AAAA record: {name} -> {ip} (TTL: {ttl}s)")
        # else skip other types for brevity

def decode_name(data: bytes, offset: int):
    """Decode a domain name, handling compression pointers (RFC 1035)."""
    labels = []
    jumped = False
    original_offset = offset
    while True:
        length = data[offset]
        if length == 0:
            offset += 1
            break
        # Check for compression pointer (top two bits set)
        if (length & 0xC0) == 0xC0:
            pointer = struct.unpack_from('!H', data, offset)[0] & 0x3FFF
            if not jumped:
                original_offset = offset + 2  # save position after pointer
            offset = pointer
            jumped = True
            continue
        offset += 1
        labels.append(data[offset:offset+length].decode('ascii'))
        offset += length
    if jumped:
        offset = original_offset
    return '.'.join(labels), offset

# Example usage
response = send_query(build_dns_query('example.com'))
parse_dns_response(response)

This parser handles the common compression scheme where a domain name suffix is replaced by a 2‑byte pointer to an earlier occurrence in the packet. Real‑world responses often chain multiple pointers; our loop handles that correctly.

Best Practices for DNS Implementations

Error Handling and Retries

DNS over UDP is inherently unreliable. Always implement exponential backoff with jitter:


import random
import time

def resolve_with_retry(domain, max_retries=3):
    base_delay = 0.5
    for attempt in range(max_retries):
        try:
            query = build_dns_query(domain)
            response = send_query(query)
            # Validate response ID and RCODE
            header = struct.unpack_from('!HH', response, 0)
            if header[0] != 0x1234:
                raise ValueError('Response ID mismatch')
            rcode = header[1] & 0xF
            if rcode == 3:
                return None  # NXDOMAIN
            if rcode != 0:
                raise RuntimeError(f'Server error RCODE={rcode}')
            return response
        except (TimeoutError, OSError) as e:
            delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
            time.sleep(delay)
    raise RuntimeError('All DNS attempts failed')

Also handle truncated responses (TC bit set). If the TC flag is 1, you must retry over TCP on port 53 using the same query. TCP DNS prepends a 2‑byte length field.

Caching and TTL Management

Respect the TTL (Time‑to‑Live) in resource records. Maintain an in‑memory cache keyed by (domain, type). Before sending a query, check the cache; if the record hasn't expired, return it immediately.


import time
from collections import namedtuple

CacheEntry = namedtuple('CacheEntry', ['records', 'expires_at'])

dns_cache = {}

def cached_resolve(domain: str, qtype: int = 1):
    key = (domain, qtype)
    now = time.time()
    if key in dns_cache and dns_cache[key].expires_at > now:
        return dns_cache[key].records
    response = resolve_with_retry(domain)
    if response is None:
        return None
    # Parse and extract records with TTLs (simplified)
    records = []
    # ... parsing code, collecting (record, min_ttl) ...
    # Store with earliest expiration
    min_ttl = min(ttl for _, ttl in records) if records else 300
    dns_cache[key] = CacheEntry(records, now + min_ttl)
    return [r for r, _ in records]

For production, use a proper LRU cache and consider negative caching (NXDOMAIN, SERVFAIL) with shorter TTLs as recommended by RFC 2308.

Security Considerations: DNSSEC

The DNS protocol itself is unauthenticated; DNSSEC adds cryptographic signatures to verify responses. When implementing a resolver, you can optionally validate DNSSEC by:

Building full DNSSEC validation is complex. For most custom tools, rely on a validating upstream resolver (like 8.8.8.8 or 1.1.1.1) and check the AD (Authenticated Data) flag in responses.

Rate Limiting and Concurrency

A raw UDP client can easily flood servers. Implement a token‑bucket or sliding‑window rate limiter per upstream resolver. For asynchronous usage, use asyncio with non‑blocking UDP or a pool of sockets. Never issue more than a few hundred queries per second to a single resolver to avoid being blocked.

Conclusion

Implementing the DNS protocol from scratch demystifies one of the internet's most fundamental systems. By mastering the binary wire format, recursive resolution logic, and best practices around caching, error handling, and security, you gain the ability to build custom resolvers, proxy‑level DNS filters, or advanced service‑discovery mechanisms. The code examples above provide a solid foundation—extend them with EDNS0, TCP fallback, or DNSSEC validation to suit your specific needs. Whether you're debugging network issues or creating a new distributed system, a deep understanding of DNS will serve you well throughout your career as a developer.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles