feat: self-host fonts, styled block page, wildcard TLS (#16)
* perf: optimize hot path — RwLock, inline filtering, pre-allocated strings - Mutex → RwLock for cache, blocklist, and overrides (concurrent read access) - Make cache.lookup() and overrides.lookup() take &self (read-only) - Eliminate 3 Vec allocations per DnsPacket::write() via inline filtering - Pre-allocate domain strings with capacity 64 in parse path - Add criterion micro-benchmarks (hot_path + throughput) - Add bench README documenting both benchmark suites Measured improvement: ~14% faster parsing, ~9% pipeline throughput, round-trip cached 733ns → 698ns (~2.3M queries/sec). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: simplify benchmark code after review - Remove redundant DnsHeader::new() (already set by DnsPacket::new()) - Remove unused DnsHeader import - Change simulate_cached_pipeline to take &DnsCache (lookup is &self now) - Remove unnecessary mut on cache in cache_lookup_miss bench Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * site: landing page overhaul, blog, benchmarks, numa.rs domain Landing page: - Split features into 3-layer card layout (Block & Protect, Developer Tools, Self-Sovereign DNS) - Add DoH and conditional forwarding to comparison table - Fix performance claim (2.3M → 2.0M qps to match benchmarks) - Add all 3 install methods (brew, cargo, curl) - Add OG tags + canonical URL for numa.rs - Fix code block whitespace rendering - Update roadmap with .onion bridge phase Blog: - Add "Building a DNS Resolver from Scratch in Rust" post - Blog index + template for future posts Other: - CNAME for GitHub Pages (numa.rs) - Benchmark results (bench/results.json) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: self-host fonts, styled block page, wildcard TLS Fonts: - Replace Google Fonts CDN with self-hosted woff2 (73KB, 5 files) - Serve fonts from API server via include_bytes! (dashboard works offline) - Proxy error pages use system fonts (zero external deps when DNS is broken) - Fix Instrument Serif font-weight: use 400 (only available weight) instead of synthetic bold 600/700 Proxy: - Styled "Blocked by Numa" page when blocked domain hits the proxy (was confusing "not a .numa domain" error) - Extract shared error_page() template for 403 + 404 pages (deduplicate ~160 lines of CSS) TLS: - Add wildcard SAN *.numa to cert — unregistered .numa domains get valid HTTPS (styled 404 without cert warning) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #16.
This commit is contained in:
651
site/blog/dns-from-scratch.html
Normal file
651
site/blog/dns-from-scratch.html
Normal file
@@ -0,0 +1,651 @@
|
||||
<!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 before we go further: Numa is currently a
|
||||
<em>forwarding</em> resolver — it parses and caches DNS packets, but
|
||||
forwards queries to an upstream (Quad9, Cloudflare, or any DoH provider)
|
||||
rather than walking the delegation chain from root servers itself. Think
|
||||
of it as a smart proxy that does useful things with your DNS traffic
|
||||
locally (caching, ad blocking, overrides, local service domains) before
|
||||
forwarding what it can’t answer. Full recursive resolution — where Numa
|
||||
talks directly to root and authoritative nameservers — is on the
|
||||
roadmap, along with DNSSEC validation.</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>Numa is at v0.5.0 with DNS forwarding, caching, ad blocking,
|
||||
DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service
|
||||
discovery.</p>
|
||||
<p>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>Recursive resolution</strong> — walk the delegation chain
|
||||
from root servers instead of forwarding. Combined with DNSSEC
|
||||
validation, this removes the need to trust any upstream resolver.</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>But those are rabbit holes for future posts.</p>
|
||||
<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>
|
||||
Reference in New Issue
Block a user