chore: GoatCounter analytics, README for v0.11.0, DoT blog post

- Add GoatCounter script to site pages (cookie-free, no consent needed)
- Update README: setup-phone section, DoT blog link, roadmap checkbox
- Add DoT blog post source and SVG assets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-04-10 19:18:19 +03:00
parent 652fca5b80
commit c4b2c890e8
7 changed files with 404 additions and 0 deletions

View File

@@ -77,6 +77,14 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification,
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
**Phone setup** — point your iPhone or Android at Numa in one step:
```bash
numa setup-phone
```
Prints a QR code. Scan it, install the profile, toggle certificate trust — your phone's DNS now routes through Numa over TLS. Requires `[mobile] enabled = true` in `numa.toml`.
## LAN Discovery
Run Numa on multiple machines. They find each other automatically via mDNS:
@@ -116,6 +124,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
## Learn More
- [Blog: DNS-over-TLS from Scratch in Rust](https://numa.rs/blog/posts/dot-from-scratch.html)
- [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html)
- [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html)
- [Configuration reference](numa.toml) — all options documented inline
@@ -130,6 +139,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
- [x] SRTT-based nameserver selection
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
- [ ] Global `.numa` names — DHT-backed, no registrar

167
blog/dot-from-scratch.md Normal file
View File

@@ -0,0 +1,167 @@
---
title: DNS-over-TLS from Scratch in Rust
description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
date: April 2026
---
The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it.
Numa now runs a DoT listener on port 853. My iPhone uses it as its system resolver, so ad blocking, DNSSEC validation, and recursive resolution follow my phone through the day. No cloud, no account, no companion app — a self-signed cert, a `.mobileconfig` profile, and a QR code in the terminal.
RFC 7858 is ten pages. The hard parts weren't in the RFC. They were in cross-protocol confusion defenses, a crypto-provider init gotcha that only triggered in one specific config combination, and a certificate SAN bug iOS was happy to accept and `kdig` immediately rejected. This post is about those parts.
## Why DoT when you already have DoH?
Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same privacy guarantees, different wrapper.
The answer to "why both" is that **phones ask for DoT by name.** iOS system DNS configures it with two fields (IP + server name) instead of a URL template. Android 9+ "Private DNS" speaks DoT natively. Linux stubs default to DoT. I wanted my phone on Numa without installing anything on the phone itself, and DoT is the protocol iOS and Android already speak for that.
## The wire format is refreshingly small
RFC 7858 is one sentence of wire protocol: *DNS-over-TCP (RFC 1035 §4.2.2) with TLS in front, on port 853.* DNS-over-TCP has existed since 1987 — a 2-byte length prefix followed by the DNS message. DoT is that, wrapped in a TLS session. The entire framing code is seven lines:
```rust
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> io::Result<()>
where S: AsyncWriteExt + Unpin {
let mut out = Vec::with_capacity(2 + msg.len());
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
out.extend_from_slice(msg);
stream.write_all(&out).await?;
stream.flush().await
}
```
Reads are symmetric: `read_exact` two bytes, convert to `u16`, `read_exact` that many bytes. No HTTP headers, no chunked encoding, no framing layer.
## Persistent connections
A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients SHOULD reuse the TCP connection for multiple queries, and every real DoT client does: iOS, Android, systemd, stubby. A single connection often carries hundreds of queries.
<img src="../dot-handshake.svg" alt="Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP).">
The amortization point is the whole game. If you only ever do one query per connection, DoT is roughly 3× slower than UDP and you should not use it. If you reuse the same TLS session for a browsing session's worth of queries, the handshake is paid once and every subsequent query is effectively free.
The server is a loop that reads a length-prefixed message, resolves it, writes the response framed the same way, waits for the next one. Three timeouts keep it honest:
- **Handshake timeout (10s)** — a slowloris that opens TCP but never sends a ClientHello can't pin a worker.
- **Idle timeout (30s)** — a connected client with nothing to say gets dropped.
- **Write timeout (10s)** — a stalled reader can't hold a response buffer indefinitely.
A semaphore caps concurrent connections at 512 so a burst of handshakes can't exhaust the tokio runtime.
## ALPN, the cross-protocol defense that matters
If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 client from hitting 853 and getting confused replies? [Cross-protocol attacks](https://alpaca-attack.com/) exist and have had real CVEs. The defense is ALPN: during the TLS handshake the client advertises protocols, the server picks one it supports or fails. A DoT server advertises `"dot"`; a client offering only `"h2"` gets a `no_application_protocol` fatal alert before any frames are exchanged.
rustls enforces this by default when you set `alpn_protocols`:
```rust
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"dot".to_vec()];
```
"The library enforces it by default" has a latent risk: a future rustls upgrade could change the default, and the defense would quietly evaporate. I wrote a test that pins the behavior so any regression in a dependency update fails loudly:
```rust
#[tokio::test]
async fn dot_rejects_non_dot_alpn() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]);
let connector = tokio_rustls::TlsConnector::from(client_config);
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let result = connector
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
.await;
assert!(result.is_err(),
"DoT server must reject ALPN that doesn't include \"dot\"");
}
```
When you're leaning on a library's default for a security-critical invariant, the test is the contract.
## Two bugs that hid for days
Both were fixed before v0.10 shipped. Both stayed hidden because my initial tests used *permissive* clients.
### The rustls crypto provider panic
rustls 0.23 requires a `CryptoProvider` installed before you can build a `ServerConfig`. Numa's HTTPS proxy calls `install_default` as a side effect when it builds its own config, so DoT "just worked" for users who enabled both — the proxy had already initialized the provider before DoT's first handshake.
Then I added support for user-provided DoT certificates. Someone running DoT with their own Let's Encrypt cert, with the HTTPS proxy disabled, would hit:
```
thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14:
no process-level CryptoProvider available -- call
CryptoProvider::install_default() before this point
```
The panic happened on the first client connection, not at startup. While writing the integration suite for "DoT with BYO cert, proxy disabled" — the one combination nobody had ever actually exercised — the first run panicked. Fix is two lines: call `install_default` inside `load_tls_config` so DoT can stand alone. If a side effect initializes something and you have a path that skips that side effect, you have a bug waiting for a specific deployment.
### The SAN bug iOS was happy to accept
Numa's self-signed DoT cert is generated on first run from a local CA alongside the data directory. It needs to match whatever `ServerName` the client sends as SNI. For the HTTPS proxy, that's the wildcard domain pattern `*.numa` (matching `frontend.numa`, `api.numa`, etc.). I initially reused the same SAN list for DoT: a wildcard `*.numa` and nothing else.
On an iPhone this worked perfectly. Full browsing session, persistent connections in the log, ad blocking active. I was about to merge when I ran one last smoke test with `kdig` (GnuTLS-backed, from [Knot DNS](https://www.knot-dns.cz/)):
```
$ kdig @192.168.1.16 -p 853 +tls \
+tls-ca=/usr/local/var/numa/ca.pem \
+tls-hostname=numa.numa example.com A
;; TLS, handshake failed (Error in the certificate.)
```
Huh.
[RFC 6125 §6.4.3](https://datatracker.ietf.org/doc/html/rfc6125#section-6.4.3): a wildcard in a certificate's DNS-ID matches exactly one label. `*.numa` matches `frontend.numa`, but not `numa.numa`, because the wildcard wants at least one label to substitute and strict clients reject wildcards in the leftmost label under single-label TLDs as ambiguous.
iOS's TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and most non-Apple validators don't. The fix is five lines — add an explicit `numa.numa` SAN alongside the wildcard. But the lesson is the one that stuck: I wrote a commit message saying "fix an iOS bug" and had to rewrite it, because iOS was fine. The real bug was that every GnuTLS/NSS-based client on the planet would have rejected the cert, and I only found it by running one more test with a stricter tool.
> Test with the strict client. The permissive client hides your bugs.
## Getting your phone onto it
A DoT server is useless without a way to point a phone at it. iOS won't let you type an IP and a server name into Settings directly — you install a `.mobileconfig` profile that bundles the CA as a trust anchor and the DNS settings in a single payload.
Numa ships a subcommand that builds one on the fly and serves it over a QR code in the terminal:
```
$ numa setup-phone
Numa Phone Setup
Profile URL: http://192.168.1.16:8765/mobileconfig
█▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█
█ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █
...
On your iPhone:
1. Open Camera, point at the QR code, tap the yellow banner
2. Allow the download when Safari asks
3. Settings → "Profile Downloaded" → Install
4. Settings → General → About → Certificate Trust Settings
Toggle ON "Numa Local CA" — required for DoT to work
```
Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct.
I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work.
## What I learned
**RFC-level small, API-level hard.** RFC 7858 is ten pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, connection caps, handshake vs idle vs write deadlines, backoff on accept errors — isn't in the RFC. Miss any of it and you leak a DoS vector or a protocol confusion hole.
**Your test matrix is your security matrix.** Both bugs in this post were hidden by lenient clients. In both cases the strict client — kdig, or a specific config combination — surfaced the bug instantly. Pick test tools for strictness, not convenience. The moment you find yourself thinking "but iOS accepts it," stop and run kdig.
**Don't initialize global state via side effects.** "Module A installs a global, module B silently depends on it, disabling A breaks B" is a bug pattern that keeps coming back. Fix: have module B initialize its dependency explicitly, even if it means calling an idempotent `install_default` twice. The dependency graph should be local and obvious.
## What's next
- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa.
- **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively.
- **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale.
The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DoT listener is in [`src/dot.rs`](https://github.com/razvandimescu/numa/blob/main/src/dot.rs) and the phone onboarding flow is in [`src/setup_phone.rs`](https://github.com/razvandimescu/numa/blob/main/src/setup_phone.rs) and [`src/mobileconfig.rs`](https://github.com/razvandimescu/numa/blob/main/src/mobileconfig.rs). MIT license.

View File

@@ -297,5 +297,7 @@ $body$
<a href="/blog/">Blog</a>
</footer>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>

129
site/blog/dot-handshake.svg Normal file
View File

@@ -0,0 +1,129 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 360" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
<defs>
<marker id="arr-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c0623a"/>
</marker>
<marker id="arr-dim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#a39888"/>
</marker>
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
</filter>
</defs>
<!-- Background -->
<rect width="720" height="360" rx="8" fill="#faf7f2"/>
<!-- Title -->
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">UDP vs DoT — one lookup, three scenarios</text>
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Time flows downward. Amber = DNS work. Gray = TCP/TLS handshake overhead.</text>
<!-- ==================== Column 1: Plain UDP ==================== -->
<g transform="translate(20, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">Plain UDP DNS</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 53 · CLEARTEXT</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- query -->
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
<!-- response -->
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
<!-- Total cost badge -->
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
</g>
<!-- ==================== Column 2: DoT cold ==================== -->
<g transform="translate(270, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — first query</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · NEW CONNECTION</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- === RTT 1: TCP handshake === -->
<!-- SYN -->
<line x1="37" y1="145" x2="143" y2="153" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- SYN-ACK -->
<line x1="143" y1="163" x2="37" y2="171" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- ACK -->
<line x1="37" y1="181" x2="143" y2="189" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- Label + RTT marker -->
<text x="168" y="170" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1 rtt</text>
<text x="90" y="143" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TCP handshake</text>
<!-- === RTT 2: TLS 1.3 handshake === -->
<!-- ClientHello -->
<line x1="37" y1="208" x2="143" y2="216" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- ServerHello + Cert + Finished -->
<line x1="143" y1="226" x2="37" y2="234" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- Label + RTT marker -->
<text x="168" y="222" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">2 rtt</text>
<text x="90" y="205" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TLS 1.3 handshake</text>
<!-- === RTT 3: DNS exchange === -->
<!-- query (piggybacked on ClientFinished) -->
<line x1="37" y1="253" x2="143" y2="261" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<!-- response -->
<line x1="143" y1="271" x2="37" y2="279" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<!-- Label + RTT marker -->
<text x="168" y="267" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">3 rtt</text>
<text x="90" y="250" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query + response</text>
<!-- Total cost badge -->
<rect x="20" y="295" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="311" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="323" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">3 × RTT</text>
</g>
<!-- ==================== Column 3: DoT reused ==================== -->
<g transform="translate(520, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — reused session</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · PERSISTENT TCP/TLS</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- query -->
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
<!-- response -->
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
<!-- Total cost badge -->
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
<!-- Tiny caption -->
<text x="90" y="280" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">(handshake amortized</text>
<text x="90" y="292" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">across the session)</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,92 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 330" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
<defs>
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
</filter>
<!-- Diagonal hatch for "wasted" UDP timeout regions. Darker warm gray
base + slightly darker diagonal stripes at 45°. The stripe pattern
is the Gantt convention for "dead/blocked time" — it reads as
"this time was thrown away" without needing the legend. -->
<pattern id="wasted-hatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(-45)">
<rect width="7" height="7" fill="#8b7f6f"/>
<line x1="0" y1="0" x2="0" y2="7" stroke="#3d3427" stroke-width="1.6" opacity="0.38"/>
</pattern>
</defs>
<!-- Background -->
<rect width="720" height="330" rx="8" fill="#faf7f2"/>
<!-- Title -->
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">TCP fallback with UDP auto-disable</text>
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Latency profile on an ISP that blocks outbound UDP:53</text>
<!-- Legend -->
<g transform="translate(160, 70)">
<rect width="14" height="12" rx="2" fill="url(#wasted-hatch)"/>
<text x="22" y="10" font-size="11" fill="#6b5e4f">UDP timeout — 800 ms wasted</text>
<rect x="220" width="14" height="12" rx="2" fill="#c0623a"/>
<text x="242" y="10" font-size="11" fill="#6b5e4f">TCP — successful exchange</text>
</g>
<!-- Time axis -->
<!-- bar area: x=90 to x=570 (480px), representing 0-1200ms, scale 0.4 px/ms -->
<line x1="90" y1="108" x2="570" y2="108" stroke="#d4cbba" stroke-width="1"/>
<!-- tick marks -->
<line x1="90" y1="106" x2="90" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="210" y1="106" x2="210" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="330" y1="106" x2="330" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="410" y1="106" x2="410" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="530" y1="106" x2="530" y2="112" stroke="#a39888" stroke-width="1"/>
<!-- tick labels -->
<text x="90" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">0</text>
<text x="210" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">300</text>
<text x="330" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">600</text>
<text x="410" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">800</text>
<text x="530" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1100 ms</text>
<!-- ============ Phase 1: UDP-first (wasted 800ms per query) ============ -->
<!-- Query 1 -->
<text x="82" y="135" text-anchor="end" font-size="11" fill="#6b5e4f">query 1</text>
<rect x="90" y="125" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="125" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="137" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- Query 2 -->
<text x="82" y="159" text-anchor="end" font-size="11" fill="#6b5e4f">query 2</text>
<rect x="90" y="149" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="149" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="161" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- Query 3 -->
<text x="82" y="183" text-anchor="end" font-size="11" fill="#6b5e4f">query 3</text>
<rect x="90" y="173" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="173" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="185" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- State-change divider -->
<line x1="90" y1="206" x2="570" y2="206" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="4 3"/>
<rect x="200" y="198" width="260" height="18" rx="9" fill="#faf7f2" stroke="#6b7c4e" stroke-width="1" filter="url(#shadow)"/>
<text x="330" y="210" text-anchor="middle" font-size="10" fill="#566540" font-weight="500">3 consecutive failures → UDP auto-disabled</text>
<!-- ============ Phase 2: TCP-first (UDP skipped) ============ -->
<!-- Query 4 -->
<text x="82" y="235" text-anchor="end" font-size="11" fill="#6b5e4f">query 4</text>
<rect x="90" y="225" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="220" y="237" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
<!-- Query 5 -->
<text x="82" y="259" text-anchor="end" font-size="11" fill="#6b5e4f">query 5</text>
<rect x="90" y="249" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="220" y="261" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
<!-- Speedup callout -->
<g transform="translate(300, 246)">
<line x1="0" y1="-10" x2="0" y2="22" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="2 2"/>
<text x="10" y="6" font-size="10" fill="#566540" font-style="italic">3.7× faster — no more UDP wait</text>
</g>
<!-- Footer caption -->
<text x="360" y="298" text-anchor="middle" font-size="10" fill="#a39888" font-style="italic">The flag resets on network change (LAN IP delta). Switch back to a clean network and UDP is tried again.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -189,5 +189,7 @@ body::before {
<a href="/">Home</a>
</footer>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>

View File

@@ -1686,5 +1686,7 @@ const observer = new IntersectionObserver((entries) => {
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
</script>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>