chore: move generated blog HTML to site/blog/posts/, gitignore
- Generated HTML now in site/blog/posts/ (gitignored) - CI workflow runs pandoc + make blog before deploy - Updated all internal blog links to /blog/posts/ path - blog/*.md remains the source of truth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,651 +0,0 @@
|
||||
<!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
|
||||
can’t 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>Here’s 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>;; ->>HEADER<<- 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>That’s the human-readable version. But what’s 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'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 “what’s
|
||||
the IP for example.com?” Compare that to an HTTP request for the same
|
||||
information — you’d 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">"</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'</span><span class="ch">\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00</span><span class="st">'</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'</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">'</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">'127.0.0.1'</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">' '</span>.join(<span class="ss">f'</span><span class="sc">{</span>b<span class="sc">:02x}</span><span class="ss">'</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">''</span>.join(<span class="bu">chr</span>(b) <span class="cf">if</span> <span class="dv">32</span><span class="op"><=</span>b<span class="op"><</span><span class="dv">127</span> <span class="cf">else</span> <span class="st">'.'</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'</span><span class="sc">{</span>i<span class="sc">:08x}</span><span class="ss"> </span><span class="sc">{</span>h<span class="sc">:<48s}</span><span class="ss"> </span><span class="sc">{</span>a<span class="sc">}</span><span class="ss">'</span>)</span>
|
||||
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a><span class="co">"</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. Let’s 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. That’s 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'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">&</span><span class="kw">mut</span> BytePacketBuffer) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>DnsHeader<span class="op">></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">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">8</span>)) <span class="op">></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">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">9</span>)) <span class="op">></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">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">10</span>)) <span class="op">></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">>></span> <span class="dv">11</span>) <span class="op">&</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">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">15</span>)) <span class="op">></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). That’s 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 didn’t
|
||||
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] ← "mail" + pointer to offset 0x20
|
||||
Offset 0x50: [3]www[0xC0][0x20] ← "www" + 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">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> outstr<span class="op">:</span> <span class="op">&</span><span class="kw">mut</span> <span class="dt">String</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">""</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">&</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"><<</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">&</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">|&</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"><</span><span class="dt">String</span><span class="op">></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">"."</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 buffer’s 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, you’ll 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 it’s 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">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> domain<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span> qtype<span class="op">:</span> QueryType) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>DnsPacket<span class="op">></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">&</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">>=</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">&</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">&</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, that’s 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 you’d 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">&</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">&</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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span>DnsPacket<span class="op">></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">&</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">"content-type"</span><span class="op">,</span> <span class="st">"application/dns-message"</span>)</span>
|
||||
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">"accept"</span><span class="op">,</span> <span class="st">"application/dns-message"</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">&</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">&</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>, it’s
|
||||
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, you’re
|
||||
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">'{"name":"frontend","target_port":5173}'</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 aren’t where you’d 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 — it’s 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">What’s 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 it’s 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>
|
||||
@@ -1,646 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Implementing DNSSEC from Scratch in Rust — Numa</title>
|
||||
<meta name="description" content="Recursive resolution from root hints,
|
||||
chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned
|
||||
implementing DNSSEC 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>Implementing DNSSEC from Scratch in Rust</h1>
|
||||
<div class="article-meta">
|
||||
March 2026 · <a href="https://dimescu.ro">Razvan Dimescu</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>In the <a href="/blog/dns-from-scratch.html">previous post</a> I
|
||||
covered how DNS works at the wire level — packet format, label
|
||||
compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed
|
||||
packets, did useful things locally, and relayed the rest to Cloudflare
|
||||
or Quad9.</p>
|
||||
<p>That post ended with “recursive resolution and DNSSEC are on the
|
||||
roadmap.” This post is about building both.</p>
|
||||
<p>The short version: Numa now resolves from root nameservers with
|
||||
iterative queries, validates the full DNSSEC chain of trust, and
|
||||
cryptographically proves that non-existent domains don’t exist. No
|
||||
upstream dependency. No DNS libraries. Just <code>ring</code> for the
|
||||
crypto primitives and a lot of RFC reading.</p>
|
||||
<h2 id="why-recursive">Why recursive?</h2>
|
||||
<p>A forwarding resolver trusts its upstream. When you ask Quad9 for
|
||||
<code>cloudflare.com</code>, you trust that Quad9 returns the real
|
||||
answer. If Quad9 lies, gets compromised, or is legally compelled to
|
||||
redirect you — you have no way to know.</p>
|
||||
<p>A recursive resolver doesn’t trust anyone. It starts at the root
|
||||
nameservers (operated by 12 independent organizations) and follows the
|
||||
delegation chain: root → <code>.com</code> TLD →
|
||||
<code>cloudflare.com</code> authoritative servers. Each server only
|
||||
answers for its own zone. No single entity sees your full query
|
||||
pattern.</p>
|
||||
<p>DNSSEC adds cryptographic proof to each step. The root signs
|
||||
<code>.com</code>’s key. <code>.com</code> signs
|
||||
<code>cloudflare.com</code>’s key. <code>cloudflare.com</code> signs its
|
||||
own records. If any step is tampered with, the chain breaks and Numa
|
||||
rejects the response.</p>
|
||||
<h2 id="the-iterative-resolution-loop">The iterative resolution
|
||||
loop</h2>
|
||||
<p>Recursive resolution is a misnomer — the resolver actually uses
|
||||
<em>iterative</em> queries. It asks root “where is
|
||||
<code>cloudflare.com</code>?”, root says “I don’t know, but here are the
|
||||
<code>.com</code> nameservers.” It asks <code>.com</code>, which says
|
||||
“here are cloudflare’s nameservers.” It asks those, and gets the
|
||||
answer.</p>
|
||||
<pre><code>resolve("cloudflare.com", A)
|
||||
→ ask 198.41.0.4 (a.root-servers.net)
|
||||
← "try .com: ns1.gtld-servers.net (192.5.6.30)" [referral + glue]
|
||||
→ ask 192.5.6.30 (ns1.gtld-servers.net)
|
||||
← "try cloudflare: ns1.cloudflare.com (173.245.58.51)" [referral + glue]
|
||||
→ ask 173.245.58.51 (ns1.cloudflare.com)
|
||||
← "104.16.132.229" [answer]</code></pre>
|
||||
<p>The implementation (<code>src/recursive.rs</code>) is a loop with
|
||||
three possible outcomes per query:</p>
|
||||
<ol type="1">
|
||||
<li><strong>Answer</strong> — the server knows the record. Cache it,
|
||||
return it.</li>
|
||||
<li><strong>Referral</strong> — the server delegates to another zone.
|
||||
Extract NS records and glue (A/AAAA records for the nameservers,
|
||||
included in the additional section to avoid a chicken-and-egg problem),
|
||||
then query the next server.</li>
|
||||
<li><strong>NXDOMAIN/REFUSED</strong> — the name doesn’t exist or the
|
||||
server refuses. Cache the negative result.</li>
|
||||
</ol>
|
||||
<p>CNAME chasing adds complexity: if you ask for
|
||||
<code>www.cloudflare.com</code> and get a CNAME to
|
||||
<code>cloudflare.com</code>, you need to restart resolution for the new
|
||||
name. I cap this at 8 levels.</p>
|
||||
<h3 id="tld-priming">TLD priming</h3>
|
||||
<p>Cold-cache resolution is slow. Every query needs root → TLD →
|
||||
authoritative, each with its own network round-trip. For the first query
|
||||
to <code>example.com</code>, that’s three serial UDP round-trips before
|
||||
you get an answer.</p>
|
||||
<p>TLD priming solves this. On startup, Numa queries root for NS records
|
||||
of 34 common TLDs (<code>.com</code>, <code>.org</code>,
|
||||
<code>.net</code>, <code>.io</code>, <code>.dev</code>, plus EU ccTLDs),
|
||||
caching NS records, glue addresses, DS records, and DNSKEY records.
|
||||
After priming, the first query to any <code>.com</code> domain skips
|
||||
root entirely — it already knows where <code>.com</code>’s nameservers
|
||||
are, and already has the DNSSEC keys needed to validate the
|
||||
response.</p>
|
||||
<h2 id="dnssec-chain-of-trust">DNSSEC chain of trust</h2>
|
||||
<p>DNSSEC doesn’t encrypt DNS traffic. It <em>signs</em> it. Every DNS
|
||||
record can have an accompanying RRSIG (signature) record. The resolver
|
||||
verifies the signature against the zone’s DNSKEY, then verifies that
|
||||
DNSKEY against the parent zone’s DS (delegation signer) record, walking
|
||||
up until it reaches the root trust anchor — a hardcoded public key that
|
||||
IANA publishes and the entire internet agrees on.</p>
|
||||
<pre><code>cloudflare.com A 104.16.132.229
|
||||
signed by → RRSIG (key_tag=34505, algo=13, signer=cloudflare.com)
|
||||
verified with → DNSKEY (cloudflare.com, key_tag=34505, ECDSA P-256)
|
||||
vouched for by → DS (at .com, key_tag=2371, digest=SHA-256 of cloudflare's DNSKEY)
|
||||
signed by → RRSIG (key_tag=19718, signer=com)
|
||||
verified with → DNSKEY (com, key_tag=19718)
|
||||
vouched for by → DS (at root, key_tag=30909)
|
||||
signed by → RRSIG (signer=.)
|
||||
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)</code></pre>
|
||||
<h3 id="how-keys-get-there">How keys get there</h3>
|
||||
<p>The domain owner generates the DNSKEY keypair — typically their DNS
|
||||
provider (Cloudflare, etc.) does this. The owner then submits the DS
|
||||
record (a hash of their DNSKEY) to their registrar (Namecheap, GoDaddy),
|
||||
who passes it to the registry (Verisign for <code>.com</code>). The
|
||||
registry signs it into the TLD zone, and IANA signs the TLD’s DS into
|
||||
the root. Trust flows up; keys flow down.</p>
|
||||
<p>The irony: you “own” your DNSSEC keys, but your registrar controls
|
||||
whether the DS record gets published. If they remove it — by mistake, by
|
||||
policy, or by court order — your DNSSEC chain breaks silently.</p>
|
||||
<h3 id="the-trust-anchor">The trust anchor</h3>
|
||||
<p>IANA’s root KSK (Key Signing Key) has key tag 20326, algorithm 8
|
||||
(RSA/SHA-256), and a 256-byte public key. It was last rolled in 2018. I
|
||||
hardcode it as a <code>const</code> array — this is the one thing in the
|
||||
entire system that requires out-of-band trust.</p>
|
||||
<div class="sourceCode" id="cb3"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> ROOT_KSK_PUBLIC_KEY<span class="op">:</span> <span class="op">&</span>[<span class="dt">u8</span>] <span class="op">=</span> <span class="op">&</span>[</span>
|
||||
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> <span class="dv">0x03</span><span class="op">,</span> <span class="dv">0x01</span><span class="op">,</span> <span class="dv">0x00</span><span class="op">,</span> <span class="dv">0x01</span><span class="op">,</span> <span class="dv">0xac</span><span class="op">,</span> <span class="dv">0xff</span><span class="op">,</span> <span class="dv">0xb4</span><span class="op">,</span> <span class="dv">0x09</span><span class="op">,</span></span>
|
||||
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... 256 bytes total</span></span>
|
||||
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a>]<span class="op">;</span></span></code></pre></div>
|
||||
<p>When IANA rolls this key (rare — the previous key lasted from 2010 to
|
||||
2018), every DNSSEC validator on the internet needs updating. For Numa,
|
||||
that means a binary update. Something to watch.</p>
|
||||
<p>Every DNSKEY has a key tag — a 16-bit checksum over its RDATA (RFC
|
||||
4034 Appendix B). The first test I wrote: compute the root KSK’s key tag
|
||||
and assert it equals 20326. Instant confidence that the RDATA encoding
|
||||
is correct.</p>
|
||||
<h2 id="the-crypto">The crypto</h2>
|
||||
<p>Numa uses <code>ring</code> for all cryptographic operations. Three
|
||||
algorithms cover the vast majority of signed zones:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Algorithm</th>
|
||||
<th>ID</th>
|
||||
<th>Usage</th>
|
||||
<th>Verify time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>RSA/SHA-256</td>
|
||||
<td>8</td>
|
||||
<td>Root, most TLDs</td>
|
||||
<td>10.9 µs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ECDSA P-256</td>
|
||||
<td>13</td>
|
||||
<td>Cloudflare, many modern zones</td>
|
||||
<td>174 ns</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ed25519</td>
|
||||
<td>15</td>
|
||||
<td>Newer zones</td>
|
||||
<td>~200 ns</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="rsa-key-format-conversion">RSA key format conversion</h3>
|
||||
<p>DNS stores RSA public keys in RFC 3110 format (exponent length,
|
||||
exponent, modulus). <code>ring</code> expects PKCS#1 DER (ASN.1
|
||||
encoded). Converting between them means writing a minimal ASN.1 encoder
|
||||
with leading-zero stripping and sign-bit padding. Getting this wrong
|
||||
produces keys that <code>ring</code> silently rejects — one of the
|
||||
harder bugs to track down.</p>
|
||||
<h3 id="ecdsa-is-simpler">ECDSA is simpler</h3>
|
||||
<p>ECDSA P-256 keys in DNS are 64 bytes (x + y coordinates).
|
||||
<code>ring</code> expects uncompressed point format: <code>0x04</code>
|
||||
prefix + 64 bytes. One line:</p>
|
||||
<div class="sourceCode" id="cb4"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> <span class="kw">mut</span> uncompressed <span class="op">=</span> <span class="dt">Vec</span><span class="pp">::</span>with_capacity(<span class="dv">65</span>)<span class="op">;</span></span>
|
||||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>uncompressed<span class="op">.</span>push(<span class="dv">0x04</span>)<span class="op">;</span></span>
|
||||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a>uncompressed<span class="op">.</span>extend_from_slice(public_key)<span class="op">;</span> <span class="co">// 64 bytes from DNS</span></span></code></pre></div>
|
||||
<p>Signatures are also 64 bytes (r + s), used directly. No format
|
||||
conversion needed.</p>
|
||||
<h3 id="building-the-signed-data">Building the signed data</h3>
|
||||
<p>RRSIG verification doesn’t sign the DNS packet — it signs a canonical
|
||||
form of the records. Building this correctly is the most
|
||||
detail-sensitive part of DNSSEC. The signed data is:</p>
|
||||
<ol type="1">
|
||||
<li>RRSIG RDATA fields (type covered, algorithm, labels, original TTL,
|
||||
expiration, inception, key tag, signer name) — <em>without</em> the
|
||||
signature itself</li>
|
||||
<li>For each record in the RRset: owner name (lowercased, uncompressed)
|
||||
+ type + class + original TTL (from the RRSIG, not the record’s current
|
||||
TTL) + RDATA length + canonical RDATA</li>
|
||||
</ol>
|
||||
<p>The records must be sorted by their canonical wire-format
|
||||
representation. Owner names must be lowercased. The TTL must be the
|
||||
<em>original</em> TTL from the RRSIG, not the decremented TTL from
|
||||
caching.</p>
|
||||
<p>Getting any of these details wrong — wrong TTL, wrong case, wrong
|
||||
sort order, wrong RDATA encoding — produces a valid-looking but
|
||||
incorrect signed data blob, and <code>ring</code> returns a signature
|
||||
mismatch with no diagnostic information. I spent more time debugging
|
||||
signed data construction than any other part of DNSSEC.</p>
|
||||
<h2 id="proving-a-name-doesnt-exist">Proving a name doesn’t exist</h2>
|
||||
<p>Verifying that <code>cloudflare.com</code> has a valid A record is
|
||||
one thing. Proving that <code>doesnotexist.cloudflare.com</code>
|
||||
<em>doesn’t</em> exist — cryptographically, in a way that can’t be
|
||||
forged — is harder.</p>
|
||||
<h3 id="nsec">NSEC</h3>
|
||||
<p>NSEC records form a chain. Each NSEC says “the next name in this zone
|
||||
after me is X, and at my name these record types exist.” If you query
|
||||
<code>beta.example.com</code> and the zone has
|
||||
<code>alpha.example.com → NSEC → gamma.example.com</code>, the gap
|
||||
proves <code>beta</code> doesn’t exist — there’s nothing between
|
||||
<code>alpha</code> and <code>gamma</code>.</p>
|
||||
<p>For NXDOMAIN proofs, RFC 4035 §5.4 requires two things: 1. An NSEC
|
||||
record whose gap covers the queried name 2. An NSEC record proving no
|
||||
wildcard exists at the closest encloser</p>
|
||||
<p>The canonical DNS name ordering (RFC 4034 §6.1) compares labels
|
||||
right-to-left, case-insensitive. <code>a.example.com</code> <
|
||||
<code>b.example.com</code> because at the <code>example.com</code> level
|
||||
they’re equal, then <code>a</code> < <code>b</code>. But
|
||||
<code>z.example.com</code> < <code>a.example.org</code> because
|
||||
<code>.com</code> < <code>.org</code> at the TLD level.</p>
|
||||
<h3 id="nsec3">NSEC3</h3>
|
||||
<p>NSEC3 solves NSEC’s zone enumeration problem — with NSEC, you can
|
||||
walk the chain and discover every name in the zone. NSEC3 hashes the
|
||||
names first (iterated SHA-1 with a salt), so the NSEC3 chain reveals
|
||||
hashes, not names.</p>
|
||||
<p>The proof is a 3-part closest encloser proof (RFC 5155 §8.4): find an
|
||||
ancestor whose hash matches an NSEC3 owner, prove the next-closer name
|
||||
falls within a hash range gap, and prove the wildcard at the closest
|
||||
encloser also falls within a gap. All three must hold, or the denial is
|
||||
rejected.</p>
|
||||
<p>I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher
|
||||
iteration counts are a DoS vector — each verification requires
|
||||
<code>iterations + 1</code> SHA-1 hashes.</p>
|
||||
<h2 id="making-it-fast">Making it fast</h2>
|
||||
<p>Cold-cache DNSSEC validation initially required ~5 network fetches
|
||||
per query (DNSKEY for each zone in the chain, plus DS records). Three
|
||||
optimizations brought this down to ~1:</p>
|
||||
<p><strong>TLD priming</strong> (startup) — fetch root DNSKEY + each
|
||||
TLD’s NS/DS/DNSKEY. After priming, the trust chain from root to any
|
||||
<code>.com</code> zone is fully cached.</p>
|
||||
<p><strong>Referral DS piggybacking</strong> — when a TLD server refers
|
||||
you to <code>cloudflare.com</code>’s nameservers, the authority section
|
||||
often includes DS records for the child zone. Cache them during
|
||||
resolution instead of fetching separately during validation.</p>
|
||||
<p><strong>DNSKEY prefetch</strong> — before the validation loop, scan
|
||||
all RRSIGs for signer zones and batch-fetch any missing DNSKEYs. This
|
||||
avoids serial DNSKEY fetches inside the per-RRset verification loop.</p>
|
||||
<p>Result: a cold-cache query for <code>cloudflare.com</code> with full
|
||||
DNSSEC validation takes ~90ms. The TLD chain is already warm; only one
|
||||
DNSKEY fetch is needed (for <code>cloudflare.com</code> itself).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Operation</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>ECDSA P-256 verify</td>
|
||||
<td>174 ns</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ed25519 verify</td>
|
||||
<td>~200 ns</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RSA/SHA-256 verify</td>
|
||||
<td>10.9 µs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DS digest (SHA-256)</td>
|
||||
<td>257 ns</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Key tag computation</td>
|
||||
<td>20–63 ns</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cold-cache validation (1 fetch)</td>
|
||||
<td>~90 ms</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>The network fetch dominates. The crypto is noise.</p>
|
||||
<h2 id="surviving-hostile-networks">Surviving hostile networks</h2>
|
||||
<p>I deployed Numa as my system DNS and switched to a different network.
|
||||
Everything broke. Every query: SERVFAIL, 3-second timeout.</p>
|
||||
<p>The network probe told the story: the ISP blocks outbound UDP port 53
|
||||
to all servers except a handful of whitelisted public resolvers (Google,
|
||||
Cloudflare). Root servers, TLD servers, authoritative servers — all
|
||||
unreachable over UDP. The ISP forces you onto their DNS or a blessed
|
||||
upstream. Recursive resolution is impossible.</p>
|
||||
<p>Except TCP port 53 worked fine. And every DNS server is required to
|
||||
support TCP (RFC 1035 section 4.2.2). The ISP apparently only filters
|
||||
UDP.</p>
|
||||
<p>The fix has three parts:</p>
|
||||
<p><strong>TCP fallback.</strong> Every outbound query tries UDP first
|
||||
(800ms timeout). If UDP fails or the response is truncated, retry
|
||||
immediately over TCP. TCP uses a 2-byte length prefix before the DNS
|
||||
message — trivial to implement, and it handles DNSSEC responses that
|
||||
exceed the UDP payload limit.</p>
|
||||
<p><strong>UDP auto-disable.</strong> After 3 consecutive UDP failures,
|
||||
flip a global <code>AtomicBool</code> and skip UDP entirely — go
|
||||
TCP-first for all queries. This avoids burning 800ms per hop on a
|
||||
network where UDP will never work. The flag resets when the network
|
||||
changes (detected via LAN IP monitoring).</p>
|
||||
<p><strong>Query minimization (RFC 7816).</strong> When querying root
|
||||
servers, send only the TLD — <code>com</code> instead of
|
||||
<code>secret-project.example.com</code>. Root servers handle trillions
|
||||
of queries and are operated by 12 organizations. Minimization reduces
|
||||
what they learn from yours.</p>
|
||||
<p>The result: on a network that blocks UDP:53, Numa detects the block
|
||||
within the first 3 queries, switches to TCP, and resolves normally at
|
||||
300-500ms per cold query. Cached queries remain 0ms. No manual config
|
||||
change needed — switch networks and it adapts.</p>
|
||||
<p>I wouldn’t have found this without dogfooding. The code worked
|
||||
perfectly on my home network. It took a real hostile network to expose
|
||||
the assumption that UDP always works.</p>
|
||||
<h2 id="what-i-learned">What I learned</h2>
|
||||
<p><strong>DNSSEC is a verification system, not an encryption
|
||||
system.</strong> It proves authenticity — this record was signed by the
|
||||
zone owner. It doesn’t hide what you’re querying. For privacy, you still
|
||||
need encrypted transport (DoH/DoT) or recursive resolution (no single
|
||||
upstream).</p>
|
||||
<p><strong>The hardest bugs are in data serialization, not
|
||||
crypto.</strong> <code>ring</code> either verifies or it doesn’t — a
|
||||
binary answer. But getting the signed data blob exactly right (correct
|
||||
TTL, correct case, correct sort, correct RDATA encoding for each record
|
||||
type) requires extreme precision. A single wrong byte means verification
|
||||
fails with no hint about what’s wrong.</p>
|
||||
<p><strong>Negative proofs are harder than positive proofs.</strong>
|
||||
Verifying a record exists: verify one RRSIG. Proving a record doesn’t
|
||||
exist: find the right NSEC/NSEC3 records, verify their RRSIGs, check gap
|
||||
coverage, check wildcard denial, compute hashes. The NSEC3 closest
|
||||
encloser proof alone has three sub-proofs, each requiring hash
|
||||
computation and range checking.</p>
|
||||
<p><strong>Performance optimization is about avoiding network, not
|
||||
avoiding CPU.</strong> The crypto takes nanoseconds to microseconds. The
|
||||
network fetch takes tens of milliseconds. Every optimization that
|
||||
matters — TLD priming, DS piggybacking, DNSKEY prefetch — is about
|
||||
eliminating a round trip, not speeding up a hash.</p>
|
||||
<h2 id="whats-next">What’s next</h2>
|
||||
<ul>
|
||||
<li><strong><a href="https://github.com/pubky/pkarr">pkarr</a>
|
||||
integration</strong> — self-sovereign DNS via the Mainline BitTorrent
|
||||
DHT. Your Ed25519 key is your domain. No registrar, no ICANN.</li>
|
||||
<li><strong>DoT (DNS-over-TLS)</strong> — the last encrypted transport
|
||||
we don’t support</li>
|
||||
</ul>
|
||||
<p>The code is at <a
|
||||
href="https://github.com/razvandimescu/numa">github.com/razvandimescu/numa</a>
|
||||
— the DNSSEC validation is in <a
|
||||
href="https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs"><code>src/dnssec.rs</code></a>
|
||||
and the recursive resolver in <a
|
||||
href="https://github.com/razvandimescu/numa/blob/main/src/recursive.rs"><code>src/recursive.rs</code></a>.
|
||||
MIT license.</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>
|
||||
@@ -168,14 +168,14 @@ body::before {
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
<li>
|
||||
<a href="/blog/dnssec-from-scratch.html">
|
||||
<a href="/blog/posts/dnssec-from-scratch.html">
|
||||
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
||||
<div class="post-desc">Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/blog/dns-from-scratch.html">
|
||||
<a href="/blog/posts/dns-from-scratch.html">
|
||||
<div class="post-title">I Built a DNS Resolver from Scratch in Rust</div>
|
||||
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
|
||||
Reference in New Issue
Block a user