feat: self-host fonts, styled block page, wildcard TLS #16

Merged
razvandimescu merged 4 commits from perf/hot-path-optimizations into main 2026-03-27 08:19:54 +08:00
15 changed files with 265 additions and 250 deletions
Showing only changes of commit bb7e33619a - Show all commits

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.
## Async per-query with tokio
## The resolution pipeline
Each incoming UDP packet spawns a tokio task. The main loop never blocks:
```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":
Each incoming UDP packet spawns a tokio task. Each task walks a deterministic pipeline — every step either answers or passes to the next:
```
┌─────────────────────────────────────────────────────┐
@@ -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)
└──→ 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 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.
This is where "from scratch" pays off. Want conditional forwarding for Tailscale? Insert a step before the upstream. Want to override `api.example.com` for 5 minutes while debugging? Add an entry in the overrides step — it auto-expires. A DNS library would have hidden this pipeline behind an opaque `resolve()` call.
## DNS-over-HTTPS: the "wait, that's it?" moment
@@ -316,37 +297,21 @@ If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP
## "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.
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:
You absolutely can — those are mature, battle-tested tools. The difference is integration: with dnsmasq + nginx + mkcert, you're configuring three tools with three config formats. Numa puts the DNS record, reverse proxy, and TLS cert behind one API call:
```bash
curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}'
```
That creates the DNS entry, generates a TLS certificate with the correct SAN, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files.
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.
That creates the DNS entry, generates a TLS certificate, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. Having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible.
## What I learned
**DNS is a 40-year-old protocol that works remarkably well.** The wire format is tight, the caching model is elegant, and the hierarchical delegation system has scaled to billions of queries per day. The things people complain about (DNSSEC complexity, lack of encryption) are extensions bolted on decades later, not flaws in the original design.
**"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).
**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.
**Learn the vocabulary before you show up.** I initially called Numa a "DNS resolver" and got corrected — it's a forwarding resolver. The distinction matters to people who work with DNS professionally, and being sloppy about it cost me credibility in my first community posts.
## What's next

View File

@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title$ — Numa</title>
<meta name="description" content="$description$">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -73,7 +71,7 @@ body::before {
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
@@ -101,7 +99,7 @@ body::before {
.article-header h1 {
font-family: var(--font-display);
font-weight: 600;
font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
margin-bottom: 1rem;

View File

@@ -7,9 +7,7 @@
<meta name="description" content="How DNS actually works at the wire
level — label compression, TTL tricks, DoH, and what surprised me
building a resolver with zero DNS libraries.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -75,7 +73,7 @@ body::before {
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
@@ -103,7 +101,7 @@ body::before {
.article-header h1 {
font-family: var(--font-display);
font-weight: 600;
font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
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>
<p>No background thread. No timer. Entries expire lazily. The cache
stays consistent because every consumer sees the adjusted TTL.</p>
<h2 id="async-per-query-with-tokio">Async per-query with tokio</h2>
<p>Each incoming UDP packet spawns a tokio task. The main loop never
blocks:</p>
<div class="sourceCode" id="cb13"><pre
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>
<h2 id="the-resolution-pipeline">The resolution pipeline</h2>
<p>Each incoming UDP packet spawns a tokio task. Each task walks a
deterministic pipeline — every step either answers or passes to the
next:</p>
<pre><code> ┌─────────────────────────────────────────────────────┐
│ Numa Resolution Pipeline │
└─────────────────────────────────────────────────────┘
@@ -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
│ after N min) proxy+TLS) records) adjusted) (encrypted)
└──→ Each step either answers or passes to the next.
Adding a feature = inserting a function into this chain.</code></pre>
<p>Want conditional forwarding for Tailscale? Insert a step before the
upstream that checks the domain suffix. Want to override
<code>api.example.com</code> 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 <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>
└──→ Each step either answers or passes to the next.</code></pre>
<p>This is where “from scratch” pays off. Want conditional forwarding
for Tailscale? Insert a step before the upstream. Want to override
<code>api.example.com</code> for 5 minutes while debugging? Add an entry
in the overrides step — it auto-expires. A DNS library would have hidden
this pipeline behind an opaque <code>resolve()</code> call.</p>
<h2 id="dns-over-https-the-wait-thats-it-moment">DNS-over-HTTPS: the
“wait, thats it?” moment</h2>
<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>,
and parse the response the same way. Same bytes, different
transport.</p>
<div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">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="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="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="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="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="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="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="cb15-9"><a href="#cb15-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="cb15-11"><a href="#cb15-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="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="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="cb15-15"><a href="#cb15-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="cb15-17"><a href="#cb15-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="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="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="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<div class="sourceCode" id="cb14"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> forward_doh(</span>
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a> query<span class="op">:</span> <span class="op">&amp;</span>DnsPacket<span class="op">,</span></span>
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> <span class="op">&amp;</span><span class="pp">reqwest::</span>Client<span class="op">,</span></span>
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a> timeout_duration<span class="op">:</span> Duration<span class="op">,</span></span>
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&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-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> send_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>new()<span class="op">;</span></span>
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a> query<span class="op">.</span>write(<span class="op">&amp;</span><span class="kw">mut</span> send_buffer)<span class="op">?;</span></span>
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resp <span class="op">=</span> timeout(timeout_duration<span class="op">,</span> client</span>
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>post(url)</span>
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">&quot;content-type&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="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>body(send_buffer<span class="op">.</span>filled()<span class="op">.</span>to_vec())</span>
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>send())</span>
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">??.</span>error_for_status()<span class="op">?;</span></span>
<span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-18"><a href="#cb14-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> bytes <span class="op">=</span> resp<span class="op">.</span>bytes()<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb14-19"><a href="#cb14-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> recv_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>from_bytes(<span class="op">&amp;</span>bytes)<span class="op">;</span></span>
<span id="cb14-20"><a href="#cb14-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">DnsPacket::</span>from_buffer(<span class="op">&amp;</span><span class="kw">mut</span> recv_buffer)</span>
<span id="cb14-21"><a href="#cb14-21" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>The one gotcha that cost me an hour: Quad9 and other DoH providers
require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad
Request. Adding the <code>http2</code> feature to reqwest fixed it. The
@@ -603,59 +580,25 @@ the TLS session — ~16ms vs ~50ms for the first query. Free
performance.</p>
<p>The <code>Upstream</code> enum dispatches between UDP and DoH based
on the URL scheme:</p>
<div class="sourceCode" id="cb16"><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>
<span id="cb16-2"><a href="#cb16-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="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> Upstream <span class="op">{</span></span>
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> Udp(SocketAddr)<span class="op">,</span></span>
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> Doh <span class="op">{</span> url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span> client<span class="op">:</span> <span class="pp">reqwest::</span>Client <span class="op">},</span></span>
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
<p>If the configured address starts with <code>https://</code>, its
DoH. Otherwise, plain UDP. Simple, no toggles.</p>
<h2 id="why-not-just-use-dnsmasq-nginx-mkcert">“Why not just use dnsmasq
+ nginx + mkcert?”</h2>
<p>Fair question — I got this a lot when I first <a
href="https://www.reddit.com/r/programare/">posted about Numa</a>. And
the answer is: you absolutely can. Those are mature, battle-tested
tools.</p>
<p>The difference is integration. With dnsmasq + nginx + mkcert, youre
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:</p>
<div class="sourceCode" id="cb17"><pre
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>
<p>You absolutely can — those are mature, battle-tested tools. The
difference is integration: with dnsmasq + nginx + mkcert, youre
configuring three tools with three config formats. Numa puts the DNS
record, reverse proxy, and TLS cert behind one API call:</p>
<div class="sourceCode" id="cb16"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="ex">curl</span> <span class="at">-X</span> POST localhost:5380/services <span class="at">-d</span> <span class="st">&#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, and starts
proxying — including WebSocket upgrade for Vite HMR. One command, no
config files. Having full control over the resolution pipeline is what
makes auto-revert overrides and LAN discovery possible.</p>
<h2 id="what-i-learned">What I learned</h2>
<p><strong>DNS is a 40-year-old protocol that works remarkably
well.</strong> The wire format is tight, the caching model is elegant,
@@ -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
encryption) are extensions bolted on decades later, not flaws in the
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
the wire protocol was straightforward (RFC 1035 is well-written). The
hard parts were: browsers rejecting wildcard certs under single-label
TLDs (<code>*.numa</code> fails — you need per-service SANs), macOS
resolver quirks (scutil vs /etc/resolv.conf), and getting multiple
processes to bind the same multicast port (<code>SO_REUSEPORT</code> on
macOS, <code>SO_REUSEADDR</code> on Linux).</p>
<p><strong>Terminology will get you roasted.</strong> I initially called
Numa a “DNS resolver” and got corrected on Reddit — its a forwarding
resolver (DNS proxy). It doesnt 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 youre building in a domain
with established terminology, learn the vocabulary before you show
up.</p>
TLDs, macOS resolver quirks (<code>scutil</code> vs
<code>/etc/resolv.conf</code>), and getting multiple processes to bind
the same multicast port (<code>SO_REUSEPORT</code> on macOS,
<code>SO_REUSEADDR</code> on Linux).</p>
<p><strong>Learn the vocabulary before you show up.</strong> I initially
called Numa a “DNS resolver” and got corrected — its a forwarding
resolver. The distinction matters to people who work with DNS
professionally, and being sloppy about it cost me credibility in my
first community posts.</p>
<h2 id="whats-next">Whats next</h2>
<p>Numa is at v0.5.0 with DNS forwarding, caching, ad blocking,
DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service

View File

@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog — Numa</title>
<meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -66,7 +64,7 @@ body::before {
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
@@ -87,7 +85,7 @@ body::before {
.blog-index h1 {
font-family: var(--font-display);
font-weight: 600;
font-weight: 400;
font-size: 2.5rem;
margin-bottom: 3rem;
}

View File

@@ -4,9 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

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:type" content="website">
<meta property="og:url" content="https://numa.rs">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -168,7 +166,7 @@ section {
h2 {
font-family: var(--font-display);
font-weight: 600;
font-weight: 400;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.2;
margin-bottom: 1.5rem;
@@ -231,7 +229,7 @@ p.lead {
.hero .wordmark {
font-family: var(--font-display);
font-weight: 700;
font-weight: 400;
font-size: clamp(4.5rem, 12vw, 9rem);
line-height: 0.9;
letter-spacing: -0.03em;
@@ -513,7 +511,7 @@ p.lead {
.layer-card h3 {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 600;
font-weight: 400;
margin-bottom: 1.25rem;
}
@@ -557,7 +555,7 @@ p.lead {
.arch-subsection h3 {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 600;
font-weight: 400;
margin-bottom: 2rem;
}
@@ -880,7 +878,7 @@ p.lead {
.perf-stat-value {
font-family: var(--font-display);
font-size: 2.2rem;
font-weight: 600;
font-weight: 400;
line-height: 1.1;
}

View File

@@ -15,6 +15,13 @@ use crate::question::QueryType;
use crate::stats::QueryPath;
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 {
Router::new()
@@ -50,6 +57,27 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/services/{name}/routes", post(add_route))
.route("/services/{name}/routes", delete(remove_route))
.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)
}
@@ -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 {
tokio::time::timeout(
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> {
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")],
fn error_page(title: &str, body: &str) -> String {
format!(
r##"<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 — {0}{1}</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">
<title>{title} — Numa</title>
<style>
*,*::before,*::after {{ margin:0;padding:0;box-sizing:border-box }}
body {{
font-family: 'DM Sans', system-ui, sans-serif;
font-family: system-ui, -apple-system, sans-serif;
background: #f5f0e8;
color: #2c2418;
min-height: 100vh;
@@ -202,16 +159,24 @@ body::before {{
from {{ opacity:0; transform:translateY(20px) }}
to {{ opacity:1; transform:translateY(0) }}
}}
.code {{
font-family: 'Instrument Serif', Georgia, serif;
.hero-text {{
font-family: Georgia, 'Times New Roman', serif;
font-size: 6rem;
line-height: 1;
color: #c0623a;
letter-spacing: 0.04em;
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 {{
font-family: 'JetBrains Mono', monospace;
font-family: ui-monospace, 'SF Mono', monospace;
font-size: 1.1rem;
color: #2c2418;
margin-top: 1rem;
@@ -239,7 +204,7 @@ pre {{
color: #e8e0d4;
padding: 1rem 1.2rem;
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: ui-monospace, 'SF Mono', monospace;
font-size: 0.78rem;
line-height: 1.7;
margin-top: 1.2rem;
@@ -248,9 +213,9 @@ pre {{
pre .prompt {{ color: #8baa6e }}
pre .flag {{ color: #8b9fbb }}
pre .str {{ color: #d48a5a }}
.lyrics {{
.aside {{
margin-top: 2.5rem;
font-family: 'Instrument Serif', Georgia, serif;
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
font-size: 0.85rem;
color: #a39888;
@@ -261,19 +226,87 @@ pre .str {{ color: #d48a5a }}
@keyframes fade {{ to {{ opacity: 1 }} }}
</style></head><body>
<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>
<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 \
<span class="flag">-H</span> 'Content-Type: application/json' \
<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>
</body></html>"##,
<div class="aside">ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha</div>"#,
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
.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 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 {
let fqdn = format!("{}.{}", name, tld);
match fqdn.clone().try_into() {