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:
50
bench/results.json
Normal file
50
bench/results.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"Numa(cold)": {
|
||||||
|
"avg": 9,
|
||||||
|
"p50": 9,
|
||||||
|
"p99": 18,
|
||||||
|
"min": 8,
|
||||||
|
"max": 18,
|
||||||
|
"count": 50
|
||||||
|
},
|
||||||
|
"Numa(cached)": {
|
||||||
|
"avg": 0,
|
||||||
|
"p50": 0,
|
||||||
|
"p99": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"count": 50
|
||||||
|
},
|
||||||
|
"System": {
|
||||||
|
"avg": 9.1,
|
||||||
|
"p50": 8,
|
||||||
|
"p99": 44,
|
||||||
|
"min": 7,
|
||||||
|
"max": 44,
|
||||||
|
"count": 50
|
||||||
|
},
|
||||||
|
"Google": {
|
||||||
|
"avg": 22.4,
|
||||||
|
"p50": 17,
|
||||||
|
"p99": 37,
|
||||||
|
"min": 13,
|
||||||
|
"max": 37,
|
||||||
|
"count": 50
|
||||||
|
},
|
||||||
|
"Cloudflare": {
|
||||||
|
"avg": 18.7,
|
||||||
|
"p50": 14,
|
||||||
|
"p99": 132,
|
||||||
|
"min": 12,
|
||||||
|
"max": 132,
|
||||||
|
"count": 50
|
||||||
|
},
|
||||||
|
"Quad9": {
|
||||||
|
"avg": 14.5,
|
||||||
|
"p50": 13,
|
||||||
|
"p99": 43,
|
||||||
|
"min": 12,
|
||||||
|
"max": 43,
|
||||||
|
"count": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
328
blog/dns-from-scratch.md
Normal file
328
blog/dns-from-scratch.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
---
|
||||||
|
title: I Built a DNS Resolver from Scratch in Rust
|
||||||
|
description: How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries.
|
||||||
|
date: March 2026
|
||||||
|
---
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. The entire RFC 1035 wire protocol — headers, labels, compression pointers, record types — parsed and serialized by hand. It started as a weekend learning project, became a side project I kept coming back to over 6 years, and eventually turned into [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS.
|
||||||
|
|
||||||
|
A note on terminology before we go further: Numa is currently a *forwarding* 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.
|
||||||
|
|
||||||
|
Here's what surprised me along the way.
|
||||||
|
|
||||||
|
## What does a DNS packet actually look like?
|
||||||
|
|
||||||
|
You can see a real one yourself. Run this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig @127.0.0.1 example.com A +noedns
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
;; ->>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
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the human-readable version. But what's actually on the wire? A DNS query for `example.com A` is just 29 bytes:
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
We can send exactly those bytes and capture what comes back:
|
||||||
|
|
||||||
|
```python
|
||||||
|
python3 -c "
|
||||||
|
import socket
|
||||||
|
# Hand-craft a DNS query: header (12 bytes) + question (17 bytes)
|
||||||
|
q = b'\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00' # header
|
||||||
|
q += b'\x07example\x03com\x00\x00\x01\x00\x01' # question
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.sendto(q, ('127.0.0.1', 53))
|
||||||
|
resp = s.recv(512)
|
||||||
|
for i in range(0, len(resp), 16):
|
||||||
|
h = ' '.join(f'{b:02x}' for b in resp[i:i+16])
|
||||||
|
a = ''.join(chr(b) if 32<=b<127 else '.' for b in resp[i:i+16])
|
||||||
|
print(f'{i:08x} {h:<48s} {a}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
83 bytes back. Let's annotate the response:
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice something wasteful? The domain `example.com` appears *three times* — 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.
|
||||||
|
|
||||||
|
The whole thing fits in a single UDP datagram. The structure is:
|
||||||
|
|
||||||
|
```
|
||||||
|
+--+--+--+--+--+--+--+--+
|
||||||
|
| 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
|
||||||
|
+--+--+--+--+--+--+--+--+
|
||||||
|
```
|
||||||
|
|
||||||
|
In Rust, parsing the header is just reading 12 bytes and unpacking the flags:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn read(buffer: &mut BytePacketBuffer) -> Result<DnsHeader> {
|
||||||
|
let id = buffer.read_u16()?;
|
||||||
|
let flags = buffer.read_u16()?;
|
||||||
|
// Flags pack 9 fields into 16 bits
|
||||||
|
let recursion_desired = (flags & (1 << 8)) > 0;
|
||||||
|
let truncated_message = (flags & (1 << 9)) > 0;
|
||||||
|
let authoritative_answer = (flags & (1 << 10)) > 0;
|
||||||
|
let opcode = (flags >> 11) & 0x0F;
|
||||||
|
let response = (flags & (1 << 15)) > 0;
|
||||||
|
// ... and so on
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Label compression is the clever part
|
||||||
|
|
||||||
|
Remember how `example.com` appeared three times in that 83-byte response? Domain names in DNS are stored as a sequence of **labels** — length-prefixed segments:
|
||||||
|
|
||||||
|
```
|
||||||
|
example.com → [7]example[3]com[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `[7]` means "the next 7 bytes are a label." The `[0]` 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.
|
||||||
|
|
||||||
|
DNS solves this with **compression pointers** — if the top two bits of a length byte are `11`, 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 `C0 0C` — a 2-byte pointer to offset 12 where `example.com` 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Pointers can chain — a pointer can point to another pointer. Parsing this correctly requires tracking your position in the buffer and handling jumps:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
|
||||||
|
let mut pos = self.pos();
|
||||||
|
let mut jumped = false;
|
||||||
|
let mut delim = "";
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let len = self.get(pos)?;
|
||||||
|
|
||||||
|
// Top two bits set = compression pointer
|
||||||
|
if (len & 0xC0) == 0xC0 {
|
||||||
|
if !jumped {
|
||||||
|
self.seek(pos + 2)?; // advance past the pointer
|
||||||
|
}
|
||||||
|
let offset = (((len as u16) ^ 0xC0) << 8) | self.get(pos + 1)? as u16;
|
||||||
|
pos = offset as usize;
|
||||||
|
jumped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += 1;
|
||||||
|
if len == 0 { break; } // root label
|
||||||
|
|
||||||
|
outstr.push_str(delim);
|
||||||
|
outstr.push_str(&self.get_range(pos, len as usize)?
|
||||||
|
.iter().map(|&b| b as char).collect::<String>());
|
||||||
|
delim = ".";
|
||||||
|
pos += len as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !jumped {
|
||||||
|
self.seek(pos)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This one bit me: when you follow a pointer, you must *not* 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.
|
||||||
|
|
||||||
|
## TTL adjustment on read, not write
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The cleaner approach: store the original TTL and the timestamp when the record was cached. On read, compute `remaining = original_ttl - elapsed`. If it's zero or negative, the entry is stale — evict it lazily.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
|
||||||
|
let key = (domain.to_lowercase(), qtype);
|
||||||
|
let entry = self.entries.get(&key)?;
|
||||||
|
let elapsed = entry.cached_at.elapsed().as_secs() as u32;
|
||||||
|
|
||||||
|
if elapsed >= entry.original_ttl {
|
||||||
|
self.entries.remove(&key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust TTLs in the response to reflect remaining time
|
||||||
|
let mut packet = entry.packet.clone();
|
||||||
|
for answer in &mut packet.answers {
|
||||||
|
answer.set_ttl(entry.original_ttl.saturating_sub(elapsed));
|
||||||
|
}
|
||||||
|
Some(packet)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL.
|
||||||
|
|
||||||
|
## The resolution pipeline
|
||||||
|
|
||||||
|
Each incoming UDP packet spawns a tokio task. Each task walks a deterministic pipeline — every step either answers or passes to the next:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
This is where "from scratch" pays off. Want conditional forwarding for Tailscale? Insert a step before the upstream. Want to override `api.example.com` 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 `resolve()` call.
|
||||||
|
|
||||||
|
## DNS-over-HTTPS: the "wait, that's it?" moment
|
||||||
|
|
||||||
|
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 `Content-Type: application/dns-message`, and parse the response the same way. Same bytes, different transport.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn forward_doh(
|
||||||
|
query: &DnsPacket,
|
||||||
|
url: &str,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
timeout_duration: Duration,
|
||||||
|
) -> Result<DnsPacket> {
|
||||||
|
let mut send_buffer = BytePacketBuffer::new();
|
||||||
|
query.write(&mut send_buffer)?;
|
||||||
|
|
||||||
|
let resp = timeout(timeout_duration, client
|
||||||
|
.post(url)
|
||||||
|
.header("content-type", "application/dns-message")
|
||||||
|
.header("accept", "application/dns-message")
|
||||||
|
.body(send_buffer.filled().to_vec())
|
||||||
|
.send())
|
||||||
|
.await??.error_for_status()?;
|
||||||
|
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes);
|
||||||
|
DnsPacket::from_buffer(&mut recv_buffer)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `http2` 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.
|
||||||
|
|
||||||
|
The `Upstream` enum dispatches between UDP and DoH based on the URL scheme:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Upstream {
|
||||||
|
Udp(SocketAddr),
|
||||||
|
Doh { url: String, client: reqwest::Client },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP. Simple, no toggles.
|
||||||
|
|
||||||
|
## "Why not just use dnsmasq + nginx + mkcert?"
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}'
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## What I learned
|
||||||
|
|
||||||
|
**DNS is a 40-year-old protocol that works remarkably well.** 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.
|
||||||
|
|
||||||
|
**The hard parts aren't where you'd expect.** 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 (`scutil` vs `/etc/resolv.conf`), and getting multiple processes to bind the same multicast port (`SO_REUSEPORT` on macOS, `SO_REUSEADDR` on Linux).
|
||||||
|
|
||||||
|
**Learn the vocabulary before you show up.** 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.
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
On the roadmap:
|
||||||
|
|
||||||
|
- **DoT (DNS-over-TLS)** — 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.
|
||||||
|
- **Recursive resolution** — walk the delegation chain from root servers instead of forwarding. Combined with DNSSEC validation, this removes the need to trust any upstream resolver.
|
||||||
|
- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Publish DNS records signed with your Ed25519 key, no registrar needed.
|
||||||
|
|
||||||
|
But those are rabbit holes for future posts.
|
||||||
|
|
||||||
|
[github.com/razvandimescu/numa](https://github.com/razvandimescu/numa)
|
||||||
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Numa — Dashboard</title>
|
<title>Numa — Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
<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">
|
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
*, *::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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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.">
|
<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="canonical" href="https://numa.rs">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
|
||||||
<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">
|
<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>
|
<style>
|
||||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
@@ -163,7 +166,7 @@ section {
|
|||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
font-size: clamp(2rem, 4vw, 3rem);
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -226,7 +229,7 @@ p.lead {
|
|||||||
|
|
||||||
.hero .wordmark {
|
.hero .wordmark {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
font-size: clamp(4.5rem, 12vw, 9rem);
|
font-size: clamp(4.5rem, 12vw, 9rem);
|
||||||
line-height: 0.9;
|
line-height: 0.9;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
@@ -508,7 +511,7 @@ p.lead {
|
|||||||
.layer-card h3 {
|
.layer-card h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +555,7 @@ p.lead {
|
|||||||
.arch-subsection h3 {
|
.arch-subsection h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,6 +788,169 @@ p.lead {
|
|||||||
background: rgba(82, 122, 82, 0.04);
|
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
|
TECHNICAL
|
||||||
=========================== */
|
=========================== */
|
||||||
@@ -824,6 +990,8 @@ p.lead {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block::before {
|
.code-block::before {
|
||||||
@@ -980,6 +1148,7 @@ footer .closing {
|
|||||||
.problem-grid { grid-template-columns: 1fr; gap: 2rem; }
|
.problem-grid { grid-template-columns: 1fr; gap: 2rem; }
|
||||||
.layers-grid { grid-template-columns: 1fr; }
|
.layers-grid { grid-template-columns: 1fr; }
|
||||||
.tech-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-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.network-connections { display: none; }
|
.network-connections { display: none; }
|
||||||
.hero-line { display: none; }
|
.hero-line { display: none; }
|
||||||
@@ -1036,9 +1205,9 @@ footer .closing {
|
|||||||
</div>
|
</div>
|
||||||
<div class="problem-grid">
|
<div class="problem-grid">
|
||||||
<div class="problem-text reveal reveal-delay-1">
|
<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>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>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>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>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>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>
|
||||||
<div class="dns-diagram reveal reveal-delay-2">
|
<div class="dns-diagram reveal reveal-delay-2">
|
||||||
<div class="dns-node"><span class="node-dot dim"></span>Your browser</div>
|
<div class="dns-node"><span class="node-dot dim"></span>Your browser</div>
|
||||||
@@ -1062,44 +1231,43 @@ footer .closing {
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="section-label">How It Works</div>
|
<div class="section-label">How It Works</div>
|
||||||
<h2>Three layers, built incrementally</h2>
|
<h2>What it does today</h2>
|
||||||
<p class="lead">Numa starts as a practical developer tool and evolves toward a decentralized network. Each layer stands on its own.</p>
|
<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>
|
||||||
<div class="layers-grid">
|
<div class="layers-grid">
|
||||||
<div class="layer-card reveal reveal-delay-1">
|
<div class="layer-card reveal reveal-delay-1">
|
||||||
<div class="layer-badge">Today</div>
|
<div class="layer-badge">Layer 1</div>
|
||||||
<h3>DNS You Control</h3>
|
<h3>Block & Protect</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
<li>Ephemeral DNS overrides with auto-revert</li>
|
<li>DNS-over-HTTPS — encrypted upstream (Quad9, Cloudflare, any provider)</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>TTL-aware caching (sub-ms lookups)</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="layer-card reveal reveal-delay-2">
|
<div class="layer-card reveal reveal-delay-2">
|
||||||
<div class="layer-badge">Next</div>
|
<div class="layer-badge">Layer 2</div>
|
||||||
<h3>Self-Sovereign DNS</h3>
|
<h3>Developer Tools</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>pkarr integration: Ed25519 keys as domains</li>
|
<li>Local service proxy — <code>frontend.numa</code> instead of <code>localhost:5173</code></li>
|
||||||
<li>Resolve via Mainline BitTorrent DHT (10M+ nodes)</li>
|
<li>Path-based routing — <code>app.numa/api</code> → <code>:5001</code></li>
|
||||||
<li>No registrar, no blockchain, no ICANN</li>
|
<li>Ephemeral DNS overrides with auto-revert</li>
|
||||||
<li>Cryptographic verification built-in</li>
|
<li>LAN service discovery via mDNS</li>
|
||||||
<li>Human-readable aliases for pkarr domains</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="layer-card reveal reveal-delay-3">
|
<div class="layer-card reveal reveal-delay-3">
|
||||||
<div class="layer-badge">Vision</div>
|
<div class="layer-badge">Coming Next</div>
|
||||||
<h3>Decentralized Resolver Network</h3>
|
<h3>Self-Sovereign DNS</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Operators run Numa nodes and stake tokens</li>
|
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
||||||
<li>Earn rewards for uptime, correctness, latency</li>
|
<li>Global <code>.numa</code> names — self-publish, DHT-backed</li>
|
||||||
<li>Independent auditors send challenge queries</li>
|
<li>.onion bridge — human-readable names for Tor hidden services</li>
|
||||||
<li>Slashing for NXDOMAIN hijacking or poisoned records</li>
|
<li>Ed25519 same-key binding — zero new trust assumptions</li>
|
||||||
<li>Geographic diversity bonuses</li>
|
<li>No blockchain required for core naming</li>
|
||||||
<li>Privacy-preserving resolution (DoH/DoT)</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1131,66 +1299,12 @@ footer .closing {
|
|||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box hl-violet">pkarr / DHT</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">Upstream</div></div>
|
|
||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box hl-emerald">Respond</div></div>
|
<div class="pipeline-node"><div class="pipeline-box hl-emerald">Respond</div></div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1265,6 +1379,22 @@ footer .closing {
|
|||||||
<td class="check">Yes</td>
|
<td class="check">Yes</td>
|
||||||
<td class="check">Real-time + controls</td>
|
<td class="check">Real-time + controls</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Zero config needed</td>
|
<td>Zero config needed</td>
|
||||||
<td class="cross">Complex setup</td>
|
<td class="cross">Complex setup</td>
|
||||||
@@ -1273,14 +1403,6 @@ footer .closing {
|
|||||||
<td class="cross">Docker/setup</td>
|
<td class="cross">Docker/setup</td>
|
||||||
<td class="check">Works out of the box</td>
|
<td class="check">Works out of the box</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1289,6 +1411,125 @@ footer .closing {
|
|||||||
|
|
||||||
<div class="section-road" aria-hidden="true"><div class="roman-bricks"></div></div>
|
<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 ==================== -->
|
<!-- ==================== TECHNICAL ==================== -->
|
||||||
<section id="technical">
|
<section id="technical">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -1305,25 +1546,30 @@ footer .closing {
|
|||||||
<dd>Zero — wire protocol parsed from scratch</dd>
|
<dd>Zero — wire protocol parsed from scratch</dd>
|
||||||
|
|
||||||
<dt>Dependencies</dt>
|
<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>
|
<dt>Packet Format</dt>
|
||||||
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
|
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
|
||||||
|
|
||||||
<dt>Concurrency</dt>
|
<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>
|
<dt>Upstream</dt>
|
||||||
<dd>Ed25519 via pkarr for self-sovereign domains</dd>
|
<dd>DNS-over-HTTPS (DoH) via reqwest + http2 + rustls</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="code-block reveal reveal-delay-2">
|
<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">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">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">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="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",
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1345,7 +1591,7 @@ footer .closing {
|
|||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item done">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 1</span>
|
<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>
|
||||||
<div class="roadmap-item done">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 2</span>
|
<span class="phase">Phase 2</span>
|
||||||
@@ -1359,25 +1605,21 @@ footer .closing {
|
|||||||
<span class="phase">Phase 4</span>
|
<span class="phase">Phase 4</span>
|
||||||
<span class="phase-desc">Local service proxy — .numa domains, HTTP/HTTPS reverse proxy, auto TLS, WebSocket</span>
|
<span class="phase-desc">Local service proxy — .numa domains, HTTP/HTTPS reverse proxy, auto TLS, WebSocket</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 5</span>
|
<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>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item phase-teal">
|
||||||
<span class="phase">Phase 6</span>
|
<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>
|
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-amber">
|
<div class="roadmap-item phase-teal">
|
||||||
<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">
|
|
||||||
<span class="phase">Phase 8</span>
|
<span class="phase">Phase 8</span>
|
||||||
<span class="phase-desc">Numa Network — proof-of-service consensus, NUMA token, paid .numa domains</span>
|
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1391,6 +1633,7 @@ footer .closing {
|
|||||||
</p>
|
</p>
|
||||||
<div class="footer-links reveal reveal-delay-1">
|
<div class="footer-links reveal reveal-delay-1">
|
||||||
<a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener">GitHub</a>
|
<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>
|
<a href="https://github.com/razvandimescu/numa/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="closing reveal reveal-delay-2">Built from scratch in Rust. No dependencies on trust.</p>
|
<p class="closing reveal reveal-delay-2">Built from scratch in Rust. No dependencies on trust.</p>
|
||||||
|
|||||||
48
src/api.rs
48
src/api.rs
@@ -15,6 +15,13 @@ use crate::question::QueryType;
|
|||||||
use crate::stats::QueryPath;
|
use crate::stats::QueryPath;
|
||||||
|
|
||||||
const DASHBOARD_HTML: &str = include_str!("../site/dashboard.html");
|
const DASHBOARD_HTML: &str = include_str!("../site/dashboard.html");
|
||||||
|
const FONTS_CSS: &str = include_str!("../site/fonts/fonts.css");
|
||||||
|
const FONT_DM_SANS: &[u8] = include_bytes!("../site/fonts/dm-sans-latin.woff2");
|
||||||
|
const FONT_DM_SANS_ITALIC: &[u8] = include_bytes!("../site/fonts/dm-sans-italic-latin.woff2");
|
||||||
|
const FONT_INSTRUMENT: &[u8] = include_bytes!("../site/fonts/instrument-serif-latin.woff2");
|
||||||
|
const FONT_INSTRUMENT_ITALIC: &[u8] =
|
||||||
|
include_bytes!("../site/fonts/instrument-serif-italic-latin.woff2");
|
||||||
|
const FONT_JETBRAINS: &[u8] = include_bytes!("../site/fonts/jetbrains-mono-latin.woff2");
|
||||||
|
|
||||||
pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -50,6 +57,27 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
|||||||
.route("/services/{name}/routes", post(add_route))
|
.route("/services/{name}/routes", post(add_route))
|
||||||
.route("/services/{name}/routes", delete(remove_route))
|
.route("/services/{name}/routes", delete(remove_route))
|
||||||
.route("/ca.pem", get(serve_ca))
|
.route("/ca.pem", get(serve_ca))
|
||||||
|
.route("/fonts/fonts.css", get(serve_fonts_css))
|
||||||
|
.route(
|
||||||
|
"/fonts/dm-sans-latin.woff2",
|
||||||
|
get(|| async { serve_font(FONT_DM_SANS) }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fonts/dm-sans-italic-latin.woff2",
|
||||||
|
get(|| async { serve_font(FONT_DM_SANS_ITALIC) }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fonts/instrument-serif-latin.woff2",
|
||||||
|
get(|| async { serve_font(FONT_INSTRUMENT) }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fonts/instrument-serif-italic-latin.woff2",
|
||||||
|
get(|| async { serve_font(FONT_INSTRUMENT_ITALIC) }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fonts/jetbrains-mono-latin.woff2",
|
||||||
|
get(|| async { serve_font(FONT_JETBRAINS) }),
|
||||||
|
)
|
||||||
.with_state(ctx)
|
.with_state(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +872,26 @@ async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn serve_fonts_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/css"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000"),
|
||||||
|
],
|
||||||
|
FONTS_CSS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_font(data: &'static [u8]) -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "font/woff2"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000"),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn check_tcp(addr: std::net::SocketAddr) -> bool {
|
async fn check_tcp(addr: std::net::SocketAddr) -> bool {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(100),
|
std::time::Duration::from_millis(100),
|
||||||
|
|||||||
155
src/proxy.rs
155
src/proxy.rs
@@ -117,58 +117,15 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_host(req: &Request) -> Option<String> {
|
fn error_page(title: &str, body: &str) -> String {
|
||||||
req.headers()
|
format!(
|
||||||
.get(hyper::header::HOST)
|
r##"<!DOCTYPE html>
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|h| h.split(':').next().unwrap_or(h).to_lowercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::response::Response {
|
|
||||||
let hostname = match extract_host(&req) {
|
|
||||||
Some(h) => h,
|
|
||||||
None => {
|
|
||||||
return (StatusCode::BAD_REQUEST, "missing Host header").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) {
|
|
||||||
Some(name) => name.to_string(),
|
|
||||||
None => {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let request_path = req.uri().path().to_string();
|
|
||||||
|
|
||||||
let (target_host, target_port, rewritten_path) = {
|
|
||||||
let store = state.ctx.services.lock().unwrap();
|
|
||||||
if let Some(entry) = store.lookup(&service_name) {
|
|
||||||
let (port, path) = entry.resolve_route(&request_path);
|
|
||||||
("localhost".to_string(), port, path)
|
|
||||||
} else {
|
|
||||||
let mut peers = state.ctx.lan_peers.lock().unwrap();
|
|
||||||
match peers.lookup(&service_name) {
|
|
||||||
Some((ip, port)) => (ip.to_string(), port, request_path.clone()),
|
|
||||||
None => {
|
|
||||||
return (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
[(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
|
||||||
format!(
|
|
||||||
r##"<!DOCTYPE html>
|
|
||||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>404 — {0}{1}</title>
|
<title>{title} — Numa</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&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after {{ margin:0;padding:0;box-sizing:border-box }}
|
*,*::before,*::after {{ margin:0;padding:0;box-sizing:border-box }}
|
||||||
body {{
|
body {{
|
||||||
font-family: 'DM Sans', system-ui, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: #f5f0e8;
|
background: #f5f0e8;
|
||||||
color: #2c2418;
|
color: #2c2418;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -202,16 +159,24 @@ body::before {{
|
|||||||
from {{ opacity:0; transform:translateY(20px) }}
|
from {{ opacity:0; transform:translateY(20px) }}
|
||||||
to {{ opacity:1; transform:translateY(0) }}
|
to {{ opacity:1; transform:translateY(0) }}
|
||||||
}}
|
}}
|
||||||
.code {{
|
.hero-text {{
|
||||||
font-family: 'Instrument Serif', Georgia, serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
font-size: 6rem;
|
font-size: 6rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #c0623a;
|
color: #c0623a;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}}
|
}}
|
||||||
|
.label {{
|
||||||
|
font-family: ui-monospace, 'SF Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #b5443a;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}}
|
||||||
.domain {{
|
.domain {{
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: ui-monospace, 'SF Mono', monospace;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #2c2418;
|
color: #2c2418;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -239,7 +204,7 @@ pre {{
|
|||||||
color: #e8e0d4;
|
color: #e8e0d4;
|
||||||
padding: 1rem 1.2rem;
|
padding: 1rem 1.2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: ui-monospace, 'SF Mono', monospace;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
@@ -248,9 +213,9 @@ pre {{
|
|||||||
pre .prompt {{ color: #8baa6e }}
|
pre .prompt {{ color: #8baa6e }}
|
||||||
pre .flag {{ color: #8b9fbb }}
|
pre .flag {{ color: #8b9fbb }}
|
||||||
pre .str {{ color: #d48a5a }}
|
pre .str {{ color: #d48a5a }}
|
||||||
.lyrics {{
|
.aside {{
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
font-family: 'Instrument Serif', Georgia, serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a39888;
|
color: #a39888;
|
||||||
@@ -261,19 +226,87 @@ pre .str {{ color: #d48a5a }}
|
|||||||
@keyframes fade {{ to {{ opacity: 1 }} }}
|
@keyframes fade {{ to {{ opacity: 1 }} }}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="code">404</div>
|
{body}
|
||||||
|
</div>
|
||||||
|
</body></html>"##
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_host(req: &Request) -> Option<String> {
|
||||||
|
req.headers()
|
||||||
|
.get(hyper::header::HOST)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|h| h.split(':').next().unwrap_or(h).to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::response::Response {
|
||||||
|
let hostname = match extract_host(&req) {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
return (StatusCode::BAD_REQUEST, "missing Host header").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => {
|
||||||
|
// Check if this domain was blocked — show a helpful styled page
|
||||||
|
if state.ctx.blocklist.read().unwrap().is_blocked(&hostname) {
|
||||||
|
let body = format!(
|
||||||
|
r#" <div class="hero-text">🛡</div>
|
||||||
|
<div class="label">Blocked by Numa</div>
|
||||||
|
<div class="domain">{0}</div>
|
||||||
|
<p class="message">This domain is on the ad & tracker blocklist.<br>To allow it, use the <a href="http://numa.numa">dashboard</a> or:</p>
|
||||||
|
<pre><span class="prompt">$</span> <span class="str">curl</span> <span class="flag">-X POST</span> localhost:5380/blocking/allowlist \
|
||||||
|
<span class="flag">-d</span> '<span class="str">{{"domain":"{0}"}}</span>'</pre>"#,
|
||||||
|
hostname
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
[(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
error_page(&format!("Blocked — {}", hostname), &body),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_path = req.uri().path().to_string();
|
||||||
|
|
||||||
|
let (target_host, target_port, rewritten_path) = {
|
||||||
|
let store = state.ctx.services.lock().unwrap();
|
||||||
|
if let Some(entry) = store.lookup(&service_name) {
|
||||||
|
let (port, path) = entry.resolve_route(&request_path);
|
||||||
|
("localhost".to_string(), port, path)
|
||||||
|
} else {
|
||||||
|
let mut peers = state.ctx.lan_peers.lock().unwrap();
|
||||||
|
match peers.lookup(&service_name) {
|
||||||
|
Some((ip, port)) => (ip.to_string(), port, request_path.clone()),
|
||||||
|
None => {
|
||||||
|
let body = format!(
|
||||||
|
r#" <div class="hero-text">404</div>
|
||||||
<div class="domain">{0}{1}</div>
|
<div class="domain">{0}{1}</div>
|
||||||
<p class="message">This service isn't registered yet.<br>Add it from the <a href="http://numa.numa">dashboard</a> or:</p>
|
<p class="message">This service isn't registered yet.<br>Add it from the <a href="http://numa.numa">dashboard</a> or:</p>
|
||||||
<pre><span class="prompt">$</span> <span class="str">curl</span> <span class="flag">-X POST</span> numa.numa:5380/services \
|
<pre><span class="prompt">$</span> <span class="str">curl</span> <span class="flag">-X POST</span> numa.numa:5380/services \
|
||||||
<span class="flag">-H</span> 'Content-Type: application/json' \
|
<span class="flag">-H</span> 'Content-Type: application/json' \
|
||||||
<span class="flag">-d</span> '<span class="str">{{"name":"{0}","target_port":3000}}</span>'</pre>
|
<span class="flag">-d</span> '<span class="str">{{"name":"{0}","target_port":3000}}</span>'</pre>
|
||||||
<div class="lyrics">ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha</div>
|
<div class="aside">ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha</div>"#,
|
||||||
</div>
|
|
||||||
</body></html>"##,
|
|
||||||
service_name, state.ctx.proxy_tld_suffix
|
service_name, state.ctx.proxy_tld_suffix
|
||||||
),
|
);
|
||||||
)
|
return (
|
||||||
.into_response()
|
StatusCode::NOT_FOUND,
|
||||||
|
[(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
error_page(
|
||||||
|
&format!("404 — {}{}", service_name, state.ctx.proxy_tld_suffix),
|
||||||
|
&body,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,8 +112,15 @@ fn generate_service_cert(
|
|||||||
.distinguished_name
|
.distinguished_name
|
||||||
.push(DnType::CommonName, format!("Numa .{} services", tld));
|
.push(DnType::CommonName, format!("Numa .{} services", tld));
|
||||||
|
|
||||||
// Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc.
|
// Add a wildcard SAN so any .numa domain gets a valid cert (including
|
||||||
|
// unregistered services — lets the proxy show a styled 404 over HTTPS).
|
||||||
|
// Also add each service explicitly for clients that don't match wildcards.
|
||||||
let mut sans = Vec::new();
|
let mut sans = Vec::new();
|
||||||
|
let wildcard = format!("*.{}", tld);
|
||||||
|
match wildcard.clone().try_into() {
|
||||||
|
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
|
||||||
|
Err(e) => warn!("invalid wildcard SAN {}: {}", wildcard, e),
|
||||||
|
}
|
||||||
for name in service_names {
|
for name in service_names {
|
||||||
let fqdn = format!("{}.{}", name, tld);
|
let fqdn = format!("{}.{}", name, tld);
|
||||||
match fqdn.clone().try_into() {
|
match fqdn.clone().try_into() {
|
||||||
|
|||||||
Reference in New Issue
Block a user