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>
This commit is contained in:
Razvan Dimescu
2026-03-27 02:01:43 +02:00
parent 38b5cd2cce
commit bb7e33619a
15 changed files with 265 additions and 250 deletions

View File

@@ -232,25 +232,9 @@ pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL. No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL.
## Async per-query with tokio ## The resolution pipeline
Each incoming UDP packet spawns a tokio task. The main loop never blocks: Each incoming UDP packet spawns a tokio task. Each task walks a deterministic pipeline — every step either answers or passes to the next:
```rust
loop {
let mut buffer = BytePacketBuffer::new();
let (_, src_addr) = socket.recv_from(&mut buffer.buf).await?;
let ctx = Arc::clone(&ctx);
tokio::spawn(async move {
if let Err(e) = handle_query(buffer, src_addr, &ctx).await {
error!("{} | HANDLER ERROR | {}", src_addr, e);
}
});
}
```
Each `handle_query` walks a pipeline. This is the part where "from scratch" pays off — every step is just a function that either returns a response or says "not my problem, pass it on":
``` ```
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
@@ -266,12 +250,9 @@ Each `handle_query` walks a pipeline. This is the part where "from scratch" pays
│ after N min) proxy+TLS) records) adjusted) (encrypted) │ after N min) proxy+TLS) records) adjusted) (encrypted)
└──→ Each step either answers or passes to the next. └──→ Each step either answers or passes to the next.
Adding a feature = inserting a function into this chain.
``` ```
Want conditional forwarding for Tailscale? Insert a step before the upstream that checks the domain suffix. Want to override `api.example.com` for 5 minutes while debugging? Insert an entry in the overrides step — it auto-expires and the domain goes back to resolving normally. A DNS library would have hidden this pipeline behind an opaque `resolve()` call. 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.
This is one of those cases where Rust + tokio makes things almost embarrassingly simple. In a synchronous resolver, you'd need a thread pool or hand-rolled event loop. Here, each query is a lightweight future. A slow upstream query doesn't block anything — other queries keep flowing.
## DNS-over-HTTPS: the "wait, that's it?" moment ## DNS-over-HTTPS: the "wait, that's it?" moment
@@ -316,37 +297,21 @@ If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP
## "Why not just use dnsmasq + nginx + mkcert?" ## "Why not just use dnsmasq + nginx + mkcert?"
Fair question — I got this a lot when I first [posted about Numa](https://www.reddit.com/r/programare/). And the answer is: you absolutely can. Those are mature, battle-tested tools. 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:
The difference is integration. With dnsmasq + nginx + mkcert, you're configuring three tools: DNS resolution, reverse proxy rules, and certificate generation. Each has its own config format, its own lifecycle, its own failure modes. Numa puts the DNS record, the reverse proxy, and the TLS cert behind a single API call:
```bash ```bash
curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}' curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}'
``` ```
That creates the DNS entry, generates a TLS certificate with the correct SAN, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. 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.
There's also a distinction people miss: **mkcert and certbot solve different problems.** Certbot issues certificates for public domains via Let's Encrypt — it needs DNS validation or an open port 80. Numa generates certificates for `.numa` domains that don't exist publicly. You can't get a Let's Encrypt cert for `frontend.numa`. They're complementary, not alternatives.
Someone on Reddit told me the real value is "TLS termination + reverse proxy, simple to install, for developers — stop there." Honestly, they might be right about focus. But DNS is the foundation the proxy sits on, and having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible. Sometimes the "unnecessary" part is what makes the interesting part work.
## The blocklist memory problem
Numa's ad blocking loads the [Hagezi Pro](https://github.com/hagezi/dns-blocklists) list at startup — ~385,000 domains stored in a `HashSet<String>`. This works, but it consumes ~30MB of memory. For a laptop DNS proxy, that's fine. For embedded devices or a future where you want to run Numa on a router, it's too much.
The obvious optimization is a **Bloom filter** — a probabilistic data structure that can tell you "definitely not in the set" or "probably in the set" using a fraction of the memory. A Bloom filter for 385K domains with a 0.1% false positive rate would use ~700KB instead of 30MB. The false positives (0.1% of queries hitting domains not in the list) would be blocked unnecessarily, which is acceptable for ad blocking.
I haven't implemented this yet — the `HashSet` is simple, correct, and 30MB is nothing on a laptop. But if Numa ever needs to run on a router or a Raspberry Pi, this is the first optimization I'd reach for.
## What I learned ## 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. **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.
**"From scratch" gives you full control.** When I wanted to add ephemeral overrides that auto-revert, it was trivial — just a new step in the resolution pipeline. Conditional forwarding for Tailscale/VPN? Another step. Every feature is a function that takes a query and returns either a response or "pass to the next stage." A DNS library would have hidden this pipeline. **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).
**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 (`*.numa` fails — you need per-service SANs), 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.
**Terminology will get you roasted.** I initially called Numa a "DNS resolver" and got corrected on Reddit — it's a forwarding resolver (DNS proxy). It doesn't walk the delegation chain from root servers; it forwards to an upstream. The distinction matters to people who work with DNS for a living, and being sloppy about it cost me credibility in my first community posts. If you're building in a domain with established terminology, learn the vocabulary before you show up.
## What's next ## What's next

View File

@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title$ — Numa</title> <title>$title$ — Numa</title>
<meta name="description" content="$description$"> <meta name="description" content="$description$">
<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: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">
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -73,7 +71,7 @@ body::before {
.blog-nav .wordmark { .blog-nav .wordmark {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 700; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@@ -101,7 +99,7 @@ body::before {
.article-header h1 { .article-header h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 600; font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem); font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15; line-height: 1.15;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@@ -7,9 +7,7 @@
<meta name="description" content="How DNS actually works at the wire <meta name="description" content="How DNS actually works at the wire
level — label compression, TTL tricks, DoH, and what surprised me level — label compression, TTL tricks, DoH, and what surprised me
building a resolver with zero DNS libraries."> building a resolver with zero DNS libraries.">
<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: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">
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -75,7 +73,7 @@ body::before {
.blog-nav .wordmark { .blog-nav .wordmark {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 700; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@@ -103,7 +101,7 @@ body::before {
.article-header h1 { .article-header h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 600; font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem); font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15; line-height: 1.15;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -522,24 +520,10 @@ class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href=
<span id="cb12-17"><a href="#cb12-17" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div> <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 <p>No background thread. No timer. Entries expire lazily. The cache
stays consistent because every consumer sees the adjusted TTL.</p> stays consistent because every consumer sees the adjusted TTL.</p>
<h2 id="async-per-query-with-tokio">Async per-query with tokio</h2> <h2 id="the-resolution-pipeline">The resolution pipeline</h2>
<p>Each incoming UDP packet spawns a tokio task. The main loop never <p>Each incoming UDP packet spawns a tokio task. Each task walks a
blocks:</p> deterministic pipeline — every step either answers or passes to the
<div class="sourceCode" id="cb13"><pre next:</p>
class="sourceCode rust"><code class="sourceCode rust"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="cf">loop</span> <span class="op">{</span></span>
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>new()<span class="op">;</span></span>
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> (_<span class="op">,</span> src_addr) <span class="op">=</span> socket<span class="op">.</span>recv_from(<span class="op">&amp;</span><span class="kw">mut</span> buffer<span class="op">.</span>buf)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> ctx <span class="op">=</span> <span class="pp">Arc::</span>clone(<span class="op">&amp;</span>ctx)<span class="op">;</span></span>
<span id="cb13-6"><a href="#cb13-6" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb13-7"><a href="#cb13-7" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Err</span>(e) <span class="op">=</span> handle_query(buffer<span class="op">,</span> src_addr<span class="op">,</span> <span class="op">&amp;</span>ctx)<span class="op">.</span><span class="kw">await</span> <span class="op">{</span></span>
<span id="cb13-8"><a href="#cb13-8" aria-hidden="true" tabindex="-1"></a> <span class="pp">error!</span>(<span class="st">&quot;{} | HANDLER ERROR | {}&quot;</span><span class="op">,</span> src_addr<span class="op">,</span> e)<span class="op">;</span></span>
<span id="cb13-9"><a href="#cb13-9" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb13-10"><a href="#cb13-10" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb13-11"><a href="#cb13-11" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>Each <code>handle_query</code> walks a pipeline. This is the part
where “from scratch” pays off — every step is just a function that
either returns a response or says “not my problem, pass it on”:</p>
<pre><code> ┌─────────────────────────────────────────────────────┐ <pre><code> ┌─────────────────────────────────────────────────────┐
│ Numa Resolution Pipeline │ │ Numa Resolution Pipeline │
└─────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────┘
@@ -552,19 +536,12 @@ either returns a response or says “not my problem, pass it on”:</p>
│ (auto-reverts (reverse (ad gone) (static (TTL to upstream │ (auto-reverts (reverse (ad gone) (static (TTL to upstream
│ after N min) proxy+TLS) records) adjusted) (encrypted) │ after N min) proxy+TLS) records) adjusted) (encrypted)
└──→ Each step either answers or passes to the next. └──→ Each step either answers or passes to the next.</code></pre>
Adding a feature = inserting a function into this chain.</code></pre> <p>This is where “from scratch” pays off. Want conditional forwarding
<p>Want conditional forwarding for Tailscale? Insert a step before the for Tailscale? Insert a step before the upstream. Want to override
upstream that checks the domain suffix. Want to override <code>api.example.com</code> for 5 minutes while debugging? Add an entry
<code>api.example.com</code> for 5 minutes while debugging? Insert an in the overrides step — it auto-expires. A DNS library would have hidden
entry in the overrides step — it auto-expires and the domain goes back this pipeline behind an opaque <code>resolve()</code> call.</p>
to resolving normally. A DNS library would have hidden this pipeline
behind an opaque <code>resolve()</code> call.</p>
<p>This is one of those cases where Rust + tokio makes things almost
embarrassingly simple. In a synchronous resolver, youd need a thread
pool or hand-rolled event loop. Here, each query is a lightweight
future. A slow upstream query doesnt block anything — other queries
keep flowing.</p>
<h2 id="dns-over-https-the-wait-thats-it-moment">DNS-over-HTTPS: the <h2 id="dns-over-https-the-wait-thats-it-moment">DNS-over-HTTPS: the
“wait, thats it?” moment</h2> “wait, thats it?” moment</h2>
<p>The most recent addition, and honestly the one that surprised me with <p>The most recent addition, and honestly the one that surprised me with
@@ -573,28 +550,28 @@ the exact same DNS wire-format packet youd send over UDP, POST it to an
HTTPS endpoint with <code>Content-Type: application/dns-message</code>, HTTPS endpoint with <code>Content-Type: application/dns-message</code>,
and parse the response the same way. Same bytes, different and parse the response the same way. Same bytes, different
transport.</p> transport.</p>
<div class="sourceCode" id="cb15"><pre <div class="sourceCode" id="cb14"><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">async</span> <span class="kw">fn</span> forward_doh(</span> 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="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> query<span class="op">:</span> <span class="op">&amp;</span>DnsPacket<span class="op">,</span></span> <span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a> query<span class="op">:</span> <span class="op">&amp;</span>DnsPacket<span class="op">,</span></span>
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><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">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> <span class="op">&amp;</span><span class="pp">reqwest::</span>Client<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">&amp;</span><span class="pp">reqwest::</span>Client<span class="op">,</span></span>
<span id="cb15-5"><a href="#cb15-5" aria-hidden="true" tabindex="-1"></a> timeout_duration<span class="op">:</span> Duration<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="cb15-6"><a href="#cb15-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>DnsPacket<span class="op">&gt;</span> <span class="op">{</span></span> <span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>DnsPacket<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb15-7"><a href="#cb15-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-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="cb15-8"><a href="#cb15-8" aria-hidden="true" tabindex="-1"></a> query<span class="op">.</span>write(<span class="op">&amp;</span><span class="kw">mut</span> send_buffer)<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">&amp;</span><span class="kw">mut</span> send_buffer)<span class="op">?;</span></span>
<span id="cb15-9"><a href="#cb15-9" aria-hidden="true" tabindex="-1"></a></span> <span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-10"><a href="#cb15-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-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="cb15-11"><a href="#cb15-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>post(url)</span> <span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>post(url)</span>
<span id="cb15-12"><a href="#cb15-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;content-type&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span> <span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;content-type&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span>
<span id="cb15-13"><a href="#cb15-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;accept&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span> <span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;accept&quot;</span><span class="op">,</span> <span class="st">&quot;application/dns-message&quot;</span>)</span>
<span id="cb15-14"><a href="#cb15-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-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="cb15-15"><a href="#cb15-15" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>send())</span> <span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>send())</span>
<span id="cb15-16"><a href="#cb15-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-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="cb15-17"><a href="#cb15-17" aria-hidden="true" tabindex="-1"></a></span> <span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-18"><a href="#cb15-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-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="cb15-19"><a href="#cb15-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">&amp;</span>bytes)<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">&amp;</span>bytes)<span class="op">;</span></span>
<span id="cb15-20"><a href="#cb15-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">DnsPacket::</span>from_buffer(<span class="op">&amp;</span><span class="kw">mut</span> recv_buffer)</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">&amp;</span><span class="kw">mut</span> recv_buffer)</span>
<span id="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div> <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 <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 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 Request. Adding the <code>http2</code> feature to reqwest fixed it. The
@@ -603,59 +580,25 @@ the TLS session — ~16ms vs ~50ms for the first query. Free
performance.</p> performance.</p>
<p>The <code>Upstream</code> enum dispatches between UDP and DoH based <p>The <code>Upstream</code> enum dispatches between UDP and DoH based
on the URL scheme:</p> on the URL scheme:</p>
<div class="sourceCode" id="cb16"><pre <div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> Upstream <span class="op">{</span></span> 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="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a> Udp(SocketAddr)<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="cb16-3"><a href="#cb16-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-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="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div> <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>, its <p>If the configured address starts with <code>https://</code>, its
DoH. Otherwise, plain UDP. Simple, no toggles.</p> DoH. Otherwise, plain UDP. Simple, no toggles.</p>
<h2 id="why-not-just-use-dnsmasq-nginx-mkcert">“Why not just use dnsmasq <h2 id="why-not-just-use-dnsmasq-nginx-mkcert">“Why not just use dnsmasq
+ nginx + mkcert?”</h2> + nginx + mkcert?”</h2>
<p>Fair question — I got this a lot when I first <a <p>You absolutely can — those are mature, battle-tested tools. The
href="https://www.reddit.com/r/programare/">posted about Numa</a>. And difference is integration: with dnsmasq + nginx + mkcert, youre
the answer is: you absolutely can. Those are mature, battle-tested configuring three tools with three config formats. Numa puts the DNS
tools.</p> record, reverse proxy, and TLS cert behind one API call:</p>
<p>The difference is integration. With dnsmasq + nginx + mkcert, youre <div class="sourceCode" id="cb16"><pre
configuring three tools: DNS resolution, reverse proxy rules, and 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">&#39;{&quot;name&quot;:&quot;frontend&quot;,&quot;target_port&quot;:5173}&#39;</span></span></code></pre></div>
certificate generation. Each has its own config format, its own <p>That creates the DNS entry, generates a TLS certificate, and starts
lifecycle, its own failure modes. Numa puts the DNS record, the reverse proxying — including WebSocket upgrade for Vite HMR. One command, no
proxy, and the TLS cert behind a single API call:</p> config files. Having full control over the resolution pipeline is what
<div class="sourceCode" id="cb17"><pre makes auto-revert overrides and LAN discovery possible.</p>
class="sourceCode bash"><code class="sourceCode bash"><span id="cb17-1"><a href="#cb17-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">&#39;{&quot;name&quot;:&quot;frontend&quot;,&quot;target_port&quot;:5173}&#39;</span></span></code></pre></div>
<p>That creates the DNS entry, generates a TLS certificate with the
correct SAN, and starts proxying — including WebSocket upgrade for Vite
HMR. One command, no config files.</p>
<p>Theres also a distinction people miss: <strong>mkcert and certbot
solve different problems.</strong> Certbot issues certificates for
public domains via Lets Encrypt — it needs DNS validation or an open
port 80. Numa generates certificates for <code>.numa</code> domains that
dont exist publicly. You cant get a Lets Encrypt cert for
<code>frontend.numa</code>. Theyre complementary, not alternatives.</p>
<p>Someone on Reddit told me the real value is “TLS termination +
reverse proxy, simple to install, for developers — stop there.”
Honestly, they might be right about focus. But DNS is the foundation the
proxy sits on, and having full control over the resolution pipeline is
what makes auto-revert overrides and LAN discovery possible. Sometimes
the “unnecessary” part is what makes the interesting part work.</p>
<h2 id="the-blocklist-memory-problem">The blocklist memory problem</h2>
<p>Numas ad blocking loads the <a
href="https://github.com/hagezi/dns-blocklists">Hagezi Pro</a> list at
startup — ~385,000 domains stored in a
<code>HashSet&lt;String&gt;</code>. This works, but it consumes ~30MB of
memory. For a laptop DNS proxy, thats fine. For embedded devices or a
future where you want to run Numa on a router, its too much.</p>
<p>The obvious optimization is a <strong>Bloom filter</strong> — a
probabilistic data structure that can tell you “definitely not in the
set” or “probably in the set” using a fraction of the memory. A Bloom
filter for 385K domains with a 0.1% false positive rate would use ~700KB
instead of 30MB. The false positives (0.1% of queries hitting domains
not in the list) would be blocked unnecessarily, which is acceptable for
ad blocking.</p>
<p>I havent implemented this yet — the <code>HashSet</code> is simple,
correct, and 30MB is nothing on a laptop. But if Numa ever needs to run
on a router or a Raspberry Pi, this is the first optimization Id reach
for.</p>
<h2 id="what-i-learned">What I learned</h2> <h2 id="what-i-learned">What I learned</h2>
<p><strong>DNS is a 40-year-old protocol that works remarkably <p><strong>DNS is a 40-year-old protocol that works remarkably
well.</strong> The wire format is tight, the caching model is elegant, well.</strong> The wire format is tight, the caching model is elegant,
@@ -663,27 +606,18 @@ and the hierarchical delegation system has scaled to billions of queries
per day. The things people complain about (DNSSEC complexity, lack of per day. The things people complain about (DNSSEC complexity, lack of
encryption) are extensions bolted on decades later, not flaws in the encryption) are extensions bolted on decades later, not flaws in the
original design.</p> original design.</p>
<p><strong>“From scratch” gives you full control.</strong> When I wanted
to add ephemeral overrides that auto-revert, it was trivial — just a new
step in the resolution pipeline. Conditional forwarding for
Tailscale/VPN? Another step. Every feature is a function that takes a
query and returns either a response or “pass to the next stage.” A DNS
library would have hidden this pipeline.</p>
<p><strong>The hard parts arent where youd expect.</strong> Parsing <p><strong>The hard parts arent where youd expect.</strong> Parsing
the wire protocol was straightforward (RFC 1035 is well-written). The the wire protocol was straightforward (RFC 1035 is well-written). The
hard parts were: browsers rejecting wildcard certs under single-label hard parts were: browsers rejecting wildcard certs under single-label
TLDs (<code>*.numa</code> fails — you need per-service SANs), macOS TLDs, macOS resolver quirks (<code>scutil</code> vs
resolver quirks (scutil vs /etc/resolv.conf), and getting multiple <code>/etc/resolv.conf</code>), and getting multiple processes to bind
processes to bind the same multicast port (<code>SO_REUSEPORT</code> on the same multicast port (<code>SO_REUSEPORT</code> on macOS,
macOS, <code>SO_REUSEADDR</code> on Linux).</p> <code>SO_REUSEADDR</code> on Linux).</p>
<p><strong>Terminology will get you roasted.</strong> I initially called <p><strong>Learn the vocabulary before you show up.</strong> I initially
Numa a “DNS resolver” and got corrected on Reddit — its a forwarding called Numa a “DNS resolver” and got corrected — its a forwarding
resolver (DNS proxy). It doesnt walk the delegation chain from root resolver. The distinction matters to people who work with DNS
servers; it forwards to an upstream. The distinction matters to people professionally, and being sloppy about it cost me credibility in my
who work with DNS for a living, and being sloppy about it cost me first community posts.</p>
credibility in my first community posts. If youre building in a domain
with established terminology, learn the vocabulary before you show
up.</p>
<h2 id="whats-next">Whats next</h2> <h2 id="whats-next">Whats next</h2>
<p>Numa is at v0.5.0 with DNS forwarding, caching, ad blocking, <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 DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service

View File

@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog — Numa</title> <title>Blog — Numa</title>
<meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch."> <meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch.">
<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: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">
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -66,7 +64,7 @@ body::before {
.blog-nav .wordmark { .blog-nav .wordmark {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 700; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@@ -87,7 +85,7 @@ body::before {
.blog-index h1 { .blog-index h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 600; font-weight: 400;
font-size: 2.5rem; font-size: 2.5rem;
margin-bottom: 3rem; margin-bottom: 3rem;
} }

View File

@@ -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; }

Binary file not shown.

Binary file not shown.

36
site/fonts/fonts.css Normal file
View 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');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -10,9 +10,7 @@
<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: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:type" content="website">
<meta property="og:url" content="https://numa.rs"> <meta property="og:url" content="https://numa.rs">
<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: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">
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -168,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;
@@ -231,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;
@@ -513,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;
} }
@@ -557,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;
} }
@@ -880,7 +878,7 @@ p.lead {
.perf-stat-value { .perf-stat-value {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2.2rem; font-size: 2.2rem;
font-weight: 600; font-weight: 400;
line-height: 1.1; line-height: 1.1;
} }

View File

@@ -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),

View File

@@ -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()
.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 => {
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!( format!(
r##"<!DOCTYPE html> 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">&#x1f6e1;</div>
<div class="label">Blocked by Numa</div>
<div class="domain">{0}</div>
<p class="message">This domain is on the ad &amp; 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 (
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() .into_response();
} }
} }
} }

View File

@@ -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() {