back to research
rustsystemsnetworking

Building a DNS Resolver from Scratch in Rust

A deep dive into implementing a DNS resolver using Rust, covering UDP sockets, DNS packet parsing, and recursive resolution.

Chaitanya

Source Code: https://github.com/cb7chaitanya/networking/tree/main/dns-resolver

DNS is often treated as background infrastructure — something that "just works."

But behind every HTTP request, TLS handshake, and API call lies a distributed protocol that translates human-readable names into IP addresses.

This post walks through the implementation of a fully functional iterative DNS resolver written in Rust, including:

  • Manual DNS packet construction and parsing
  • Iterative resolution across root, TLD, and authoritative servers
  • Name compression decoding
  • EDNS0 support
  • Positive and negative caching
  • Failure handling (NXDOMAIN, SERVFAIL, timeouts)
  • Packet-level verification using Wireshark
  • Performance benchmarking (cold vs warm cache)
  • Concurrency scaling analysis

This is not a wrapper around libc. It is a ground-up implementation of the DNS protocol.

Why Build a Resolver?

Most applications delegate DNS to recursive resolvers like Google Public DNS or Cloudflare. Those systems perform root lookup, TLD resolution, authoritative resolution, caching, and retry logic.

In this project, the resolver performs iterative resolution itself. That means:

  • Recursion Desired (RD) flag is disabled
  • The resolver follows referrals manually
  • Authority and Additional sections must be parsed correctly
  • Glue records must be extracted
  • Delegation must be handled explicitly

This makes the protocol transparent.

DNS Architecture Overview

DNS is a hierarchical distributed system:

Client
  ↓
Root Servers
  ↓
TLD Servers (.com, .org, etc.)
  ↓
Authoritative Name Servers
plain text

The 13 logical root servers are coordinated by IANA under the oversight of ICANN.

DNS Message Format: Wire-Level Understanding

A DNS message consists of: Header (12 bytes), Question section, Answer section, Authority section, and Additional section.

Header Structure

  • Transaction ID: 16 bits
  • Flags: 16 bits
  • QDCOUNT: 16 bits
  • ANCOUNT: 16 bits
  • NSCOUNT: 16 bits
  • ARCOUNT: 16 bits

Important flags: QR (Query/Response), RD (Recursion Desired), RA (Recursion Available), AA (Authoritative Answer), RCODE (Result Code).

Because this resolver is iterative: RD = 0.

Constructing DNS Queries

The resolver manually constructs the DNS header, question section, and EDNS0 OPT record in the Additional section.

EDNS0 extends UDP payload size beyond the traditional 512-byte limit. Without EDNS0, large responses are truncated requiring TCP fallback. With EDNS0, UDP payload up to 4096 bytes avoids unnecessary TCP fallback.

Name Encoding and Compression

DNS encodes domain names as: [length][label][length][label]...[0]

Example:

07 example
03 com
00
plain text

Responses frequently use compression pointers (0xC0 0x0C): the first two bits (11) indicate a pointer, the remaining 14 bits represent the offset in the packet.

Correct pointer decoding is critical to avoid infinite loops, incorrect label resolution, and offset corruption. This resolver handles nested pointers safely and enforces bounds.

Iterative Resolution Logic

Resolution proceeds as follows:

  1. Select a root server
  2. Send an A record query
  3. If Answer section present: return result
  4. If referral: extract NS records from Authority section
  5. Extract glue A records from Additional section
  6. Select next nameserver
  7. Repeat

Unlike recursive resolvers, this implementation must explicitly manage delegation, including parsing NS records, handling glue, rotating across multiple servers, and retrying when necessary.

Caching Design

The resolver implements:

  • Positive caching (A records)
  • Negative caching (NXDOMAIN)
  • TTL-based expiration

Each cache entry includes the result (IP or NXDOMAIN marker) and an expiration timestamp. Cache lookups avoid unnecessary network traversal and drastically reduce latency.

Failure Case Analysis

NXDOMAIN (RCODE = 3)

Indicates the domain does not exist. Resolver behavior: stop resolution, extract SOA record, cache negative result, respect negative TTL (RFC 2308). This prevents repeated unnecessary queries.

SERVFAIL (RCODE = 2)

Indicates temporary failure. Resolver behavior: attempt alternate nameservers, retry with backoff, avoid aggressive caching.

Critical distinction: NXDOMAIN is permanent. SERVFAIL is transient.

Timeout Handling

If a UDP query receives no response: retry with exponential backoff, rotate across available nameservers, cap maximum retries. Prevents infinite resolution loops.

Performance Benchmarks

Cold Cache Resolution

Includes root query, TLD query, and authoritative query. Measured latency: 110-180 ms. Dominated by network RTT.

Warm Cache Resolution

All responses served locally. Measured latency: < 1 ms. Improvement: ~100x-150x faster.

This illustrates why caching is fundamental to DNS scalability.

Concurrency Benchmarks

Concurrency implemented via Arc<RwLock<HashMap<...>>>.

Cold cache (500 parallel queries): total time ~350-600 ms, avg latency ~150 ms. Network-bound workload.

Warm cache (5000 parallel queries): total time ~15-25 ms, avg latency < 1 ms.

Warm cache throughput: ~150k-250k queries/sec (local process). Reveals that DNS parsing is lightweight, network traversal dominates latency, and lock design impacts scalability.

What This Project Reveals

Implementing a DNS resolver from scratch demonstrates:

  • Backward-compatible protocol evolution (EDNS0 layered over RFC 1035)
  • Compression as a space optimization with parsing complexity
  • Delegation as a scalability mechanism
  • Caching as the primary performance amplifier
  • Failure handling as a first-class systems concern

DNS is not just name lookup. It is a distributed system, a caching problem, a wire protocol exercise, and a concurrency design challenge.

Closing Thoughts

This resolver is not intended to replace production-grade systems such as BIND or Unbound. It is designed to make the protocol transparent rather than abstract.

After implementing DNS manually: every curl request has visible mechanics, every TLS handshake begins with a system you understand, every latency spike has a traceable root.

DNS stops being invisible infrastructure. It becomes a system you can reason about byte by byte.