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:
4
.github/workflows/static.yml
vendored
4
.github/workflows/static.yml
vendored
@@ -31,6 +31,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Install pandoc
|
||||||
|
run: sudo apt-get install -y pandoc
|
||||||
|
- name: Generate blog HTML
|
||||||
|
run: make blog
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v5
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs/
|
docs/
|
||||||
|
site/blog/posts/
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -23,11 +23,11 @@ bench:
|
|||||||
cargo bench
|
cargo bench
|
||||||
|
|
||||||
blog:
|
blog:
|
||||||
@mkdir -p site/blog
|
@mkdir -p site/blog/posts
|
||||||
@for f in blog/*.md; do \
|
@for f in blog/*.md; do \
|
||||||
name=$$(basename "$$f" .md); \
|
name=$$(basename "$$f" .md); \
|
||||||
pandoc "$$f" --template=site/blog-template.html -o "site/blog/$$name.html"; \
|
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
|
||||||
echo " $$f → site/blog/$$name.html"; \
|
echo " $$f → site/blog/posts/$$name.html"; \
|
||||||
done
|
done
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ I wanted to understand how DNS actually works. Not the "it translates domain nam
|
|||||||
|
|
||||||
So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. 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 [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS.
|
So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. 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 [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS.
|
||||||
|
|
||||||
A note on terminology: Numa supports two resolution modes. *Forward* mode relays queries to an upstream (Quad9, Cloudflare, or any DoH provider). *Recursive* 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; [the next post](/blog/dnssec-from-scratch.html) covers recursive resolution and DNSSEC.
|
A note on terminology: Numa supports two resolution modes. *Forward* mode relays queries to an upstream (Quad9, Cloudflare, or any DoH provider). *Recursive* 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; [the next post](/blog/posts/dnssec-from-scratch.html) covers recursive resolution and DNSSEC.
|
||||||
|
|
||||||
Here's what surprised me along the way.
|
Here's what surprised me along the way.
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ That creates the DNS entry, generates a TLS certificate, and starts proxying —
|
|||||||
|
|
||||||
**Update (March 2026):** 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.
|
**Update (March 2026):** 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.
|
||||||
|
|
||||||
**[Read the follow-up: Implementing DNSSEC from Scratch in Rust →](/blog/dnssec-from-scratch.html)**
|
**[Read the follow-up: Implementing DNSSEC from Scratch in Rust →](/blog/posts/dnssec-from-scratch.html)**
|
||||||
|
|
||||||
Still on the roadmap:
|
Still on the roadmap:
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description: Recursive resolution from root hints, chain-of-trust validation, NS
|
|||||||
date: March 2026
|
date: March 2026
|
||||||
---
|
---
|
||||||
|
|
||||||
In the [previous post](/blog/dns-from-scratch.html) 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.
|
In the [previous post](/blog/posts/dns-from-scratch.html) 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.
|
||||||
|
|
||||||
That post ended with "recursive resolution and DNSSEC are on the roadmap." This post is about building both.
|
That post ended with "recursive resolution and DNSSEC are on the roadmap." This post is about building both.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<h1>Blog</h1>
|
||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
<li>
|
<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-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-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>
|
<div class="post-date">March 2026</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<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-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-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>
|
<div class="post-date">March 2026</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user