feat: self-host fonts, styled block page, wildcard TLS #16
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">&</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">&</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">&</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">"{} | HANDLER ERROR | {}"</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, 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.</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, that’s it?” moment</h2>
|
“wait, that’s 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 you’d 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">&</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">&</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">&</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">&</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">&</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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span>DnsPacket<span class="op">></span> <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="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">&</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">&</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">"content-type"</span><span class="op">,</span> <span class="st">"application/dns-message"</span>)</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="cb15-13"><a href="#cb15-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-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="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">&</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">&</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">&</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">&</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>, it’s
|
<p>If the configured address starts with <code>https://</code>, it’s
|
||||||
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, you’re
|
||||||
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, you’re
|
<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">'{"name":"frontend","target_port":5173}'</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">'{"name":"frontend","target_port":5173}'</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>There’s also a distinction people miss: <strong>mkcert and certbot
|
|
||||||
solve different problems.</strong> Certbot issues certificates for
|
|
||||||
public domains via Let’s Encrypt — it needs DNS validation or an open
|
|
||||||
port 80. Numa generates certificates for <code>.numa</code> domains that
|
|
||||||
don’t exist publicly. You can’t get a Let’s Encrypt cert for
|
|
||||||
<code>frontend.numa</code>. They’re 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>Numa’s 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<String></code>. 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.</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 haven’t 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 I’d 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 aren’t where you’d expect.</strong> Parsing
|
<p><strong>The hard parts aren’t where you’d 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 — it’s a forwarding
|
called Numa a “DNS resolver” and got corrected — it’s a forwarding
|
||||||
resolver (DNS proxy). It doesn’t 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 you’re building in a domain
|
|
||||||
with established terminology, learn the vocabulary before you show
|
|
||||||
up.</p>
|
|
||||||
<h2 id="whats-next">What’s next</h2>
|
<h2 id="whats-next">What’s 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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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