Files
numa/site/blog/dns-from-scratch.html
Razvan Dimescu 5b2cc874a1 feat: TCP fallback, query minimization, UDP auto-disable
Transport resilience for restrictive networks (ISPs blocking UDP:53):
- DNS-over-TCP fallback: UDP fail/truncation → automatic TCP retry
- UDP auto-disable: after 3 consecutive failures, switch to TCP-first
- IPv6 → TCP directly (UDP socket binds 0.0.0.0, can't reach IPv6)
- Network change resets UDP detection for re-probing
- Root hint rotation in TLD priming

Privacy:
- RFC 7816 query minimization: root servers see TLD only, not full name

Code quality:
- Merged find_starting_ns + find_starting_zone → find_closest_ns
- Extracted resolve_ns_addrs_from_glue shared helper
- Removed overall timeout wrapper (per-hop timeouts sufficient)
- forward_tcp for DNS-over-TCP (RFC 1035 §4.2.2)

Testing:
- Mock TCP-only DNS server for fallback tests (no network needed)
- tcp_fallback_resolves_when_udp_blocked
- tcp_only_iterative_resolution
- tcp_fallback_handles_nxdomain
- udp_auto_disable_resets
- Integration test suite (4 suites, 51 tests)
- Network probe script (tests/network-probe.sh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:50:19 +02:00

652 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>I Built a DNS Resolver from Scratch in Rust — Numa</title>
<meta name="description" content="How DNS actually works at the wire
level — label compression, TTL tricks, DoH, and what surprised me
building a resolver with zero DNS libraries.">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #f5f0e8;
--bg-surface: #ece5da;
--bg-elevated: #e3dbce;
--bg-card: #faf7f2;
--amber: #c0623a;
--amber-dim: #9e4e2d;
--teal: #6b7c4e;
--teal-dim: #566540;
--violet: #64748b;
--text-primary: #2c2418;
--text-secondary: #6b5e4f;
--text-dim: #a39888;
--border: rgba(0, 0, 0, 0.08);
--border-amber: rgba(192, 98, 58, 0.22);
--font-display: 'Instrument Serif', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg-deep);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 400;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
}
/* --- Blog nav --- */
.blog-nav {
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.blog-nav a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
transition: color 0.2s;
}
.blog-nav a:hover { color: var(--amber); }
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
}
.blog-nav .wordmark:hover { color: var(--amber); }
.blog-nav .sep {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.75rem;
}
/* --- Article --- */
.article {
max-width: 720px;
margin: 0 auto;
padding: 3rem 2rem 6rem;
}
.article-header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.article-header h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
margin-bottom: 1rem;
color: var(--text-primary);
}
.article-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.04em;
}
.article-meta a {
color: var(--amber);
text-decoration: none;
}
.article-meta a:hover { text-decoration: underline; }
/* --- Prose --- */
.article h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.8rem;
line-height: 1.2;
margin: 3rem 0 1rem;
color: var(--text-primary);
}
.article h3 {
font-family: var(--font-body);
font-weight: 600;
font-size: 1.2rem;
margin: 2rem 0 0.75rem;
color: var(--text-primary);
}
.article p {
margin-bottom: 1.25rem;
color: var(--text-secondary);
font-size: 1.05rem;
}
.article a {
color: var(--amber);
text-decoration: underline;
text-decoration-color: rgba(192, 98, 58, 0.3);
text-underline-offset: 2px;
transition: text-decoration-color 0.2s;
}
.article a:hover {
text-decoration-color: var(--amber);
}
.article strong {
color: var(--text-primary);
font-weight: 600;
}
.article ul, .article ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
color: var(--text-secondary);
}
.article li {
margin-bottom: 0.4rem;
font-size: 1.05rem;
}
.article blockquote {
border-left: 3px solid var(--amber);
padding: 0.75rem 1.25rem;
margin: 1.5rem 0;
background: rgba(192, 98, 58, 0.04);
border-radius: 0 4px 4px 0;
}
.article blockquote p {
color: var(--text-secondary);
font-style: italic;
margin-bottom: 0;
}
/* --- Code --- */
.article code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--bg-elevated);
padding: 0.15em 0.4em;
border-radius: 3px;
color: var(--amber-dim);
}
.article pre {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
overflow-x: auto;
line-height: 1.55;
}
.article pre code {
background: none;
padding: 0;
border-radius: 0;
color: var(--text-primary);
font-size: 0.85rem;
}
/* --- Images --- */
.article img {
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--border);
margin: 1.5rem 0;
}
/* --- Tables --- */
.article table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.95rem;
}
.article th {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
text-align: left;
padding: 0.6rem 1rem;
border-bottom: 2px solid var(--border);
}
.article td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
/* --- Footer --- */
.blog-footer {
text-align: center;
padding: 3rem 2rem;
border-top: 1px solid var(--border);
max-width: 720px;
margin: 0 auto;
}
.blog-footer a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
margin: 0 1rem;
}
.blog-footer a:hover { color: var(--amber); }
/* --- Responsive --- */
@media (max-width: 640px) {
.article { padding: 2rem 1.25rem 4rem; }
.article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; }
}
</style>
</head>
<body>
<nav class="blog-nav">
<a href="/" class="wordmark">Numa</a>
<span class="sep">/</span>
<a href="/blog/">Blog</a>
</nav>
<article class="article">
<header class="article-header">
<h1>I Built a DNS Resolver from Scratch in Rust</h1>
<div class="article-meta">
March 2026 · <a href="https://dimescu.ro">Razvan Dimescu</a>
</div>
</header>
<p>I wanted to understand how DNS actually works. Not the “it translates
domain names to IP addresses” explanation — the actual bytes on the
wire. What does a DNS packet look like? How does label compression work?
Why is everything crammed into 512 bytes?</p>
<p>So I built one from scratch in Rust. No <code>hickory-dns</code>, no
<code>trust-dns</code>, no <code>simple-dns</code>. The entire RFC 1035
wire protocol — headers, labels, compression pointers, record types —
parsed and serialized by hand. It started as a weekend learning project,
became a side project I kept coming back to over 6 years, and eventually
turned into <a href="https://github.com/razvandimescu/numa">Numa</a>
which I now use as my actual system DNS.</p>
<p>A note on terminology: Numa supports two resolution modes.
<em>Forward</em> mode relays queries to an upstream (Quad9, Cloudflare,
or any DoH provider). <em>Recursive</em> mode walks the delegation chain
from root servers itself — iterative queries to root, TLD, and
authoritative nameservers, with full DNSSEC validation. In both modes,
Numa does useful things with your DNS traffic locally (caching, ad
blocking, overrides, local service domains) before resolving what it
cant answer. This post covers the wire protocol and forwarding path; <a
href="/blog/dnssec-from-scratch.html">the next post</a> covers recursive
resolution and DNSSEC.</p>
<p>Heres what surprised me along the way.</p>
<h2 id="what-does-a-dns-packet-actually-look-like">What does a DNS
packet actually look like?</h2>
<p>You can see a real one yourself. Run this:</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="ex">dig</span> @127.0.0.1 example.com A +noedns</span></code></pre></div>
<pre><code>;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 15242
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 53 IN A 104.18.27.120
example.com. 53 IN A 104.18.26.120</code></pre>
<p>Thats the human-readable version. But whats actually on the wire? A
DNS query for <code>example.com A</code> is just 29 bytes:</p>
<pre><code> ID Flags QCount ACount NSCount ARCount
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
Header: AB CD 01 00 00 01 00 00 00 00 00 00
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
↑ ↑ ↑
│ │ └─ 1 question, 0 answers, 0 authority, 0 additional
│ └─ Standard query, recursion desired
└─ Random ID (we&#39;ll match this in the response)
Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
── ───────────────────── ── ───────── ── ───── ─────
7 e x a m p l e 3 c o m end A IN
↑ ↑ ↑
└─ length prefix └─ length └─ root label (end of name)</code></pre>
<p>12 bytes of header + 17 bytes of question = 29 bytes to ask “whats
the IP for example.com?” Compare that to an HTTP request for the same
information — youd need hundreds of bytes just for headers.</p>
<p>We can send exactly those bytes and capture what comes back:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a>python3 <span class="op">-</span>c <span class="st">&quot;</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="er">import socket</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co"># Hand-craft a DNS query: header (12 bytes) + question (17 bytes)</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a>q <span class="op">=</span> <span class="st">b&#39;</span><span class="ch">\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00</span><span class="st">&#39;</span> <span class="co"># header</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a>q <span class="op">+=</span> <span class="st">b&#39;</span><span class="ch">\x07</span><span class="st">example</span><span class="ch">\x03</span><span class="st">com</span><span class="ch">\x00\x00\x01\x00\x01</span><span class="st">&#39;</span> <span class="co"># question</span></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a>s <span class="op">=</span> socket.socket(socket.AF_INET, socket.SOCK_DGRAM)</span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a>s.sendto(q, (<span class="st">&#39;127.0.0.1&#39;</span>, <span class="dv">53</span>))</span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a>resp <span class="op">=</span> s.recv(<span class="dv">512</span>)</span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a><span class="cf">for</span> i <span class="kw">in</span> <span class="bu">range</span>(<span class="dv">0</span>, <span class="bu">len</span>(resp), <span class="dv">16</span>):</span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> h <span class="op">=</span> <span class="st">&#39; &#39;</span>.join(<span class="ss">f&#39;</span><span class="sc">{</span>b<span class="sc">:02x}</span><span class="ss">&#39;</span> <span class="cf">for</span> b <span class="kw">in</span> resp[i:i<span class="op">+</span><span class="dv">16</span>])</span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> a <span class="op">=</span> <span class="st">&#39;&#39;</span>.join(<span class="bu">chr</span>(b) <span class="cf">if</span> <span class="dv">32</span><span class="op">&lt;=</span>b<span class="op">&lt;</span><span class="dv">127</span> <span class="cf">else</span> <span class="st">&#39;.&#39;</span> <span class="cf">for</span> b <span class="kw">in</span> resp[i:i<span class="op">+</span><span class="dv">16</span>])</span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="bu">print</span>(<span class="ss">f&#39;</span><span class="sc">{</span>i<span class="sc">:08x}</span><span class="ss"> </span><span class="sc">{</span>h<span class="sc">:&lt;48s}</span><span class="ss"> </span><span class="sc">{</span>a<span class="sc">}</span><span class="ss">&#39;</span>)</span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a><span class="co">&quot;</span></span></code></pre></div>
<pre><code>00000000 ab cd 81 80 00 01 00 02 00 00 00 00 07 65 78 61 .............exa
00000010 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 07 65 78 mple.com......ex
00000020 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 ample.com.......
00000030 00 19 00 04 68 12 1b 78 07 65 78 61 6d 70 6c 65 ....h..x.example
00000040 03 63 6f 6d 00 00 01 00 01 00 00 00 19 00 04 68 .com...........h
00000050 12 1a 78 ..x</code></pre>
<p>83 bytes back. Lets annotate the response:</p>
<pre><code> ID Flags QCount ACount NSCount ARCount
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
Header: AB CD 81 80 00 01 00 02 00 00 00 00
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
↑ ↑ ↑ ↑
│ │ │ └─ 2 answers
│ │ └─ 1 question (echoed back)
│ └─ Response flag set, recursion available
└─ Same ID as our query
Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
(same as our query — echoed back)
Answer 1: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
───────────────────────────────────── ── ───── ─────
e x a m p l e . c o m end A IN
00 00 00 19 00 04 68 12 1B 78
─────────── ───── ───────────
TTL: 25s len:4 104.18.27.120
Answer 2: (same domain repeated) 00 01 00 01 00 00 00 19 00 04 68 12 1A 78
───────────
104.18.26.120</code></pre>
<p>Notice something wasteful? The domain <code>example.com</code>
appears <em>three times</em> — once in the question, twice in the
answers. Thats 39 bytes of repeated names in an 83-byte packet. DNS has
a solution for this — but first, the overall structure.</p>
<p>The whole thing fits in a single UDP datagram. The structure is:</p>
<pre><code>+--+--+--+--+--+--+--+--+
| Header | 12 bytes: ID, flags, counts
+--+--+--+--+--+--+--+--+
| Questions | What you&#39;re asking
+--+--+--+--+--+--+--+--+
| Answers | The response records
+--+--+--+--+--+--+--+--+
| Authorities | NS records for the zone
+--+--+--+--+--+--+--+--+
| Additional | Extra helpful records
+--+--+--+--+--+--+--+--+</code></pre>
<p>In Rust, parsing the header is just reading 12 bytes and unpacking
the flags:</p>
<div class="sourceCode" id="cb8"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read(buffer<span class="op">:</span> <span class="op">&amp;</span><span class="kw">mut</span> BytePacketBuffer) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>DnsHeader<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> id <span class="op">=</span> buffer<span class="op">.</span>read_u16()<span class="op">?;</span></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> flags <span class="op">=</span> buffer<span class="op">.</span>read_u16()<span class="op">?;</span></span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// Flags pack 9 fields into 16 bits</span></span>
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> recursion_desired <span class="op">=</span> (flags <span class="op">&amp;</span> (<span class="dv">1</span> <span class="op">&lt;&lt;</span> <span class="dv">8</span>)) <span class="op">&gt;</span> <span class="dv">0</span><span class="op">;</span></span>
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> truncated_message <span class="op">=</span> (flags <span class="op">&amp;</span> (<span class="dv">1</span> <span class="op">&lt;&lt;</span> <span class="dv">9</span>)) <span class="op">&gt;</span> <span class="dv">0</span><span class="op">;</span></span>
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> authoritative_answer <span class="op">=</span> (flags <span class="op">&amp;</span> (<span class="dv">1</span> <span class="op">&lt;&lt;</span> <span class="dv">10</span>)) <span class="op">&gt;</span> <span class="dv">0</span><span class="op">;</span></span>
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> opcode <span class="op">=</span> (flags <span class="op">&gt;&gt;</span> <span class="dv">11</span>) <span class="op">&amp;</span> <span class="dv">0x0F</span><span class="op">;</span></span>
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> response <span class="op">=</span> (flags <span class="op">&amp;</span> (<span class="dv">1</span> <span class="op">&lt;&lt;</span> <span class="dv">15</span>)) <span class="op">&gt;</span> <span class="dv">0</span><span class="op">;</span></span>
<span id="cb8-10"><a href="#cb8-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... and so on</span></span>
<span id="cb8-11"><a href="#cb8-11" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>No padding, no alignment, no JSON overhead. DNS was designed in 1987
when every byte counted, and honestly? The wire format is kind of
beautiful in its efficiency.</p>
<h2 id="label-compression-is-the-clever-part">Label compression is the
clever part</h2>
<p>Remember how <code>example.com</code> appeared three times in that
83-byte response? Domain names in DNS are stored as a sequence of
<strong>labels</strong> — length-prefixed segments:</p>
<pre><code>example.com → [7]example[3]com[0]</code></pre>
<p>The <code>[7]</code> means “the next 7 bytes are a label.” The
<code>[0]</code> is the root label (end of name). Thats 13 bytes per
occurrence, 39 bytes for three repetitions. In a response with authority
and additional records, domain names can account for half the
packet.</p>
<p>DNS solves this with <strong>compression pointers</strong> — if the
top two bits of a length byte are <code>11</code>, the remaining 14 bits
are an offset back into the packet where the rest of the name can be
found. A well-compressed version of our response would replace the
answer names with <code>C0 0C</code> — a 2-byte pointer to offset 12
where <code>example.com</code> first appears in the question section.
That turns 39 bytes of names into 15 (13 + 2 + 2). Our upstream didnt
bother compressing, but many do — especially when related domains
appear:</p>
<pre><code>Offset 0x20: [6]google[3]com[0] ← full name
Offset 0x40: [4]mail[0xC0][0x20] ← &quot;mail&quot; + pointer to offset 0x20
Offset 0x50: [3]www[0xC0][0x20] ← &quot;www&quot; + pointer to offset 0x20</code></pre>
<p>Pointers can chain — a pointer can point to another pointer. Parsing
this correctly requires tracking your position in the buffer and
handling jumps:</p>
<div class="sourceCode" id="cb11"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read_qname(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> outstr<span class="op">:</span> <span class="op">&amp;</span><span class="kw">mut</span> <span class="dt">String</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> pos <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>pos()<span class="op">;</span></span>
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> jumped <span class="op">=</span> <span class="cn">false</span><span class="op">;</span></span>
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> delim <span class="op">=</span> <span class="st">&quot;&quot;</span><span class="op">;</span></span>
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a> <span class="cf">loop</span> <span class="op">{</span></span>
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> len <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get(pos)<span class="op">?;</span></span>
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Top two bits set = compression pointer</span></span>
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (len <span class="op">&amp;</span> <span class="dv">0xC0</span>) <span class="op">==</span> <span class="dv">0xC0</span> <span class="op">{</span></span>
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="op">!</span>jumped <span class="op">{</span></span>
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>seek(pos <span class="op">+</span> <span class="dv">2</span>)<span class="op">?;</span> <span class="co">// advance past the pointer</span></span>
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> offset <span class="op">=</span> (((len <span class="kw">as</span> <span class="dt">u16</span>) <span class="op">^</span> <span class="dv">0xC0</span>) <span class="op">&lt;&lt;</span> <span class="dv">8</span>) <span class="op">|</span> <span class="kw">self</span><span class="op">.</span>get(pos <span class="op">+</span> <span class="dv">1</span>)<span class="op">?</span> <span class="kw">as</span> <span class="dt">u16</span><span class="op">;</span></span>
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a> pos <span class="op">=</span> offset <span class="kw">as</span> <span class="dt">usize</span><span class="op">;</span></span>
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a> jumped <span class="op">=</span> <span class="cn">true</span><span class="op">;</span></span>
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a> <span class="cf">continue</span><span class="op">;</span></span>
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a> pos <span class="op">+=</span> <span class="dv">1</span><span class="op">;</span></span>
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> len <span class="op">==</span> <span class="dv">0</span> <span class="op">{</span> <span class="cf">break</span><span class="op">;</span> <span class="op">}</span> <span class="co">// root label</span></span>
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a> outstr<span class="op">.</span>push_str(delim)<span class="op">;</span></span>
<span id="cb11-24"><a href="#cb11-24" aria-hidden="true" tabindex="-1"></a> outstr<span class="op">.</span>push_str(<span class="op">&amp;</span><span class="kw">self</span><span class="op">.</span>get_range(pos<span class="op">,</span> len <span class="kw">as</span> <span class="dt">usize</span>)<span class="op">?</span></span>
<span id="cb11-25"><a href="#cb11-25" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>iter()<span class="op">.</span>map(<span class="op">|&amp;</span>b<span class="op">|</span> b <span class="kw">as</span> <span class="dt">char</span>)<span class="op">.</span><span class="pp">collect::</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;</span>())<span class="op">;</span></span>
<span id="cb11-26"><a href="#cb11-26" aria-hidden="true" tabindex="-1"></a> delim <span class="op">=</span> <span class="st">&quot;.&quot;</span><span class="op">;</span></span>
<span id="cb11-27"><a href="#cb11-27" aria-hidden="true" tabindex="-1"></a> pos <span class="op">+=</span> len <span class="kw">as</span> <span class="dt">usize</span><span class="op">;</span></span>
<span id="cb11-28"><a href="#cb11-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-29"><a href="#cb11-29" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-30"><a href="#cb11-30" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="op">!</span>jumped <span class="op">{</span></span>
<span id="cb11-31"><a href="#cb11-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>seek(pos)<span class="op">?;</span></span>
<span id="cb11-32"><a href="#cb11-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-33"><a href="#cb11-33" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(())</span>
<span id="cb11-34"><a href="#cb11-34" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>This one bit me: when you follow a pointer, you must <em>not</em>
advance the buffers read position past where you jumped from. The
pointer is 2 bytes, so you advance by 2, but the actual label data lives
elsewhere in the packet. If you follow the pointer and also advance past
it, youll skip over the next record entirely. I spent a fun evening
debugging that one.</p>
<h2 id="ttl-adjustment-on-read-not-write">TTL adjustment on read, not
write</h2>
<p>This is my favorite trick in the whole codebase. I initially stored
the remaining TTL and decremented it, which meant I needed a background
thread to sweep expired entries. It worked, but it felt wrong — too much
machinery for something simple.</p>
<p>The cleaner approach: store the original TTL and the timestamp when
the record was cached. On read, compute
<code>remaining = original_ttl - elapsed</code>. If its zero or
negative, the entry is stale — evict it lazily.</p>
<div class="sourceCode" id="cb12"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> lookup(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> domain<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span> qtype<span class="op">:</span> QueryType) <span class="op">-&gt;</span> <span class="dt">Option</span><span class="op">&lt;</span>DnsPacket<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> key <span class="op">=</span> (domain<span class="op">.</span>to_lowercase()<span class="op">,</span> qtype)<span class="op">;</span></span>
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> entry <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>entries<span class="op">.</span>get(<span class="op">&amp;</span>key)<span class="op">?;</span></span>
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> elapsed <span class="op">=</span> entry<span class="op">.</span>cached_at<span class="op">.</span>elapsed()<span class="op">.</span>as_secs() <span class="kw">as</span> <span class="dt">u32</span><span class="op">;</span></span>
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> elapsed <span class="op">&gt;=</span> entry<span class="op">.</span>original_ttl <span class="op">{</span></span>
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>entries<span class="op">.</span>remove(<span class="op">&amp;</span>key)<span class="op">;</span></span>
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cn">None</span><span class="op">;</span></span>
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-10"><a href="#cb12-10" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-11"><a href="#cb12-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adjust TTLs in the response to reflect remaining time</span></span>
<span id="cb12-12"><a href="#cb12-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> packet <span class="op">=</span> entry<span class="op">.</span>packet<span class="op">.</span>clone()<span class="op">;</span></span>
<span id="cb12-13"><a href="#cb12-13" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> answer <span class="kw">in</span> <span class="op">&amp;</span><span class="kw">mut</span> packet<span class="op">.</span>answers <span class="op">{</span></span>
<span id="cb12-14"><a href="#cb12-14" aria-hidden="true" tabindex="-1"></a> answer<span class="op">.</span>set_ttl(entry<span class="op">.</span>original_ttl<span class="op">.</span>saturating_sub(elapsed))<span class="op">;</span></span>
<span id="cb12-15"><a href="#cb12-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-16"><a href="#cb12-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(packet)</span>
<span id="cb12-17"><a href="#cb12-17" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>No background thread. No timer. Entries expire lazily. The cache
stays consistent because every consumer sees the adjusted TTL.</p>
<h2 id="the-resolution-pipeline">The resolution pipeline</h2>
<p>Each incoming UDP packet spawns a tokio task. Each task walks a
deterministic pipeline — every step either answers or passes to the
next:</p>
<pre><code> ┌─────────────────────────────────────────────────────┐
│ Numa Resolution Pipeline │
└─────────────────────────────────────────────────────┘
Query ──→ Overrides ──→ .numa TLD ──→ Blocklist ──→ Zones ──→ Cache ──→ DoH
│ │ │ │ │ │ │
│ │ match? │ match? │ blocked? │ match? │ hit? │
│ ↓ ↓ ↓ ↓ ↓ ↓
│ respond respond 0.0.0.0 respond respond forward
│ (auto-reverts (reverse (ad gone) (static (TTL to upstream
│ after N min) proxy+TLS) records) adjusted) (encrypted)
└──→ Each step either answers or passes to the next.</code></pre>
<p>This is where “from scratch” pays off. Want conditional forwarding
for Tailscale? Insert a step before the upstream. Want to override
<code>api.example.com</code> for 5 minutes while debugging? Add an entry
in the overrides step — it auto-expires. A DNS library would have hidden
this pipeline behind an opaque <code>resolve()</code> call.</p>
<h2 id="dns-over-https-the-wait-thats-it-moment">DNS-over-HTTPS: the
“wait, thats it?” moment</h2>
<p>The most recent addition, and honestly the one that surprised me with
how little code it needed. DoH (RFC 8484) is conceptually simple: take
the exact same DNS wire-format packet youd send over UDP, POST it to an
HTTPS endpoint with <code>Content-Type: application/dns-message</code>,
and parse the response the same way. Same bytes, different
transport.</p>
<div class="sourceCode" id="cb14"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> forward_doh(</span>
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a> query<span class="op">:</span> <span class="op">&amp;</span>DnsPacket<span class="op">,</span></span>
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> <span class="op">&amp;</span><span class="pp">reqwest::</span>Client<span class="op">,</span></span>
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a> timeout_duration<span class="op">:</span> Duration<span class="op">,</span></span>
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>DnsPacket<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> send_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>new()<span class="op">;</span></span>
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a> query<span class="op">.</span>write(<span class="op">&amp;</span><span class="kw">mut</span> send_buffer)<span class="op">?;</span></span>
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resp <span class="op">=</span> timeout(timeout_duration<span class="op">,</span> client</span>
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>post(url)</span>
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;content-type&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span>
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;accept&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span>
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>body(send_buffer<span class="op">.</span>filled()<span class="op">.</span>to_vec())</span>
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>send())</span>
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">??.</span>error_for_status()<span class="op">?;</span></span>
<span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-18"><a href="#cb14-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> bytes <span class="op">=</span> resp<span class="op">.</span>bytes()<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb14-19"><a href="#cb14-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> recv_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>from_bytes(<span class="op">&amp;</span>bytes)<span class="op">;</span></span>
<span id="cb14-20"><a href="#cb14-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">DnsPacket::</span>from_buffer(<span class="op">&amp;</span><span class="kw">mut</span> recv_buffer)</span>
<span id="cb14-21"><a href="#cb14-21" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>The one gotcha that cost me an hour: Quad9 and other DoH providers
require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad
Request. Adding the <code>http2</code> feature to reqwest fixed it. The
upside of HTTP/2? Connection multiplexing means subsequent queries reuse
the TLS session — ~16ms vs ~50ms for the first query. Free
performance.</p>
<p>The <code>Upstream</code> enum dispatches between UDP and DoH based
on the URL scheme:</p>
<div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> Upstream <span class="op">{</span></span>
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> Udp(SocketAddr)<span class="op">,</span></span>
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> Doh <span class="op">{</span> url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span> client<span class="op">:</span> <span class="pp">reqwest::</span>Client <span class="op">},</span></span>
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>If the configured address starts with <code>https://</code>, its
DoH. Otherwise, plain UDP. Simple, no toggles.</p>
<h2 id="why-not-just-use-dnsmasq-nginx-mkcert">“Why not just use dnsmasq
+ nginx + mkcert?”</h2>
<p>You absolutely can — those are mature, battle-tested tools. The
difference is integration: with dnsmasq + nginx + mkcert, youre
configuring three tools with three config formats. Numa puts the DNS
record, reverse proxy, and TLS cert behind one API call:</p>
<div class="sourceCode" id="cb16"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="ex">curl</span> <span class="at">-X</span> POST localhost:5380/services <span class="at">-d</span> <span class="st">&#39;{&quot;name&quot;:&quot;frontend&quot;,&quot;target_port&quot;:5173}&#39;</span></span></code></pre></div>
<p>That creates the DNS entry, generates a TLS certificate, and starts
proxying — including WebSocket upgrade for Vite HMR. One command, no
config files. Having full control over the resolution pipeline is what
makes auto-revert overrides and LAN discovery possible.</p>
<h2 id="what-i-learned">What I learned</h2>
<p><strong>DNS is a 40-year-old protocol that works remarkably
well.</strong> The wire format is tight, the caching model is elegant,
and the hierarchical delegation system has scaled to billions of queries
per day. The things people complain about (DNSSEC complexity, lack of
encryption) are extensions bolted on decades later, not flaws in the
original design.</p>
<p><strong>The hard parts arent where youd expect.</strong> Parsing
the wire protocol was straightforward (RFC 1035 is well-written). The
hard parts were: browsers rejecting wildcard certs under single-label
TLDs, macOS resolver quirks (<code>scutil</code> vs
<code>/etc/resolv.conf</code>), and getting multiple processes to bind
the same multicast port (<code>SO_REUSEPORT</code> on macOS,
<code>SO_REUSEADDR</code> on Linux).</p>
<p><strong>Learn the vocabulary before you show up.</strong> I initially
called Numa a “DNS resolver” and got corrected — its a forwarding
resolver. The distinction matters to people who work with DNS
professionally, and being sloppy about it cost me credibility in my
first community posts.</p>
<h2 id="whats-next">Whats next</h2>
<p><strong>Update (March 2026):</strong> Recursive resolution and DNSSEC
validation are now shipped. Numa resolves from root nameservers with
full chain-of-trust verification (RSA/SHA-256, ECDSA P-256, Ed25519) and
NSEC/NSEC3 authenticated denial of existence.</p>
<p><strong><a href="/blog/dnssec-from-scratch.html">Read the follow-up:
Implementing DNSSEC from Scratch in Rust →</a></strong></p>
<p>Still on the roadmap:</p>
<ul>
<li><strong>DoT (DNS-over-TLS)</strong> — DoH was first because it
passes through captive portals and corporate firewalls (port 443 vs
853). DoT has less framing overhead, so its faster. Both will be
available.</li>
<li><strong><a href="https://github.com/pubky/pkarr">pkarr</a>
integration</strong> — self-sovereign DNS via the Mainline BitTorrent
DHT. Publish DNS records signed with your Ed25519 key, no registrar
needed.</li>
</ul>
<p><a
href="https://github.com/razvandimescu/numa">github.com/razvandimescu/numa</a></p>
</article>
<footer class="blog-footer">
<a href="https://github.com/razvandimescu/numa">GitHub</a>
<a href="/">Home</a>
<a href="/blog/">Blog</a>
</footer>
</body>
</html>