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 is contained in:
1
site/CNAME
Normal file
1
site/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
numa.rs
|
||||
301
site/blog-template.html
Normal file
301
site/blog-template.html
Normal file
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$title$ — Numa</title>
|
||||
<meta name="description" content="$description$">
|
||||
<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>$title$</h1>
|
||||
<div class="article-meta">
|
||||
$date$ · <a href="https://dimescu.ro">Razvan Dimescu</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
$body$
|
||||
</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>
|
||||
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>
|
||||
186
site/blog/index.html
Normal file
186
site/blog/index.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Blog — Numa</title>
|
||||
<meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch.">
|
||||
<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-card: #faf7f2;
|
||||
--amber: #c0623a;
|
||||
--amber-dim: #9e4e2d;
|
||||
--teal: #6b7c4e;
|
||||
--text-primary: #2c2418;
|
||||
--text-secondary: #6b5e4f;
|
||||
--text-dim: #a39888;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--font-display: 'Instrument Serif', Georgia, serif;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.blog-index {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 6rem;
|
||||
}
|
||||
|
||||
.blog-index h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post-list li {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-list li:first-child {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-list a {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post-list .post-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.4rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-list a:hover .post-title {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.post-list .post-desc {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.post-list .post-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="blog-nav">
|
||||
<a href="/" class="wordmark">Numa</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="/blog/">Blog</a>
|
||||
</nav>
|
||||
|
||||
<main class="blog-index">
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
<li>
|
||||
<a href="/blog/dns-from-scratch.html">
|
||||
<div class="post-title">I Built a DNS Resolver from Scratch in Rust</div>
|
||||
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<footer class="blog-footer">
|
||||
<a href="https://github.com/razvandimescu/numa">GitHub</a>
|
||||
<a href="/">Home</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,9 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Numa — Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
|
||||
BIN
site/fonts/dm-sans-italic-latin.woff2
Normal file
BIN
site/fonts/dm-sans-italic-latin.woff2
Normal file
Binary file not shown.
BIN
site/fonts/dm-sans-latin.woff2
Normal file
BIN
site/fonts/dm-sans-latin.woff2
Normal file
Binary file not shown.
36
site/fonts/fonts.css
Normal file
36
site/fonts/fonts.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* Self-hosted fonts — no external requests to Google */
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/instrument-serif-latin.woff2) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/instrument-serif-italic-latin.woff2) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/dm-sans-latin.woff2) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/dm-sans-italic-latin.woff2) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url(/fonts/jetbrains-mono-latin.woff2) format('woff2');
|
||||
}
|
||||
BIN
site/fonts/instrument-serif-italic-latin.woff2
Normal file
BIN
site/fonts/instrument-serif-italic-latin.woff2
Normal file
Binary file not shown.
BIN
site/fonts/instrument-serif-latin.woff2
Normal file
BIN
site/fonts/instrument-serif-latin.woff2
Normal file
Binary file not shown.
BIN
site/fonts/jetbrains-mono-latin.woff2
Normal file
BIN
site/fonts/jetbrains-mono-latin.woff2
Normal file
Binary file not shown.
477
site/index.html
477
site/index.html
@@ -3,11 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Numa — DNS that governs itself</title>
|
||||
<title>Numa — DNS you own. Everywhere you go.</title>
|
||||
<meta name="description" content="DNS you own. Block ads, override DNS for development, name your local services with .numa domains, cache for speed. A single portable binary built from scratch in Rust.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://numa.rs">
|
||||
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
|
||||
<meta property="og:description" content="Portable DNS resolver with ad blocking, encrypted upstream, .numa local domains, and developer overrides. Built from scratch in Rust.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://numa.rs">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
@@ -163,7 +166,7 @@ section {
|
||||
|
||||
h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -226,7 +229,7 @@ p.lead {
|
||||
|
||||
.hero .wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-weight: 400;
|
||||
font-size: clamp(4.5rem, 12vw, 9rem);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.03em;
|
||||
@@ -508,7 +511,7 @@ p.lead {
|
||||
.layer-card h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -552,7 +555,7 @@ p.lead {
|
||||
.arch-subsection h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -785,6 +788,169 @@ p.lead {
|
||||
background: rgba(82, 122, 82, 0.04);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
PERFORMANCE
|
||||
=========================== */
|
||||
.perf-section {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.perf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
margin-top: 3rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.perf-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.perf-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.perf-table thead th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
padding: 0.8rem 1rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.perf-table thead th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.perf-table tbody td {
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.perf-table tbody td:first-child {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.perf-table tbody tr:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.perf-table tbody tr.perf-highlight td {
|
||||
color: var(--emerald);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.perf-table tbody tr.perf-highlight td:first-child {
|
||||
color: var(--emerald);
|
||||
}
|
||||
|
||||
.perf-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.perf-stat {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.perf-stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.2rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.perf-stat-value.emerald { color: var(--emerald); }
|
||||
.perf-stat-value.teal { color: var(--teal); }
|
||||
.perf-stat-value.amber { color: var(--amber); }
|
||||
|
||||
.perf-stat-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.perf-bar-group {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.perf-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.perf-bar-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.perf-bar-track {
|
||||
flex: 1;
|
||||
height: 18px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.perf-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.perf-bar-fill.emerald { background: var(--emerald); }
|
||||
.perf-bar-fill.teal { background: var(--teal); }
|
||||
.perf-bar-fill.dim { background: var(--text-dim); }
|
||||
|
||||
.perf-bar-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
width: 42px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.perf-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.perf-note a {
|
||||
color: var(--teal-dim);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--border-teal);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
TECHNICAL
|
||||
=========================== */
|
||||
@@ -824,6 +990,8 @@ p.lead {
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.code-block::before {
|
||||
@@ -980,6 +1148,7 @@ footer .closing {
|
||||
.problem-grid { grid-template-columns: 1fr; gap: 2rem; }
|
||||
.layers-grid { grid-template-columns: 1fr; }
|
||||
.tech-grid { grid-template-columns: 1fr; }
|
||||
.perf-grid { grid-template-columns: 1fr; }
|
||||
.network-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.network-connections { display: none; }
|
||||
.hero-line { display: none; }
|
||||
@@ -1036,9 +1205,9 @@ footer .closing {
|
||||
</div>
|
||||
<div class="problem-grid">
|
||||
<div class="problem-text reveal reveal-delay-1">
|
||||
<p>Every time you visit a website, you ask a DNS resolver where to go. That resolver sees every domain you visit, when, and how often.</p>
|
||||
<p>Today, a handful of operators control this infrastructure. ICANN governs the root. Registrars can seize domains. Governments compel censorship. Your ISP logs your queries by default.</p>
|
||||
<p>The protocol that underpins the entire internet has no built-in privacy, no cryptographic ownership, and no way for users to choose who they trust.</p>
|
||||
<p>Every time you visit a website, you ask a DNS resolver where to go. That resolver sees every domain you visit, when, and how often. Your ISP logs these queries by default.</p>
|
||||
<p>Ad blockers work in one browser. Pi-hole needs a Raspberry Pi. Your local dev services live at <code>localhost:5173</code> and you can never remember which port is which.</p>
|
||||
<p>DNS is the foundation of everything you do on the internet, but the tools for controlling it locally are either too complex (dnsmasq + nginx + mkcert) or too limited (cloud-only, appliance-only).</p>
|
||||
</div>
|
||||
<div class="dns-diagram reveal reveal-delay-2">
|
||||
<div class="dns-node"><span class="node-dot dim"></span>Your browser</div>
|
||||
@@ -1062,44 +1231,43 @@ footer .closing {
|
||||
<div class="container">
|
||||
<div class="reveal">
|
||||
<div class="section-label">How It Works</div>
|
||||
<h2>Three layers, built incrementally</h2>
|
||||
<p class="lead">Numa starts as a practical developer tool and evolves toward a decentralized network. Each layer stands on its own.</p>
|
||||
<h2>What it does today</h2>
|
||||
<p class="lead">A portable DNS proxy with ad blocking, encrypted upstream, local service domains, and a REST API. Everything runs in a single binary.</p>
|
||||
</div>
|
||||
<div class="layers-grid">
|
||||
<div class="layer-card reveal reveal-delay-1">
|
||||
<div class="layer-badge">Today</div>
|
||||
<h3>DNS You Control</h3>
|
||||
<div class="layer-badge">Layer 1</div>
|
||||
<h3>Block & Protect</h3>
|
||||
<ul>
|
||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||
<li>Ephemeral DNS overrides with auto-revert</li>
|
||||
<li>Local service proxy — <code>frontend.numa</code> instead of <code>localhost:5173</code></li>
|
||||
<li>Live dashboard with real-time stats and controls</li>
|
||||
<li>REST API — 22 endpoints for programmatic control</li>
|
||||
<li>DNS-over-HTTPS — encrypted upstream (Quad9, Cloudflare, any provider)</li>
|
||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||
<li>Single binary, portable — your ad blocker travels with you</li>
|
||||
<li>Single binary, portable — your DNS travels with you</li>
|
||||
<li>macOS, Linux, and Windows</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="layer-card reveal reveal-delay-2">
|
||||
<div class="layer-badge">Next</div>
|
||||
<h3>Self-Sovereign DNS</h3>
|
||||
<div class="layer-badge">Layer 2</div>
|
||||
<h3>Developer Tools</h3>
|
||||
<ul>
|
||||
<li>pkarr integration: Ed25519 keys as domains</li>
|
||||
<li>Resolve via Mainline BitTorrent DHT (10M+ nodes)</li>
|
||||
<li>No registrar, no blockchain, no ICANN</li>
|
||||
<li>Cryptographic verification built-in</li>
|
||||
<li>Human-readable aliases for pkarr domains</li>
|
||||
<li>Local service proxy — <code>frontend.numa</code> instead of <code>localhost:5173</code></li>
|
||||
<li>Path-based routing — <code>app.numa/api</code> → <code>:5001</code></li>
|
||||
<li>Ephemeral DNS overrides with auto-revert</li>
|
||||
<li>LAN service discovery via mDNS</li>
|
||||
<li>Conditional forwarding — plays nice with Tailscale/VPN split-DNS</li>
|
||||
<li>REST API — script everything, automate anything</li>
|
||||
<li>Live dashboard with real-time stats and controls</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="layer-card reveal reveal-delay-3">
|
||||
<div class="layer-badge">Vision</div>
|
||||
<h3>Decentralized Resolver Network</h3>
|
||||
<div class="layer-badge">Coming Next</div>
|
||||
<h3>Self-Sovereign DNS</h3>
|
||||
<ul>
|
||||
<li>Operators run Numa nodes and stake tokens</li>
|
||||
<li>Earn rewards for uptime, correctness, latency</li>
|
||||
<li>Independent auditors send challenge queries</li>
|
||||
<li>Slashing for NXDOMAIN hijacking or poisoned records</li>
|
||||
<li>Geographic diversity bonuses</li>
|
||||
<li>Privacy-preserving resolution (DoH/DoT)</li>
|
||||
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
||||
<li>Global <code>.numa</code> names — self-publish, DHT-backed</li>
|
||||
<li>.onion bridge — human-readable names for Tor hidden services</li>
|
||||
<li>Ed25519 same-key binding — zero new trust assumptions</li>
|
||||
<li>No blockchain required for core naming</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1131,66 +1299,12 @@ footer .closing {
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-violet">pkarr / DHT</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box">Upstream</div></div>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-violet">DoH Upstream</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-emerald">Respond</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arch-subsection reveal">
|
||||
<h3>Layered resilience</h3>
|
||||
<div class="layer-stack">
|
||||
<div class="stack-row">
|
||||
<div class="stack-label" style="color: var(--violet)">L4 Permanence</div>
|
||||
<div class="stack-value">Arweave immutable zone snapshots (future)</div>
|
||||
</div>
|
||||
<div class="stack-row">
|
||||
<div class="stack-label" style="color: var(--violet-dim)">L3 Distribution</div>
|
||||
<div class="stack-value">Mainline DHT via pkarr — 10M+ nodes</div>
|
||||
</div>
|
||||
<div class="stack-row">
|
||||
<div class="stack-label" style="color: var(--amber)">L2 Serving</div>
|
||||
<div class="stack-value">Numa instances worldwide</div>
|
||||
</div>
|
||||
<div class="stack-row">
|
||||
<div class="stack-label" style="color: var(--teal)">L1 Compatibility</div>
|
||||
<div class="stack-value">Standard DNS wire protocol — RFC 1035</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arch-subsection reveal">
|
||||
<h3>Network actors</h3>
|
||||
<div class="network-grid">
|
||||
<div class="network-actor">
|
||||
<span class="actor-icon" style="color: var(--teal)" aria-hidden="true">∘</span>
|
||||
<h4 style="color: var(--teal)">Users</h4>
|
||||
<p>Choose resolvers from a decentralized marketplace based on latency, privacy, and reputation</p>
|
||||
</div>
|
||||
<div class="network-actor">
|
||||
<span class="actor-icon" style="color: var(--amber)" aria-hidden="true">⋄</span>
|
||||
<h4 style="color: var(--amber)">Operators</h4>
|
||||
<p>Stake tokens, run Numa nodes, earn rewards proportional to verified service quality</p>
|
||||
</div>
|
||||
<div class="network-actor">
|
||||
<span class="actor-icon" style="color: var(--rose)" aria-hidden="true">⌖</span>
|
||||
<h4 style="color: var(--rose)">Auditors</h4>
|
||||
<p>Send challenge queries from diverse locations, verify correctness and latency</p>
|
||||
</div>
|
||||
<div class="network-actor">
|
||||
<span class="actor-icon" style="color: var(--violet)" aria-hidden="true">≡</span>
|
||||
<h4 style="color: var(--violet)">Chain</h4>
|
||||
<p>Accounting, reputation scores, reward distribution, slashing proofs</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-connections" aria-hidden="true">
|
||||
<div class="network-conn-line"></div>
|
||||
<div class="network-conn-line"></div>
|
||||
<div class="network-conn-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1265,6 +1379,22 @@ footer .closing {
|
||||
<td class="check">Yes</td>
|
||||
<td class="check">Real-time + controls</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DNS-over-HTTPS upstream</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="check">Yes</td>
|
||||
<td class="check">Yes</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="check">Built in (HTTP/2 + rustls)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Conditional forwarding</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="muted">Manual</td>
|
||||
<td class="check">Auto-detects Tailscale/VPN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Zero config needed</td>
|
||||
<td class="cross">Complex setup</td>
|
||||
@@ -1273,14 +1403,6 @@ footer .closing {
|
||||
<td class="cross">Docker/setup</td>
|
||||
<td class="check">Works out of the box</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Self-sovereign DNS roadmap</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="check">pkarr / DHT</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1289,6 +1411,125 @@ footer .closing {
|
||||
|
||||
<div class="section-road" aria-hidden="true"><div class="roman-bricks"></div></div>
|
||||
|
||||
<!-- ==================== PERFORMANCE ==================== -->
|
||||
<section class="perf-section" id="performance">
|
||||
<div class="container">
|
||||
<div class="reveal">
|
||||
<div class="section-label" style="color: var(--emerald)">Performance</div>
|
||||
<h2>Measured, not claimed</h2>
|
||||
<p class="lead">Benchmarked with <code style="font-size:0.85em">dig</code> against public resolvers on the same machine. Cached queries resolve in under a microsecond.</p>
|
||||
</div>
|
||||
|
||||
<div class="perf-grid">
|
||||
<div class="reveal reveal-delay-1">
|
||||
<div class="perf-table-wrapper">
|
||||
<table class="perf-table">
|
||||
<caption class="sr-only">DNS resolver latency comparison</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resolver</th>
|
||||
<th>Avg</th>
|
||||
<th>P50</th>
|
||||
<th>P99</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="perf-highlight">
|
||||
<td>Numa (cached)</td>
|
||||
<td><1ms</td>
|
||||
<td><1ms</td>
|
||||
<td><1ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Numa (cold)</td>
|
||||
<td>9ms</td>
|
||||
<td>9ms</td>
|
||||
<td>18ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System resolver</td>
|
||||
<td>9ms</td>
|
||||
<td>8ms</td>
|
||||
<td>44ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Quad9</td>
|
||||
<td>15ms</td>
|
||||
<td>13ms</td>
|
||||
<td>43ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cloudflare</td>
|
||||
<td>19ms</td>
|
||||
<td>14ms</td>
|
||||
<td>132ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Google</td>
|
||||
<td>22ms</td>
|
||||
<td>17ms</td>
|
||||
<td>37ms</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="perf-bar-group">
|
||||
<div class="perf-bar-row">
|
||||
<span class="perf-bar-label">Numa</span>
|
||||
<div class="perf-bar-track"><div class="perf-bar-fill emerald" style="width: 2%"></div></div>
|
||||
<span class="perf-bar-ms"><1ms</span>
|
||||
</div>
|
||||
<div class="perf-bar-row">
|
||||
<span class="perf-bar-label">System</span>
|
||||
<div class="perf-bar-track"><div class="perf-bar-fill dim" style="width: 20%"></div></div>
|
||||
<span class="perf-bar-ms">9ms</span>
|
||||
</div>
|
||||
<div class="perf-bar-row">
|
||||
<span class="perf-bar-label">Quad9</span>
|
||||
<div class="perf-bar-track"><div class="perf-bar-fill dim" style="width: 33%"></div></div>
|
||||
<span class="perf-bar-ms">15ms</span>
|
||||
</div>
|
||||
<div class="perf-bar-row">
|
||||
<span class="perf-bar-label">Cloudflare</span>
|
||||
<div class="perf-bar-track"><div class="perf-bar-fill dim" style="width: 42%"></div></div>
|
||||
<span class="perf-bar-ms">19ms</span>
|
||||
</div>
|
||||
<div class="perf-bar-row">
|
||||
<span class="perf-bar-label">Google</span>
|
||||
<div class="perf-bar-track"><div class="perf-bar-fill dim" style="width: 49%"></div></div>
|
||||
<span class="perf-bar-ms">22ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perf-sidebar reveal reveal-delay-2">
|
||||
<div class="perf-stat">
|
||||
<div class="perf-stat-value emerald">689 ns</div>
|
||||
<div class="perf-stat-label">Cached round-trip — parse query, cache lookup, serialize response</div>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<div class="perf-stat-value teal">2.0M</div>
|
||||
<div class="perf-stat-label">Queries per second (single-threaded pipeline throughput, batched)</div>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<div class="perf-stat-value amber">0 allocations</div>
|
||||
<div class="perf-stat-label">Heap allocations in the I/O path — 4KB stack buffers, inline serialization</div>
|
||||
</div>
|
||||
|
||||
<p class="perf-note">
|
||||
Cold queries match system resolver speed — the bottleneck is upstream RTT, not Numa. We don't claim to be faster when the network is the limit.
|
||||
<br><br>
|
||||
Benchmarks are reproducible: <code style="font-size:0.85em">cargo bench</code> for micro-benchmarks, <code style="font-size:0.85em">python3 bench/dns-bench.sh</code> for end-to-end.
|
||||
<a href="https://github.com/razvandimescu/numa/tree/main/bench">Methodology →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section-road on-surface" aria-hidden="true"><div class="roman-bricks"></div></div>
|
||||
|
||||
<!-- ==================== TECHNICAL ==================== -->
|
||||
<section id="technical">
|
||||
<div class="container">
|
||||
@@ -1305,25 +1546,30 @@ footer .closing {
|
||||
<dd>Zero — wire protocol parsed from scratch</dd>
|
||||
|
||||
<dt>Dependencies</dt>
|
||||
<dd>8 runtime crates (tokio, axum, hyper, serde, serde_json, toml, log, futures)</dd>
|
||||
<dd>18 runtime crates — tokio, axum, hyper, reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
|
||||
|
||||
<dt>Packet Format</dt>
|
||||
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
|
||||
|
||||
<dt>Concurrency</dt>
|
||||
<dd>Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)</dd>
|
||||
<dd>Arc<ServerCtx> + RwLock for reads, Mutex for writes (never across .await)</dd>
|
||||
|
||||
<dt>Signatures</dt>
|
||||
<dd>Ed25519 via pkarr for self-sovereign domains</dd>
|
||||
<dt>Upstream</dt>
|
||||
<dd>DNS-over-HTTPS (DoH) via reqwest + http2 + rustls</dd>
|
||||
</dl>
|
||||
<div class="code-block reveal reveal-delay-2">
|
||||
<span class="comment"># Install (pick one)</span>
|
||||
<span class="prompt">$</span> <span class="cmd">brew install</span> razvandimescu/tap/numa
|
||||
<span class="prompt">$</span> <span class="cmd">cargo install</span> numa
|
||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span>
|
||||
|
||||
<span class="comment"># Run</span>
|
||||
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53, :80, :5380</span>
|
||||
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
||||
<span class="prompt">$</span> <span class="cmd">open</span> http://numa.numa <span class="comment"># dashboard</span>
|
||||
<span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span>
|
||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
|
||||
<span class="flag">-d</span> <span class="str">'{"name":"frontend",
|
||||
"target_port":5173}'</span> <span class="comment"># http://frontend.numa</span>
|
||||
"target_port":5173}'</span> <span class="comment"># https://frontend.numa</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1345,7 +1591,7 @@ footer .closing {
|
||||
</div>
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 1</span>
|
||||
<span class="phase-desc">Override layer + REST API with 18 endpoints</span>
|
||||
<span class="phase-desc">Override layer + REST API for programmatic DNS control</span>
|
||||
</div>
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 2</span>
|
||||
@@ -1359,25 +1605,21 @@ footer .closing {
|
||||
<span class="phase">Phase 4</span>
|
||||
<span class="phase-desc">Local service proxy — .numa domains, HTTP/HTTPS reverse proxy, auto TLS, WebSocket</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 5</span>
|
||||
<span class="phase-desc">pkarr integration — resolve Ed25519 keys via Mainline DHT (15M nodes)</span>
|
||||
<span class="phase-desc">DNS-over-HTTPS — encrypted upstream, HTTP/2 connection pooling</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 6</span>
|
||||
<span class="phase-desc">pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 7</span>
|
||||
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-amber">
|
||||
<span class="phase">Phase 7</span>
|
||||
<span class="phase-desc">Audit protocol — challenge-based verification of resolver honesty</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-violet">
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 8</span>
|
||||
<span class="phase-desc">Numa Network — proof-of-service consensus, NUMA token, paid .numa domains</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-violet">
|
||||
<span class="phase">Phase 9</span>
|
||||
<span class="phase-desc">.onion bridge — human-readable .numa names for Tor hidden services</span>
|
||||
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1391,6 +1633,7 @@ footer .closing {
|
||||
</p>
|
||||
<div class="footer-links reveal reveal-delay-1">
|
||||
<a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="/blog/">Blog</a>
|
||||
<a href="https://github.com/razvandimescu/numa/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
|
||||
</div>
|
||||
<p class="closing reveal reveal-delay-2">Built from scratch in Rust. No dependencies on trust.</p>
|
||||
|
||||
Reference in New Issue
Block a user