Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de1bc2efc | ||
|
|
156b68de87 | ||
|
|
7d6b0ed568 | ||
|
|
7770129589 | ||
|
|
8abcd91f95 | ||
|
|
a96b84fdeb | ||
|
|
23ff3ce455 | ||
|
|
2c20c56421 | ||
|
|
921ed68d54 | ||
|
|
8da03b1b8c |
2
.github/workflows/static.yml
vendored
2
.github/workflows/static.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install pandoc
|
- name: Install pandoc
|
||||||
run: sudo apt-get install -y pandoc
|
uses: pandoc/actions/setup@v1
|
||||||
- name: Generate blog HTML
|
- name: Generate blog HTML
|
||||||
run: make blog
|
run: make blog
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs/
|
docs/
|
||||||
site/blog/posts/
|
site/blog/posts/
|
||||||
|
ios/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1144,7 +1144,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
@@ -30,7 +30,7 @@ tokio-rustls = "0.26"
|
|||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
rustls-pemfile = "2.2.0"
|
rustls-pemfile = "2.2.0"
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.8", features = ["html_reports"] }
|
criterion = { version = "0.8", features = ["html_reports"] }
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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.
|
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
|
## LAN Discovery
|
||||||
|
|
||||||
Run Numa on multiple machines. They find each other automatically via mDNS:
|
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
|
## 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: 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)
|
- [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
|
- [Configuration reference](numa.toml) — all options documented inline
|
||||||
@@ -130,6 +139,9 @@ 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] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
||||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
||||||
- [x] SRTT-based nameserver selection
|
- [x] SRTT-based nameserver selection
|
||||||
|
- [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool
|
||||||
|
- [x] Cache warming — proactive resolution for configured domains
|
||||||
|
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
|
||||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||||
- [ ] Global `.numa` names — DHT-backed, no registrar
|
- [ ] Global `.numa` names — DHT-backed, no registrar
|
||||||
|
|
||||||
|
|||||||
@@ -163,12 +163,12 @@ The fix has three parts:
|
|||||||
|
|
||||||
**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit.
|
**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit.
|
||||||
|
|
||||||
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring).
|
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. The flag resets when the network changes (detected via LAN IP monitoring).
|
||||||
|
|
||||||
|
<img src="../hostile-network.svg" alt="Latency profile on a hostile network: queries 1-3 each spend 800ms waiting for a UDP timeout before retrying over TCP, taking 1,100ms total per query. After 3 consecutive failures the UDP auto-disable flag flips, and queries 4+ go TCP-first and complete in 300ms each — 3.7× faster.">
|
||||||
|
|
||||||
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
|
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
|
||||||
|
|
||||||
The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts.
|
|
||||||
|
|
||||||
I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works.
|
I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works.
|
||||||
|
|
||||||
## What I learned
|
## What I learned
|
||||||
|
|||||||
176
blog/dot-from-scratch.md
Normal file
176
blog/dot-from-scratch.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
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.10:8765/mobileconfig
|
||||||
|
|
||||||
|
██████████████████████████████
|
||||||
|
██ ██
|
||||||
|
██ [QR code rendered in ██
|
||||||
|
██ your terminal] ██
|
||||||
|
██ ██
|
||||||
|
██████████████████████████████
|
||||||
|
|
||||||
|
On your iPhone:
|
||||||
|
1. Open Camera, point at the QR code, tap the yellow banner
|
||||||
|
2. Allow the download when Safari asks
|
||||||
|
3. Open Settings — tap "Profile Downloaded" near the top
|
||||||
|
(or: Settings → General → VPN & Device Management → Numa DNS)
|
||||||
|
4. Tap Install (top right), enter passcode, Install again
|
||||||
|
5. Settings → General → About → Certificate Trust Settings
|
||||||
|
Toggle ON "Numa Local CA" — required for DoT to work
|
||||||
|
```
|
||||||
|
|
||||||
|
The same QR is available in the dashboard — click "Phone Setup" in the header and the popover renders an SVG QR code pointing at the mobileconfig URL. On mobile viewports it shows a direct download link instead.
|
||||||
|
|
||||||
|
<img src="../phone-setup-dashboard.png" alt="Numa dashboard with Phone Setup popover showing QR code and install instructions">
|
||||||
|
|
||||||
|
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.
|
||||||
10
numa.toml
10
numa.toml
@@ -12,10 +12,11 @@ api_port = 5380
|
|||||||
# [upstream]
|
# [upstream]
|
||||||
# mode = "forward" # "forward" (default) — relay to upstream
|
# mode = "forward" # "forward" (default) — relay to upstream
|
||||||
# # "recursive" — resolve from root hints (no address needed)
|
# # "recursive" — resolve from root hints (no address needed)
|
||||||
|
# address = "9.9.9.9" # single upstream (plain UDP)
|
||||||
|
# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest
|
||||||
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
|
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
|
||||||
# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH
|
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
|
||||||
# address = "9.9.9.9" # plain UDP
|
# port = 53 # default port for addresses without :port
|
||||||
# port = 53 # only for forward mode, plain UDP
|
|
||||||
# timeout_ms = 3000
|
# timeout_ms = 3000
|
||||||
# root_hints = [ # only used in recursive mode
|
# root_hints = [ # only used in recursive mode
|
||||||
# "198.41.0.4", # a.root-servers.net (Verisign)
|
# "198.41.0.4", # a.root-servers.net (Verisign)
|
||||||
@@ -54,6 +55,7 @@ api_port = 5380
|
|||||||
max_entries = 10000
|
max_entries = 10000
|
||||||
min_ttl = 60
|
min_ttl = 60
|
||||||
max_ttl = 86400
|
max_ttl = 86400
|
||||||
|
# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -91,7 +93,7 @@ tld = "numa"
|
|||||||
|
|
||||||
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
||||||
# [dot]
|
# [dot]
|
||||||
# enabled = false # opt-in: accept DoT queries
|
# enabled = true # on by default; set false to disable
|
||||||
# port = 853 # standard DoT port
|
# port = 853 # standard DoT port
|
||||||
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
|
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
|
||||||
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)
|
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)
|
||||||
|
|||||||
@@ -7,18 +7,19 @@
|
|||||||
# The script:
|
# The script:
|
||||||
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
|
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
|
||||||
# 2. Generates DNS traffic (forward, cache hit, blocked)
|
# 2. Generates DNS traffic (forward, cache hit, blocked)
|
||||||
# 3. Types "peekm" / "6419" into the Local Services form on camera
|
# 3. Opens Phone Setup QR popover
|
||||||
# 4. Shows LAN accessibility badge ("local only" / "LAN")
|
# 4. Types "peekm" / "6419" into the Local Services form on camera
|
||||||
# 5. Checks a blocked domain
|
# 5. Shows LAN accessibility badge ("local only" / "LAN")
|
||||||
# 6. Opens peekm.numa to show the proxy working
|
# 6. Checks a blocked domain
|
||||||
# 7. Records via ffmpeg and converts to optimized GIF
|
# 7. Opens peekm.numa to show the proxy working
|
||||||
|
# 8. Records via ffmpeg and converts to optimized GIF
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --------------- Configuration ---------------
|
# --------------- Configuration ---------------
|
||||||
OUTPUT="${1:-assets/hero-demo.gif}"
|
OUTPUT="${1:-assets/hero-demo.gif}"
|
||||||
PORT=5380
|
PORT=5380
|
||||||
RECORD_SECONDS=20
|
RECORD_SECONDS=24
|
||||||
VIEWPORT_W=1800
|
VIEWPORT_W=1800
|
||||||
VIEWPORT_H=1100
|
VIEWPORT_H=1100
|
||||||
FPS=12
|
FPS=12
|
||||||
@@ -230,8 +231,16 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1
|
|||||||
dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1
|
dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# --------------- Scene 2: Add peekm service via UI (3-7s) ---------------
|
# --------------- Scene 2: Phone Setup popover (3-7s) ---------------
|
||||||
log "Scene 2: Adding peekm.numa service..."
|
log "Scene 2: Phone Setup QR popover..."
|
||||||
|
run_js "document.querySelector('#phoneSetup button').click();"
|
||||||
|
sleep 3
|
||||||
|
# Dismiss popover
|
||||||
|
run_js "document.getElementById('phoneSetupPopover').style.display = 'none';"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# --------------- Scene 3: Add peekm service via UI (7-11s) ---------------
|
||||||
|
log "Scene 3: Adding peekm.numa service..."
|
||||||
|
|
||||||
# Services panel is now first — scroll to it
|
# Services panel is now first — scroll to it
|
||||||
run_js "
|
run_js "
|
||||||
@@ -249,18 +258,18 @@ sleep 0.3
|
|||||||
run_js "document.querySelector('#serviceForm .btn-add').click();"
|
run_js "document.querySelector('#serviceForm .btn-add').click();"
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# --------------- Scene 3: Open peekm.numa (7-11s) ---------------
|
# --------------- Scene 4: Open peekm.numa (11-15s) ---------------
|
||||||
log "Scene 3: Opening peekm.numa in browser..."
|
log "Scene 4: Opening peekm.numa in browser..."
|
||||||
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
|
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
|
||||||
sleep 4
|
sleep 4
|
||||||
|
|
||||||
# --------------- Scene 4: Back to dashboard (11-14s) ---------------
|
# --------------- Scene 5: Back to dashboard (15-18s) ---------------
|
||||||
log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..."
|
log "Scene 5: Back to dashboard — LAN badges + LOCAL queries visible..."
|
||||||
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true
|
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# --------------- Scene 5: Check Domain blocker (14-17s) ---------------
|
# --------------- Scene 6: Check Domain blocker (18-21s) ---------------
|
||||||
log "Scene 5: Check Domain — blocked tracker..."
|
log "Scene 6: Check Domain — blocked tracker..."
|
||||||
# Scroll down to blocking panel
|
# Scroll down to blocking panel
|
||||||
run_js "
|
run_js "
|
||||||
var blockPanel = document.getElementById('blockingPanel');
|
var blockPanel = document.getElementById('blockingPanel');
|
||||||
@@ -273,8 +282,8 @@ sleep 0.3
|
|||||||
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
|
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# --------------- Scene 6: Terminal-style dig overlay (17-20s) ---------------
|
# --------------- Scene 7: Terminal-style dig overlay (21-24s) ---------------
|
||||||
log "Scene 6: dig proof overlay..."
|
log "Scene 7: dig proof overlay..."
|
||||||
DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1)
|
DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1)
|
||||||
run_js "
|
run_js "
|
||||||
var overlay = document.createElement('div');
|
var overlay = document.createElement('div');
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ body::before {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-transform: none;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||||
|
|||||||
129
site/blog/dot-handshake.svg
Normal file
129
site/blog/dot-handshake.svg
Normal 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 |
92
site/blog/hostile-network.svg
Normal file
92
site/blog/hostile-network.svg
Normal 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 |
@@ -67,6 +67,7 @@ body::before {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-transform: none;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||||
@@ -167,6 +168,13 @@ body::before {
|
|||||||
<main class="blog-index">
|
<main class="blog-index">
|
||||||
<h1>Blog</h1>
|
<h1>Blog</h1>
|
||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
|
<li>
|
||||||
|
<a href="/blog/posts/dot-from-scratch.html">
|
||||||
|
<div class="post-title">DNS-over-TLS from Scratch in Rust</div>
|
||||||
|
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.</div>
|
||||||
|
<div class="post-date">April 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/blog/posts/dnssec-from-scratch.html">
|
<a href="/blog/posts/dnssec-from-scratch.html">
|
||||||
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
||||||
|
|||||||
BIN
site/blog/phone-setup-dashboard.png
Normal file
BIN
site/blog/phone-setup-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
@@ -554,6 +554,20 @@ body {
|
|||||||
<div class="tagline">DNS that governs itself</div>
|
<div class="tagline">DNS that governs itself</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:1.2rem;">
|
<div style="display:flex;align-items:center;gap:1.2rem;">
|
||||||
|
<div id="phoneSetup" style="position:relative;display:none;">
|
||||||
|
<button class="btn" onclick="togglePhoneSetup()" style="background:var(--bg-surface);color:var(--text-secondary);font-family:var(--font-mono);font-size:0.7rem;padding:0.35rem 0.6rem;border:1px solid var(--border);" title="Set up phone">Phone Setup</button>
|
||||||
|
<div id="phoneSetupPopover" style="display:none;position:absolute;top:calc(100% + 8px);right:0;z-index:100;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.2rem;width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.08);">
|
||||||
|
<div style="font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-secondary);margin-bottom:0.8rem;">Phone Setup</div>
|
||||||
|
<div id="qrContainer" style="display:flex;justify-content:center;margin-bottom:0.8rem;"></div>
|
||||||
|
<div id="phoneSetupLink" style="display:none;text-align:center;margin-bottom:0.8rem;"></div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.62rem;color:var(--text-dim);line-height:1.6;">
|
||||||
|
1. Scan QR → allow download<br>
|
||||||
|
2. Settings → Profile Downloaded → Install<br>
|
||||||
|
3. Settings → General → About →<br>
|
||||||
|
Certificate Trust Settings → toggle ON
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
|
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
|
||||||
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
|
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
|
||||||
<div class="status-badge">
|
<div class="status-badge">
|
||||||
@@ -788,6 +802,34 @@ function formatTime(epoch) {
|
|||||||
return d.toLocaleTimeString([], { hour12: false });
|
return d.toLocaleTimeString([], { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mobilePort = 8765;
|
||||||
|
function togglePhoneSetup() {
|
||||||
|
const pop = document.getElementById('phoneSetupPopover');
|
||||||
|
const isOpen = pop.style.display !== 'none';
|
||||||
|
pop.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen) {
|
||||||
|
if (window.innerWidth <= 700) {
|
||||||
|
document.getElementById('qrContainer').style.display = 'none';
|
||||||
|
const linkEl = document.getElementById('phoneSetupLink');
|
||||||
|
const host = window.location.hostname;
|
||||||
|
linkEl.style.display = 'block';
|
||||||
|
linkEl.innerHTML = `<a href="http://${host}:${mobilePort}/mobileconfig" style="display:inline-block;padding:0.5rem 1rem;background:var(--amber);color:white;border-radius:6px;text-decoration:none;font-family:var(--font-mono);font-size:0.75rem;">Install Profile</a>`;
|
||||||
|
} else {
|
||||||
|
fetch(API + '/qr').then(r => r.text()).then(svg => {
|
||||||
|
document.getElementById('qrContainer').innerHTML = svg;
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('qrContainer').innerHTML = '<div class="empty-state">Could not load QR</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const setup = document.getElementById('phoneSetup');
|
||||||
|
if (setup && !setup.contains(e.target)) {
|
||||||
|
document.getElementById('phoneSetupPopover').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function shortSrc(addr) {
|
function shortSrc(addr) {
|
||||||
if (!addr) return '';
|
if (!addr) return '';
|
||||||
const ip = addr.replace(/:\d+$/, '');
|
const ip = addr.replace(/:\d+$/, '');
|
||||||
@@ -1058,6 +1100,14 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phoneSetupEl = document.getElementById('phoneSetup');
|
||||||
|
if (stats.mobile && stats.mobile.enabled) {
|
||||||
|
phoneSetupEl.style.display = '';
|
||||||
|
mobilePort = stats.mobile.port;
|
||||||
|
} else {
|
||||||
|
phoneSetupEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('overrideCount').textContent = stats.overrides.active;
|
document.getElementById('overrideCount').textContent = stats.overrides.active;
|
||||||
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
||||||
const bl = stats.blocking;
|
const bl = stats.blocking;
|
||||||
|
|||||||
@@ -188,11 +188,50 @@ p.lead {
|
|||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
TOP NAV
|
||||||
|
=========================== */
|
||||||
|
.site-nav {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.site-nav a:hover { color: var(--amber); }
|
||||||
|
|
||||||
|
.site-nav .wordmark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.site-nav .wordmark:hover { color: var(--amber); }
|
||||||
|
|
||||||
|
.site-nav .sep {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================
|
/* ===========================
|
||||||
HERO
|
HERO
|
||||||
=========================== */
|
=========================== */
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
min-height: calc(100vh - 5rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1158,6 +1197,9 @@ footer .closing {
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
section { padding: 4rem 0; }
|
section { padding: 4rem 0; }
|
||||||
.container { padding: 0 1.25rem; }
|
.container { padding: 0 1.25rem; }
|
||||||
|
.site-nav { padding: 1rem 1.25rem; gap: 1rem; }
|
||||||
|
.site-nav .wordmark { font-size: 1.2rem; }
|
||||||
|
.hero { min-height: calc(100vh - 4rem); }
|
||||||
.network-grid { grid-template-columns: 1fr; }
|
.network-grid { grid-template-columns: 1fr; }
|
||||||
.pipeline { flex-direction: column; align-items: stretch; gap: 0; }
|
.pipeline { flex-direction: column; align-items: stretch; gap: 0; }
|
||||||
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
|
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
|
||||||
@@ -1171,6 +1213,14 @@ footer .closing {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="wordmark">Numa</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<a href="/blog/">Blog</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- ==================== HERO ==================== -->
|
<!-- ==================== HERO ==================== -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="roman-bricks" aria-hidden="true"></div>
|
<div class="roman-bricks" aria-hidden="true"></div>
|
||||||
@@ -1243,6 +1293,8 @@ footer .closing {
|
|||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</li>
|
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</li>
|
||||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||||
|
<li>DNS-over-TLS listener — encrypted DNS for phones and strict clients (RFC 7858 with ALPN defense)</li>
|
||||||
|
<li>Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block port 53</li>
|
||||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1261,7 +1313,7 @@ footer .closing {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="layer-card reveal reveal-delay-3">
|
<div class="layer-card reveal reveal-delay-3">
|
||||||
<div class="layer-badge">Coming Next</div>
|
<div class="layer-badge">The Vision</div>
|
||||||
<h3>Self-Sovereign DNS</h3>
|
<h3>Self-Sovereign DNS</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
||||||
@@ -1342,6 +1394,14 @@ footer .closing {
|
|||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
<td class="check">Root hints + full DNSSEC</td>
|
<td class="check">Root hints + full DNSSEC</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>DNSSEC validation</td>
|
||||||
|
<td class="muted">Passthrough</td>
|
||||||
|
<td class="muted">Cloud only</td>
|
||||||
|
<td class="muted">Cloud only</td>
|
||||||
|
<td class="muted">Passthrough</td>
|
||||||
|
<td class="check">Full chain-of-trust</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Ad & tracker blocking</td>
|
<td>Ad & tracker blocking</td>
|
||||||
<td class="check">Yes</td>
|
<td class="check">Yes</td>
|
||||||
@@ -1398,6 +1458,14 @@ footer .closing {
|
|||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
<td class="check">Built in (HTTP/2 + rustls)</td>
|
<td class="check">Built in (HTTP/2 + rustls)</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>DNS-over-TLS listener</td>
|
||||||
|
<td class="cross">No</td>
|
||||||
|
<td class="muted">Cloud only</td>
|
||||||
|
<td class="muted">Cloud only</td>
|
||||||
|
<td class="check">Yes (cert required)</td>
|
||||||
|
<td class="check">Self-signed or BYO</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Conditional forwarding</td>
|
<td>Conditional forwarding</td>
|
||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
@@ -1567,11 +1635,14 @@ footer .closing {
|
|||||||
<dt>Resolution Modes</dt>
|
<dt>Resolution Modes</dt>
|
||||||
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
|
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
|
||||||
|
|
||||||
|
<dt>Listeners</dt>
|
||||||
|
<dd>UDP:53 + TCP:53 (plain DNS), DoT:853 (RFC 7858 + ALPN), HTTP proxy :80 / HTTPS proxy :443, dashboard :5380</dd>
|
||||||
|
|
||||||
<dt>DNSSEC</dt>
|
<dt>DNSSEC</dt>
|
||||||
<dd>Chain-of-trust via ring — RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd>
|
<dd>Chain-of-trust via ring — RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd>
|
||||||
|
|
||||||
<dt>Dependencies</dt>
|
<dt>Dependencies</dt>
|
||||||
<dd>19 runtime crates — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
|
<dd>A focused set — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls + tokio-rustls (TLS/DoT), socket2 (multicast), serde. No transitive DNS library.</dd>
|
||||||
|
|
||||||
<dt>Packet Format</dt>
|
<dt>Packet Format</dt>
|
||||||
<dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd>
|
<dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd>
|
||||||
@@ -1586,7 +1657,7 @@ footer .closing {
|
|||||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span>
|
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span>
|
||||||
|
|
||||||
<span class="comment"># Run</span>
|
<span class="comment"># Run</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53, :80, :5380</span>
|
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind :53, :80, :443, :853, :5380</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span>
|
<span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
|
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
|
||||||
@@ -1639,16 +1710,28 @@ footer .closing {
|
|||||||
<span class="phase">Phase 7</span>
|
<span class="phase">Phase 7</span>
|
||||||
<span class="phase-desc">DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
|
<span class="phase-desc">DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 8</span>
|
<span class="phase">Phase 8</span>
|
||||||
|
<span class="phase-desc">Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block :53, RFC 7816 query minimization</span>
|
||||||
|
</div>
|
||||||
|
<div class="roadmap-item done">
|
||||||
|
<span class="phase">Phase 9</span>
|
||||||
|
<span class="phase-desc">Windows support — cross-platform install/uninstall, <code>netsh</code> DNS config, service integration</span>
|
||||||
|
</div>
|
||||||
|
<div class="roadmap-item done">
|
||||||
|
<span class="phase">Phase 10</span>
|
||||||
|
<span class="phase-desc">DNS-over-TLS listener (RFC 7858) — ALPN enforcement, persistent connections, self-signed or BYO cert</span>
|
||||||
|
</div>
|
||||||
|
<div class="roadmap-item phase-teal">
|
||||||
|
<span class="phase">Phase 11</span>
|
||||||
<span class="phase-desc">pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
<span class="phase-desc">pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item phase-teal">
|
||||||
<span class="phase">Phase 9</span>
|
<span class="phase">Phase 12</span>
|
||||||
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item phase-teal">
|
||||||
<span class="phase">Phase 10</span>
|
<span class="phase">Phase 13</span>
|
||||||
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</span>
|
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
src/api.rs
52
src/api.rs
@@ -57,6 +57,7 @@ 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("/qr", get(serve_qr))
|
||||||
.route("/fonts/fonts.css", get(serve_fonts_css))
|
.route("/fonts/fonts.css", get(serve_fonts_css))
|
||||||
.route(
|
.route(
|
||||||
"/fonts/dm-sans-latin.woff2",
|
"/fonts/dm-sans-latin.woff2",
|
||||||
@@ -170,9 +171,16 @@ struct StatsResponse {
|
|||||||
overrides: OverrideStats,
|
overrides: OverrideStats,
|
||||||
blocking: BlockingStatsResponse,
|
blocking: BlockingStatsResponse,
|
||||||
lan: LanStatsResponse,
|
lan: LanStatsResponse,
|
||||||
|
mobile: MobileStatsResponse,
|
||||||
memory: MemoryStats,
|
memory: MemoryStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MobileStatsResponse {
|
||||||
|
enabled: bool,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct LanStatsResponse {
|
struct LanStatsResponse {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -403,9 +411,12 @@ async fn diagnose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check upstream (async, no locks held)
|
// Check upstream (async, no locks held)
|
||||||
let upstream = ctx.upstream.lock().unwrap().clone();
|
let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned();
|
||||||
let (upstream_matched, upstream_detail) =
|
let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream {
|
||||||
forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await;
|
forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await
|
||||||
|
} else {
|
||||||
|
(false, "no upstream configured".to_string())
|
||||||
|
};
|
||||||
steps.push(DiagnoseStep {
|
steps.push(DiagnoseStep {
|
||||||
source: "upstream".to_string(),
|
source: "upstream".to_string(),
|
||||||
matched: upstream_matched,
|
matched: upstream_matched,
|
||||||
@@ -512,7 +523,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
|||||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||||
"recursive (root hints)".to_string()
|
"recursive (root hints)".to_string()
|
||||||
} else {
|
} else {
|
||||||
ctx.upstream.lock().unwrap().to_string()
|
ctx.upstream_pool.lock().unwrap().label()
|
||||||
};
|
};
|
||||||
|
|
||||||
Json(StatsResponse {
|
Json(StatsResponse {
|
||||||
@@ -551,6 +562,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
|||||||
enabled: ctx.lan_enabled,
|
enabled: ctx.lan_enabled,
|
||||||
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
||||||
},
|
},
|
||||||
|
mobile: MobileStatsResponse {
|
||||||
|
enabled: ctx.mobile_enabled,
|
||||||
|
port: ctx.mobile_port,
|
||||||
|
},
|
||||||
memory: MemoryStats {
|
memory: MemoryStats {
|
||||||
cache_bytes,
|
cache_bytes,
|
||||||
blocklist_bytes,
|
blocklist_bytes,
|
||||||
@@ -931,6 +946,28 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
if !ctx.mobile_enabled {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
let lan_ip = *ctx.lan_ip.lock().unwrap();
|
||||||
|
let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
|
||||||
|
let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let svg = code
|
||||||
|
.render::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(180, 180)
|
||||||
|
.dark_color(qrcode::render::svg::Color("#2c2418"))
|
||||||
|
.light_color(qrcode::render::svg::Color("#faf7f2"))
|
||||||
|
.build();
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||||
|
(header::CACHE_CONTROL, "no-store"),
|
||||||
|
],
|
||||||
|
svg,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn serve_fonts_css() -> impl IntoResponse {
|
async fn serve_fonts_css() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
@@ -982,8 +1019,11 @@ mod tests {
|
|||||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||||
forwarding_rules: Vec::new(),
|
forwarding_rules: Vec::new(),
|
||||||
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||||
|
vec![crate::forward::Upstream::Udp(
|
||||||
"127.0.0.1:53".parse().unwrap(),
|
"127.0.0.1:53".parse().unwrap(),
|
||||||
|
)],
|
||||||
|
vec![],
|
||||||
)),
|
)),
|
||||||
upstream_auto: false,
|
upstream_auto: false,
|
||||||
upstream_port: 53,
|
upstream_port: 53,
|
||||||
@@ -1005,6 +1045,8 @@ mod tests {
|
|||||||
dnssec_strict: false,
|
dnssec_strict: false,
|
||||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||||
ca_pem: None,
|
ca_pem: None,
|
||||||
|
mobile_enabled: false,
|
||||||
|
mobile_port: 8765,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
173
src/blocklist.rs
173
src/blocklist.rs
@@ -81,66 +81,70 @@ impl BlocklistStore {
|
|||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(until) = self.paused_until {
|
if let Some(until) = self.paused_until {
|
||||||
if Instant::now() < until {
|
if Instant::now() < until {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let domain = Self::normalize(domain);
|
||||||
if self.allowlist.contains(domain) {
|
if Self::find_in_set(&domain, &self.allowlist).is_some() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
Self::find_in_set(&domain, &self.domains).is_some()
|
||||||
if self.domains.contains(domain) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk up: ads.tracker.example.com → tracker.example.com → example.com
|
|
||||||
let mut d = domain;
|
|
||||||
while let Some(dot) = d.find('.') {
|
|
||||||
d = &d[dot + 1..];
|
|
||||||
if self.allowlist.contains(d) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.domains.contains(d) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a domain is blocked and return the reason.
|
|
||||||
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
||||||
let domain = domain.to_lowercase();
|
|
||||||
|
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return BlockCheckResult::disabled();
|
return BlockCheckResult::disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.allowlist.contains(&domain) {
|
if let Some(until) = self.paused_until {
|
||||||
return BlockCheckResult::allowed(&domain, "exact match in allowlist");
|
if Instant::now() < until {
|
||||||
|
return BlockCheckResult::disabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.domains.contains(&domain) {
|
let domain = Self::normalize(domain);
|
||||||
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
|
|
||||||
|
if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) {
|
||||||
|
let reason = if matched == domain {
|
||||||
|
"exact match in allowlist"
|
||||||
|
} else {
|
||||||
|
"parent domain in allowlist"
|
||||||
|
};
|
||||||
|
return BlockCheckResult::allowed(matched, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut d = domain.as_str();
|
if let Some(matched) = Self::find_in_set(&domain, &self.domains) {
|
||||||
while let Some(dot) = d.find('.') {
|
let reason = if matched == domain {
|
||||||
d = &d[dot + 1..];
|
"exact match in blocklist"
|
||||||
if self.allowlist.contains(d) {
|
} else {
|
||||||
return BlockCheckResult::allowed(d, "parent domain in allowlist");
|
"parent domain in blocklist"
|
||||||
}
|
};
|
||||||
if self.domains.contains(d) {
|
return BlockCheckResult::blocked(matched, reason);
|
||||||
return BlockCheckResult::blocked(d, "parent domain in blocklist");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockCheckResult::not_blocked()
|
BlockCheckResult::not_blocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize(domain: &str) -> String {
|
||||||
|
domain.to_lowercase().trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_in_set<'a>(domain: &'a str, set: &HashSet<String>) -> Option<&'a str> {
|
||||||
|
if set.contains(domain) {
|
||||||
|
return Some(domain);
|
||||||
|
}
|
||||||
|
let mut d = domain;
|
||||||
|
while let Some(dot) = d.find('.') {
|
||||||
|
d = &d[dot + 1..];
|
||||||
|
if set.contains(d) {
|
||||||
|
return Some(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomically swap in a new domain set. Build the set outside the lock,
|
/// Atomically swap in a new domain set. Build the set outside the lock,
|
||||||
/// then call this to swap — keeps lock hold time sub-microsecond.
|
/// then call this to swap — keeps lock hold time sub-microsecond.
|
||||||
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
||||||
@@ -172,11 +176,11 @@ impl BlocklistStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_to_allowlist(&mut self, domain: &str) {
|
pub fn add_to_allowlist(&mut self, domain: &str) {
|
||||||
self.allowlist.insert(domain.to_lowercase());
|
self.allowlist.insert(Self::normalize(domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_from_allowlist(&mut self, domain: &str) -> bool {
|
pub fn remove_from_allowlist(&mut self, domain: &str) -> bool {
|
||||||
self.allowlist.remove(&domain.to_lowercase())
|
self.allowlist.remove(&Self::normalize(domain))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn allowlist(&self) -> Vec<String> {
|
pub fn allowlist(&self) -> Vec<String> {
|
||||||
@@ -247,6 +251,97 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore {
|
||||||
|
let mut store = BlocklistStore::new();
|
||||||
|
store.swap_domains(domains.iter().map(|s| s.to_string()).collect(), vec![]);
|
||||||
|
for d in allowlist {
|
||||||
|
store.add_to_allowlist(d);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_block() {
|
||||||
|
let store = store_with(&["ads.example.com"], &[]);
|
||||||
|
assert!(store.is_blocked("ads.example.com"));
|
||||||
|
assert!(!store.is_blocked("example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_block_covers_subdomain() {
|
||||||
|
let store = store_with(&["tracker.com"], &[]);
|
||||||
|
assert!(store.is_blocked("tracker.com"));
|
||||||
|
assert!(store.is_blocked("www.tracker.com"));
|
||||||
|
assert!(store.is_blocked("deep.sub.tracker.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_allowlist_unblocks() {
|
||||||
|
let store = store_with(&["ads.example.com"], &["ads.example.com"]);
|
||||||
|
assert!(!store.is_blocked("ads.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_allowlist_unblocks_subdomain() {
|
||||||
|
let store = store_with(&["example.com", "www.example.com"], &["example.com"]);
|
||||||
|
assert!(!store.is_blocked("example.com"));
|
||||||
|
assert!(!store.is_blocked("www.example.com"));
|
||||||
|
assert!(!store.is_blocked("sub.deep.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlist_does_not_unblock_sibling() {
|
||||||
|
let store = store_with(
|
||||||
|
&["www.example.com", "ads.example.com"],
|
||||||
|
&["www.example.com"],
|
||||||
|
);
|
||||||
|
assert!(!store.is_blocked("www.example.com"));
|
||||||
|
assert!(store.is_blocked("ads.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_reports_parent_allowlist() {
|
||||||
|
let store = store_with(
|
||||||
|
&["goatcounter.com", "www.goatcounter.com"],
|
||||||
|
&["goatcounter.com"],
|
||||||
|
);
|
||||||
|
let result = store.check("www.goatcounter.com");
|
||||||
|
assert!(!result.blocked);
|
||||||
|
assert_eq!(result.matched_rule.as_deref(), Some("goatcounter.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_never_blocks() {
|
||||||
|
let mut store = store_with(&["ads.example.com"], &[]);
|
||||||
|
store.set_enabled(false);
|
||||||
|
assert!(!store.is_blocked("ads.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_dot_normalized() {
|
||||||
|
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
|
||||||
|
assert!(store.is_blocked("ads.example.com."));
|
||||||
|
assert!(!store.is_blocked("safe.example.com."));
|
||||||
|
let result = store.check("ads.example.com.");
|
||||||
|
assert!(result.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_insensitive() {
|
||||||
|
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
|
||||||
|
assert!(store.is_blocked("ADS.Example.COM"));
|
||||||
|
assert!(!store.is_blocked("Safe.Example.COM"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_in_neither_list() {
|
||||||
|
let store = store_with(&["ads.example.com"], &[]);
|
||||||
|
let result = store.check("clean.example.org");
|
||||||
|
assert!(!result.blocked);
|
||||||
|
assert_eq!(result.reason, "not in blocklist");
|
||||||
|
assert!(result.matched_rule.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heap_bytes_grows_with_domains() {
|
fn heap_bytes_grows_with_domains() {
|
||||||
let mut store = BlocklistStore::new();
|
let mut store = BlocklistStore::new();
|
||||||
|
|||||||
85
src/cache.rs
85
src/cache.rs
@@ -82,6 +82,29 @@ impl DnsCache {
|
|||||||
Some((packet, entry.dnssec_status))
|
Some((packet, entry.dnssec_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> {
|
||||||
|
let type_map = self.entries.get(domain)?;
|
||||||
|
let entry = type_map.get(&qtype)?;
|
||||||
|
let elapsed = entry.inserted_at.elapsed();
|
||||||
|
if elapsed >= entry.ttl {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let total = entry.ttl.as_secs() as u32;
|
||||||
|
let remaining = (entry.ttl - elapsed).as_secs() as u32;
|
||||||
|
Some((remaining, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn needs_warm(&self, domain: &str) -> bool {
|
||||||
|
for qtype in [QueryType::A, QueryType::AAAA] {
|
||||||
|
match self.ttl_remaining(domain, qtype) {
|
||||||
|
None => return true,
|
||||||
|
Some((remaining, total)) if remaining < total / 4 => return true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
||||||
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
||||||
}
|
}
|
||||||
@@ -233,4 +256,66 @@ mod tests {
|
|||||||
cache.insert("example.com", QueryType::A, &pkt);
|
cache.insert("example.com", QueryType::A, &pkt);
|
||||||
assert!(cache.heap_bytes() > empty);
|
assert!(cache.heap_bytes() > empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ttl_remaining_returns_values_for_fresh_entry() {
|
||||||
|
let mut cache = DnsCache::new(100, 60, 3600);
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "1.2.3.4".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
cache.insert("example.com", QueryType::A, &pkt);
|
||||||
|
let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap();
|
||||||
|
assert_eq!(total, 300);
|
||||||
|
assert!(remaining <= 300);
|
||||||
|
assert!(remaining > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ttl_remaining_none_for_missing() {
|
||||||
|
let cache = DnsCache::new(100, 1, 3600);
|
||||||
|
assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_warm_true_when_missing() {
|
||||||
|
let cache = DnsCache::new(100, 1, 3600);
|
||||||
|
assert!(cache.needs_warm("missing.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_warm_false_when_fresh() {
|
||||||
|
let mut cache = DnsCache::new(100, 1, 3600);
|
||||||
|
let mut pkt_a = DnsPacket::new();
|
||||||
|
pkt_a.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "1.2.3.4".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
let mut pkt_aaaa = DnsPacket::new();
|
||||||
|
pkt_aaaa.answers.push(DnsRecord::AAAA {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "::1".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
cache.insert("example.com", QueryType::A, &pkt_a);
|
||||||
|
cache.insert("example.com", QueryType::AAAA, &pkt_aaaa);
|
||||||
|
assert!(!cache.needs_warm("example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_warm_true_when_only_a_cached() {
|
||||||
|
let mut cache = DnsCache::new(100, 1, 3600);
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "1.2.3.4".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
cache.insert("example.com", QueryType::A, &pkt);
|
||||||
|
// AAAA missing → needs warm
|
||||||
|
assert!(cache.needs_warm("example.com"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,10 +97,12 @@ impl UpstreamMode {
|
|||||||
pub struct UpstreamConfig {
|
pub struct UpstreamConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: UpstreamMode,
|
pub mode: UpstreamMode,
|
||||||
#[serde(default = "default_upstream_addr")]
|
#[serde(default, deserialize_with = "string_or_vec")]
|
||||||
pub address: String,
|
pub address: Vec<String>,
|
||||||
#[serde(default = "default_upstream_port")]
|
#[serde(default = "default_upstream_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fallback: Vec<String>,
|
||||||
#[serde(default = "default_timeout_ms")]
|
#[serde(default = "default_timeout_ms")]
|
||||||
pub timeout_ms: u64,
|
pub timeout_ms: u64,
|
||||||
#[serde(default = "default_root_hints")]
|
#[serde(default = "default_root_hints")]
|
||||||
@@ -115,8 +117,9 @@ impl Default for UpstreamConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
UpstreamConfig {
|
UpstreamConfig {
|
||||||
mode: UpstreamMode::default(),
|
mode: UpstreamMode::default(),
|
||||||
address: default_upstream_addr(),
|
address: Vec::new(),
|
||||||
port: default_upstream_port(),
|
port: default_upstream_port(),
|
||||||
|
fallback: Vec::new(),
|
||||||
timeout_ms: default_timeout_ms(),
|
timeout_ms: default_timeout_ms(),
|
||||||
root_hints: default_root_hints(),
|
root_hints: default_root_hints(),
|
||||||
prime_tlds: default_prime_tlds(),
|
prime_tlds: default_prime_tlds(),
|
||||||
@@ -125,6 +128,33 @@ impl Default for UpstreamConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct Visitor;
|
||||||
|
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||||
|
type Value = Vec<String>;
|
||||||
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
f.write_str("string or array of strings")
|
||||||
|
}
|
||||||
|
fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
|
||||||
|
Ok(vec![v.to_string()])
|
||||||
|
}
|
||||||
|
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||||
|
self,
|
||||||
|
mut seq: A,
|
||||||
|
) -> std::result::Result<Self::Value, A::Error> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
while let Some(s) = seq.next_element::<String>()? {
|
||||||
|
v.push(s);
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deserializer.deserialize_any(Visitor)
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -202,9 +232,6 @@ fn default_root_hints() -> Vec<String> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_upstream_addr() -> String {
|
|
||||||
String::new() // empty = auto-detect from system resolver
|
|
||||||
}
|
|
||||||
fn default_upstream_port() -> u16 {
|
fn default_upstream_port() -> u16 {
|
||||||
53
|
53
|
||||||
}
|
}
|
||||||
@@ -220,6 +247,8 @@ pub struct CacheConfig {
|
|||||||
pub min_ttl: u32,
|
pub min_ttl: u32,
|
||||||
#[serde(default = "default_max_ttl")]
|
#[serde(default = "default_max_ttl")]
|
||||||
pub max_ttl: u32,
|
pub max_ttl: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub warm: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CacheConfig {
|
impl Default for CacheConfig {
|
||||||
@@ -228,6 +257,7 @@ impl Default for CacheConfig {
|
|||||||
max_entries: default_max_entries(),
|
max_entries: default_max_entries(),
|
||||||
min_ttl: default_min_ttl(),
|
min_ttl: default_min_ttl(),
|
||||||
max_ttl: default_max_ttl(),
|
max_ttl: default_max_ttl(),
|
||||||
|
warm: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,7 +411,7 @@ pub struct DnssecConfig {
|
|||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct DotConfig {
|
pub struct DotConfig {
|
||||||
#[serde(default)]
|
#[serde(default = "default_dot_enabled")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default = "default_dot_port")]
|
#[serde(default = "default_dot_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -398,7 +428,7 @@ pub struct DotConfig {
|
|||||||
impl Default for DotConfig {
|
impl Default for DotConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
DotConfig {
|
DotConfig {
|
||||||
enabled: false,
|
enabled: default_dot_enabled(),
|
||||||
port: default_dot_port(),
|
port: default_dot_port(),
|
||||||
bind_addr: default_dot_bind_addr(),
|
bind_addr: default_dot_bind_addr(),
|
||||||
cert_path: None,
|
cert_path: None,
|
||||||
@@ -407,6 +437,9 @@ impl Default for DotConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_dot_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
fn default_dot_port() -> u16 {
|
fn default_dot_port() -> u16 {
|
||||||
853
|
853
|
||||||
}
|
}
|
||||||
@@ -525,6 +558,33 @@ mod tests {
|
|||||||
assert!(config.services[0].routes[0].strip);
|
assert!(config.services[0].routes[0].strip);
|
||||||
assert!(!config.services[0].routes[1].strip); // default false
|
assert!(!config.services[0].routes[1].strip); // default false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_string_parses_to_vec() {
|
||||||
|
let config: Config = toml::from_str("[upstream]\naddress = \"1.2.3.4\"").unwrap();
|
||||||
|
assert_eq!(config.upstream.address, vec!["1.2.3.4"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_array_parses() {
|
||||||
|
let config: Config =
|
||||||
|
toml::from_str("[upstream]\naddress = [\"1.2.3.4\", \"5.6.7.8:5353\"]").unwrap();
|
||||||
|
assert_eq!(config.upstream.address, vec!["1.2.3.4", "5.6.7.8:5353"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_parses() {
|
||||||
|
let config: Config =
|
||||||
|
toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap();
|
||||||
|
assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_address_gives_empty_vec() {
|
||||||
|
let config: Config = toml::from_str("").unwrap();
|
||||||
|
assert!(config.upstream.address.is_empty());
|
||||||
|
assert!(config.upstream.fallback.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ConfigLoad {
|
pub struct ConfigLoad {
|
||||||
|
|||||||
22
src/ctx.rs
22
src/ctx.rs
@@ -16,7 +16,7 @@ use crate::blocklist::BlocklistStore;
|
|||||||
use crate::buffer::BytePacketBuffer;
|
use crate::buffer::BytePacketBuffer;
|
||||||
use crate::cache::{DnsCache, DnssecStatus};
|
use crate::cache::{DnsCache, DnssecStatus};
|
||||||
use crate::config::{UpstreamMode, ZoneMap};
|
use crate::config::{UpstreamMode, ZoneMap};
|
||||||
use crate::forward::{forward_query, Upstream};
|
use crate::forward::{forward_query, forward_with_failover, Upstream, UpstreamPool};
|
||||||
use crate::header::ResultCode;
|
use crate::header::ResultCode;
|
||||||
use crate::health::HealthMeta;
|
use crate::health::HealthMeta;
|
||||||
use crate::lan::PeerStore;
|
use crate::lan::PeerStore;
|
||||||
@@ -42,7 +42,7 @@ pub struct ServerCtx {
|
|||||||
pub services: Mutex<ServiceStore>,
|
pub services: Mutex<ServiceStore>,
|
||||||
pub lan_peers: Mutex<PeerStore>,
|
pub lan_peers: Mutex<PeerStore>,
|
||||||
pub forwarding_rules: Vec<ForwardingRule>,
|
pub forwarding_rules: Vec<ForwardingRule>,
|
||||||
pub upstream: Mutex<Upstream>,
|
pub upstream_pool: Mutex<UpstreamPool>,
|
||||||
pub upstream_auto: bool,
|
pub upstream_auto: bool,
|
||||||
pub upstream_port: u16,
|
pub upstream_port: u16,
|
||||||
pub lan_ip: Mutex<std::net::Ipv4Addr>,
|
pub lan_ip: Mutex<std::net::Ipv4Addr>,
|
||||||
@@ -70,6 +70,8 @@ pub struct ServerCtx {
|
|||||||
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
|
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
|
||||||
/// handlers to avoid per-request disk I/O on the hot path.
|
/// handlers to avoid per-request disk I/O on the hot path.
|
||||||
pub ca_pem: Option<String>,
|
pub ca_pem: Option<String>,
|
||||||
|
pub mobile_enabled: bool,
|
||||||
|
pub mobile_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||||
@@ -108,6 +110,10 @@ pub async fn resolve_query(
|
|||||||
300,
|
300,
|
||||||
));
|
));
|
||||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||||
|
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||||
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
|
resp.answers = records.clone();
|
||||||
|
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||||
} else if is_special_use_domain(&qname) {
|
} else if is_special_use_domain(&qname) {
|
||||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||||
let resp = special_use_response(&query, &qname, qtype);
|
let resp = special_use_response(&query, &qname, qtype);
|
||||||
@@ -156,10 +162,6 @@ pub async fn resolve_query(
|
|||||||
60,
|
60,
|
||||||
));
|
));
|
||||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
|
||||||
resp.answers = records.clone();
|
|
||||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
|
||||||
} else {
|
} else {
|
||||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||||
if let Some((cached, cached_dnssec)) = cached {
|
if let Some((cached, cached_dnssec)) = cached {
|
||||||
@@ -218,12 +220,8 @@ pub async fn resolve_query(
|
|||||||
}
|
}
|
||||||
(resp, path, DnssecStatus::Indeterminate)
|
(resp, path, DnssecStatus::Indeterminate)
|
||||||
} else {
|
} else {
|
||||||
let upstream =
|
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
match forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await {
|
||||||
Some(addr) => Upstream::Udp(addr),
|
|
||||||
None => ctx.upstream.lock().unwrap().clone(),
|
|
||||||
};
|
|
||||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||||
|
|||||||
188
src/doh.rs
Normal file
188
src/doh.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
|
use crate::buffer::BytePacketBuffer;
|
||||||
|
use crate::ctx::{resolve_query, ServerCtx};
|
||||||
|
use crate::header::ResultCode;
|
||||||
|
use crate::packet::DnsPacket;
|
||||||
|
|
||||||
|
const MAX_DNS_MSG: usize = 4096;
|
||||||
|
const DOH_CONTENT_TYPE: &str = "application/dns-message";
|
||||||
|
|
||||||
|
pub async fn doh_post(State(state): State<super::proxy::DohState>, req: Request) -> Response {
|
||||||
|
let host = super::proxy::extract_host(&req);
|
||||||
|
if !is_doh_host(host.as_deref(), &state.ctx.proxy_tld) {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_type = req
|
||||||
|
.headers()
|
||||||
|
.get(hyper::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if !content_type.starts_with(DOH_CONTENT_TYPE) {
|
||||||
|
return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = match axum::body::to_bytes(req.into_body(), MAX_DNS_MSG).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => {
|
||||||
|
return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4096 bytes").into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if body.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "empty body").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let src = state
|
||||||
|
.remote_addr
|
||||||
|
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||||
|
|
||||||
|
resolve_doh(&body, src, &state.ctx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_doh_host(host: Option<&str>, tld: &str) -> bool {
|
||||||
|
match host {
|
||||||
|
Some(h) if h == tld => true,
|
||||||
|
Some(h) => {
|
||||||
|
h.len() == 2 * tld.len() + 1
|
||||||
|
&& h.starts_with(tld)
|
||||||
|
&& h.as_bytes().get(tld.len()) == Some(&b'.')
|
||||||
|
&& h.ends_with(tld)
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response {
|
||||||
|
let mut buffer = BytePacketBuffer::from_bytes(dns_bytes);
|
||||||
|
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||||
|
Ok(q) => q,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("DoH: parse error from {}: {}", src, e);
|
||||||
|
let query_id = u16::from_be_bytes([
|
||||||
|
dns_bytes.first().copied().unwrap_or(0),
|
||||||
|
dns_bytes.get(1).copied().unwrap_or(0),
|
||||||
|
]);
|
||||||
|
let mut resp = DnsPacket::new();
|
||||||
|
resp.header.id = query_id;
|
||||||
|
resp.header.response = true;
|
||||||
|
resp.header.rescode = ResultCode::FORMERR;
|
||||||
|
return serialize_response(&resp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_id = query.header.id;
|
||||||
|
let query_rd = query.header.recursion_desired;
|
||||||
|
let questions = query.questions.clone();
|
||||||
|
|
||||||
|
match resolve_query(query, src, ctx).await {
|
||||||
|
Ok(resp_buffer) => {
|
||||||
|
let min_ttl = extract_min_ttl(resp_buffer.filled());
|
||||||
|
dns_response(resp_buffer.filled(), min_ttl)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("DoH: resolve error for {}: {}", src, e);
|
||||||
|
let mut resp = DnsPacket::new();
|
||||||
|
resp.header.id = query_id;
|
||||||
|
resp.header.response = true;
|
||||||
|
resp.header.recursion_desired = query_rd;
|
||||||
|
resp.header.recursion_available = true;
|
||||||
|
resp.header.rescode = ResultCode::SERVFAIL;
|
||||||
|
resp.questions = questions;
|
||||||
|
serialize_response(&resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_min_ttl(wire: &[u8]) -> u32 {
|
||||||
|
let mut buf = BytePacketBuffer::from_bytes(wire);
|
||||||
|
match DnsPacket::from_buffer(&mut buf) {
|
||||||
|
Ok(pkt) => pkt.answers.iter().map(|r| r.ttl()).min().unwrap_or(0),
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dns_response(wire: &[u8], min_ttl: u32) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(hyper::header::CONTENT_TYPE, DOH_CONTENT_TYPE),
|
||||||
|
(
|
||||||
|
hyper::header::CACHE_CONTROL,
|
||||||
|
&format!("max-age={}", min_ttl),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Bytes::copy_from_slice(wire),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_response(pkt: &DnsPacket) -> Response {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
match pkt.write(&mut buf) {
|
||||||
|
Ok(_) => dns_response(buf.filled(), 0),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::buffer::BytePacketBuffer;
|
||||||
|
use crate::header::ResultCode;
|
||||||
|
use crate::packet::DnsPacket;
|
||||||
|
use crate::record::DnsRecord;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_doh_host_matches_tld() {
|
||||||
|
assert!(is_doh_host(Some("numa"), "numa"));
|
||||||
|
assert!(is_doh_host(Some("numa.numa"), "numa"));
|
||||||
|
assert!(!is_doh_host(Some("foo.numa"), "numa"));
|
||||||
|
assert!(!is_doh_host(None, "numa"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_min_ttl_from_response() {
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.header.response = true;
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
addr: std::net::Ipv4Addr::new(1, 2, 3, 4),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
addr: std::net::Ipv4Addr::new(5, 6, 7, 8),
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
pkt.write(&mut buf).unwrap();
|
||||||
|
assert_eq!(extract_min_ttl(buf.filled()), 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_min_ttl_no_answers() {
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.header.response = true;
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
pkt.write(&mut buf).unwrap();
|
||||||
|
assert_eq!(extract_min_ttl(buf.filled()), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_formerr_response() {
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.header.id = 0xABCD;
|
||||||
|
pkt.header.response = true;
|
||||||
|
pkt.header.rescode = ResultCode::FORMERR;
|
||||||
|
let resp = serialize_response(&pkt);
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -362,7 +362,10 @@ mod tests {
|
|||||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||||
forwarding_rules: Vec::new(),
|
forwarding_rules: Vec::new(),
|
||||||
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
|
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||||
|
vec![crate::forward::Upstream::Udp(upstream_addr)],
|
||||||
|
vec![],
|
||||||
|
)),
|
||||||
upstream_auto: false,
|
upstream_auto: false,
|
||||||
upstream_port: 53,
|
upstream_port: 53,
|
||||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||||
@@ -383,6 +386,8 @@ mod tests {
|
|||||||
dnssec_strict: false,
|
dnssec_strict: false,
|
||||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||||
ca_pem: None,
|
ca_pem: None,
|
||||||
|
mobile_enabled: false,
|
||||||
|
mobile_port: 8765,
|
||||||
});
|
});
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
241
src/forward.rs
241
src/forward.rs
@@ -1,12 +1,14 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::net::SocketAddr;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::time::Duration;
|
use std::sync::RwLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::buffer::BytePacketBuffer;
|
use crate::buffer::BytePacketBuffer;
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
|
use crate::srtt::SrttCache;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -37,6 +39,133 @@ impl fmt::Display for Upstream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result<SocketAddr, String> {
|
||||||
|
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
|
||||||
|
if let Ok(addr) = s.parse::<SocketAddr>() {
|
||||||
|
return Ok(addr);
|
||||||
|
}
|
||||||
|
// Bare IP: "1.2.3.4" or "::1"
|
||||||
|
if let Ok(ip) = s.parse::<IpAddr>() {
|
||||||
|
return Ok(SocketAddr::new(ip, default_port));
|
||||||
|
}
|
||||||
|
Err(format!("invalid upstream address: {}", s))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||||
|
if s.starts_with("https://") {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Ok(Upstream::Doh {
|
||||||
|
url: s.to_string(),
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let addr = parse_upstream_addr(s, default_port)?;
|
||||||
|
Ok(Upstream::Udp(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UpstreamPool {
|
||||||
|
primary: Vec<Upstream>,
|
||||||
|
fallback: Vec<Upstream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpstreamPool {
|
||||||
|
pub fn new(primary: Vec<Upstream>, fallback: Vec<Upstream>) -> Self {
|
||||||
|
Self { primary, fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preferred(&self) -> Option<&Upstream> {
|
||||||
|
self.primary.first().or(self.fallback.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_primary(&mut self, primary: Vec<Upstream>) {
|
||||||
|
self.primary = primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the primary upstream if `new_addr` (parsed with `port`) differs
|
||||||
|
/// from the current preferred upstream. Returns `true` if the pool changed.
|
||||||
|
pub fn maybe_update_primary(&mut self, new_addr: &str, port: u16) -> bool {
|
||||||
|
let Ok(new_sock) = format!("{}:{}", new_addr, port).parse::<SocketAddr>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let new_upstream = Upstream::Udp(new_sock);
|
||||||
|
if self.preferred() == Some(&new_upstream) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.primary = vec![new_upstream];
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match self.preferred() {
|
||||||
|
Some(u) => {
|
||||||
|
let total = self.primary.len() + self.fallback.len();
|
||||||
|
if total > 1 {
|
||||||
|
format!("{} (+{} more)", u, total - 1)
|
||||||
|
} else {
|
||||||
|
u.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forward_with_failover(
|
||||||
|
query: &DnsPacket,
|
||||||
|
pool: &UpstreamPool,
|
||||||
|
srtt: &RwLock<SrttCache>,
|
||||||
|
timeout_duration: Duration,
|
||||||
|
) -> Result<DnsPacket> {
|
||||||
|
// Build candidate list: primary (sorted by SRTT for UDP) then fallback
|
||||||
|
let mut candidates: Vec<(usize, u64)> = pool
|
||||||
|
.primary
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, u)| {
|
||||||
|
let rtt = match u {
|
||||||
|
Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()),
|
||||||
|
_ => 0, // DoH: keep config order (stable sort preserves it)
|
||||||
|
};
|
||||||
|
(i, rtt)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
candidates.sort_by_key(|&(_, rtt)| rtt);
|
||||||
|
|
||||||
|
let all_upstreams: Vec<&Upstream> = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|&(i, _)| &pool.primary[i])
|
||||||
|
.chain(pool.fallback.iter())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
|
||||||
|
|
||||||
|
for upstream in &all_upstreams {
|
||||||
|
let start = Instant::now();
|
||||||
|
match forward_query(query, upstream, timeout_duration).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Upstream::Udp(addr) = upstream {
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||||
|
srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false);
|
||||||
|
}
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Upstream::Udp(addr) = upstream {
|
||||||
|
srtt.write().unwrap().record_failure(addr.ip());
|
||||||
|
}
|
||||||
|
log::debug!("upstream {} failed: {}", upstream, e);
|
||||||
|
last_err = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_err.unwrap_or_else(|| "no upstream configured".into()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn forward_query(
|
pub async fn forward_query(
|
||||||
query: &DnsPacket,
|
query: &DnsPacket,
|
||||||
upstream: &Upstream,
|
upstream: &Upstream,
|
||||||
@@ -271,4 +400,112 @@ mod tests {
|
|||||||
let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await;
|
let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_addr_ip_only() {
|
||||||
|
let addr = parse_upstream_addr("1.2.3.4", 53).unwrap();
|
||||||
|
assert_eq!(addr, "1.2.3.4:53".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_addr_ip_port() {
|
||||||
|
let addr = parse_upstream_addr("1.2.3.4:5353", 53).unwrap();
|
||||||
|
assert_eq!(addr, "1.2.3.4:5353".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_addr_ipv6_bracketed() {
|
||||||
|
let addr = parse_upstream_addr("[::1]:5553", 53).unwrap();
|
||||||
|
assert_eq!(addr, "[::1]:5553".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_addr_ipv6_bare() {
|
||||||
|
let addr = parse_upstream_addr("::1", 53).unwrap();
|
||||||
|
assert_eq!(addr, "[::1]:53".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_label_single() {
|
||||||
|
let pool = UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||||
|
assert_eq!(pool.label(), "1.2.3.4:53");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_label_multi() {
|
||||||
|
let pool = UpstreamPool::new(
|
||||||
|
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
|
||||||
|
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
|
||||||
|
);
|
||||||
|
assert_eq!(pool.label(), "1.2.3.4:53 (+1 more)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failover_tries_next_on_failure() {
|
||||||
|
// First upstream is unreachable, second responds
|
||||||
|
let query = make_query();
|
||||||
|
let response_bytes = to_wire(&make_response(&query));
|
||||||
|
|
||||||
|
let app = axum::Router::new().route(
|
||||||
|
"/dns-query",
|
||||||
|
axum::routing::post(move || {
|
||||||
|
let body = response_bytes.clone();
|
||||||
|
async move {
|
||||||
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "application/dns-message")],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let good_addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(axum::serve(listener, app).into_future());
|
||||||
|
|
||||||
|
// Unreachable UDP upstream + working DoH upstream
|
||||||
|
let pool = UpstreamPool::new(
|
||||||
|
vec![
|
||||||
|
Upstream::Udp("127.0.0.1:1".parse().unwrap()), // will fail
|
||||||
|
Upstream::Doh {
|
||||||
|
url: format!("http://{}/dns-query", good_addr),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let srtt = RwLock::new(SrttCache::new(true));
|
||||||
|
let result = forward_with_failover(&query, &pool, &srtt, Duration::from_millis(500))
|
||||||
|
.await
|
||||||
|
.expect("should fail over to second upstream");
|
||||||
|
|
||||||
|
assert_eq!(result.header.id, 0xABCD);
|
||||||
|
assert_eq!(result.answers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maybe_update_primary_swaps_when_different() {
|
||||||
|
let mut pool = UpstreamPool::new(
|
||||||
|
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
|
||||||
|
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
|
||||||
|
);
|
||||||
|
assert!(pool.maybe_update_primary("5.6.7.8", 53));
|
||||||
|
assert_eq!(pool.preferred().unwrap().to_string(), "5.6.7.8:53");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maybe_update_primary_noop_when_same() {
|
||||||
|
let mut pool =
|
||||||
|
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||||
|
assert!(!pool.maybe_update_primary("1.2.3.4", 53));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maybe_update_primary_rejects_invalid_addr() {
|
||||||
|
let mut pool =
|
||||||
|
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||||
|
assert!(!pool.maybe_update_primary("not-an-ip", 53));
|
||||||
|
assert_eq!(pool.preferred().unwrap().to_string(), "1.2.3.4:53");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,11 +73,15 @@ impl HealthMeta {
|
|||||||
recursive_enabled: bool,
|
recursive_enabled: bool,
|
||||||
mdns_enabled: bool,
|
mdns_enabled: bool,
|
||||||
blocking_enabled: bool,
|
blocking_enabled: bool,
|
||||||
|
doh_enabled: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let ca_path = data_dir.join("ca.pem");
|
let ca_path = data_dir.join("ca.pem");
|
||||||
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
|
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
|
||||||
|
|
||||||
let mut features = Vec::new();
|
let mut features = Vec::new();
|
||||||
|
if doh_enabled {
|
||||||
|
features.push("doh".to_string());
|
||||||
|
}
|
||||||
if dot_enabled {
|
if dot_enabled {
|
||||||
features.push("dot".to_string());
|
features.push("dot".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod ctx;
|
pub mod ctx;
|
||||||
pub mod dnssec;
|
pub mod dnssec;
|
||||||
|
pub mod doh;
|
||||||
pub mod dot;
|
pub mod dot;
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
|||||||
167
src/main.rs
167
src/main.rs
@@ -11,7 +11,7 @@ use numa::buffer::BytePacketBuffer;
|
|||||||
use numa::cache::DnsCache;
|
use numa::cache::DnsCache;
|
||||||
use numa::config::{build_zone_map, load_config, ConfigLoad};
|
use numa::config::{build_zone_map, load_config, ConfigLoad};
|
||||||
use numa::ctx::{handle_query, ServerCtx};
|
use numa::ctx::{handle_query, ServerCtx};
|
||||||
use numa::forward::Upstream;
|
use numa::forward::{parse_upstream, Upstream, UpstreamPool};
|
||||||
use numa::override_store::OverrideStore;
|
use numa::override_store::OverrideStore;
|
||||||
use numa::query_log::QueryLog;
|
use numa::query_log::QueryLog;
|
||||||
use numa::service_store::ServiceStore;
|
use numa::service_store::ServiceStore;
|
||||||
@@ -129,18 +129,18 @@ async fn main() -> numa::Result<()> {
|
|||||||
|
|
||||||
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||||
|
|
||||||
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
let recursive_pool = || {
|
||||||
|
let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]);
|
||||||
|
(dummy, "recursive (root hints)".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode {
|
||||||
numa::config::UpstreamMode::Auto => {
|
numa::config::UpstreamMode::Auto => {
|
||||||
info!("auto mode: probing recursive resolution...");
|
info!("auto mode: probing recursive resolution...");
|
||||||
if numa::recursive::probe_recursive(&root_hints).await {
|
if numa::recursive::probe_recursive(&root_hints).await {
|
||||||
info!("recursive probe succeeded — self-sovereign mode");
|
info!("recursive probe succeeded — self-sovereign mode");
|
||||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
let (pool, label) = recursive_pool();
|
||||||
(
|
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||||
numa::config::UpstreamMode::Recursive,
|
|
||||||
false,
|
|
||||||
dummy,
|
|
||||||
"recursive (root hints)".to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -149,55 +149,45 @@ async fn main() -> numa::Result<()> {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let url = DOH_FALLBACK.to_string();
|
let url = DOH_FALLBACK.to_string();
|
||||||
let label = url.clone();
|
let label = url.clone();
|
||||||
(
|
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||||
numa::config::UpstreamMode::Forward,
|
(numa::config::UpstreamMode::Forward, false, pool, label)
|
||||||
false,
|
|
||||||
Upstream::Doh { url, client },
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
numa::config::UpstreamMode::Recursive => {
|
numa::config::UpstreamMode::Recursive => {
|
||||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
let (pool, label) = recursive_pool();
|
||||||
(
|
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||||
numa::config::UpstreamMode::Recursive,
|
|
||||||
false,
|
|
||||||
dummy,
|
|
||||||
"recursive (root hints)".to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
numa::config::UpstreamMode::Forward => {
|
numa::config::UpstreamMode::Forward => {
|
||||||
let upstream_addr = if config.upstream.address.is_empty() {
|
let addrs = if config.upstream.address.is_empty() {
|
||||||
system_dns
|
let detected = system_dns
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||||
DOH_FALLBACK.to_string()
|
DOH_FALLBACK.to_string()
|
||||||
})
|
});
|
||||||
|
vec![detected]
|
||||||
} else {
|
} else {
|
||||||
config.upstream.address.clone()
|
config.upstream.address.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
let primary: Vec<Upstream> = addrs
|
||||||
let client = reqwest::Client::builder()
|
.iter()
|
||||||
.use_rustls_tls()
|
.map(|s| parse_upstream(s, config.upstream.port))
|
||||||
.build()
|
.collect::<numa::Result<Vec<_>>>()?;
|
||||||
.unwrap_or_default();
|
let fallback: Vec<Upstream> = config
|
||||||
Upstream::Doh {
|
.upstream
|
||||||
url: upstream_addr,
|
.fallback
|
||||||
client,
|
.iter()
|
||||||
}
|
.map(|s| parse_upstream(s, config.upstream.port))
|
||||||
} else {
|
.collect::<numa::Result<Vec<_>>>()?;
|
||||||
let addr: SocketAddr =
|
|
||||||
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
let pool = UpstreamPool::new(primary, fallback);
|
||||||
Upstream::Udp(addr)
|
let label = pool.label();
|
||||||
};
|
|
||||||
let label = upstream.to_string();
|
|
||||||
(
|
(
|
||||||
numa::config::UpstreamMode::Forward,
|
numa::config::UpstreamMode::Forward,
|
||||||
config.upstream.address.is_empty(),
|
config.upstream.address.is_empty(),
|
||||||
upstream,
|
pool,
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -253,6 +243,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let doh_enabled = initial_tls.is_some();
|
||||||
let health_meta = numa::health::HealthMeta::build(
|
let health_meta = numa::health::HealthMeta::build(
|
||||||
&resolved_data_dir,
|
&resolved_data_dir,
|
||||||
config.dot.enabled,
|
config.dot.enabled,
|
||||||
@@ -262,6 +253,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
resolved_mode == numa::config::UpstreamMode::Recursive,
|
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||||
config.lan.enabled,
|
config.lan.enabled,
|
||||||
config.blocking.enabled,
|
config.blocking.enabled,
|
||||||
|
doh_enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||||
@@ -294,7 +286,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
services: Mutex::new(service_store),
|
services: Mutex::new(service_store),
|
||||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||||
forwarding_rules,
|
forwarding_rules,
|
||||||
upstream: Mutex::new(upstream),
|
upstream_pool: Mutex::new(pool),
|
||||||
upstream_auto,
|
upstream_auto,
|
||||||
upstream_port: config.upstream.port,
|
upstream_port: config.upstream.port,
|
||||||
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
||||||
@@ -319,6 +311,8 @@ async fn main() -> numa::Result<()> {
|
|||||||
dnssec_strict: config.dnssec.strict,
|
dnssec_strict: config.dnssec.strict,
|
||||||
health_meta,
|
health_meta,
|
||||||
ca_pem,
|
ca_pem,
|
||||||
|
mobile_enabled: config.mobile.enabled,
|
||||||
|
mobile_port: config.mobile.port,
|
||||||
});
|
});
|
||||||
|
|
||||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||||
@@ -410,6 +404,9 @@ async fn main() -> numa::Result<()> {
|
|||||||
g,
|
g,
|
||||||
&format!("max {} entries", config.cache.max_entries),
|
&format!("max {} entries", config.cache.max_entries),
|
||||||
);
|
);
|
||||||
|
if !config.cache.warm.is_empty() {
|
||||||
|
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
|
||||||
|
}
|
||||||
row(
|
row(
|
||||||
"Blocking",
|
"Blocking",
|
||||||
g,
|
g,
|
||||||
@@ -436,6 +433,13 @@ async fn main() -> numa::Result<()> {
|
|||||||
if config.dot.enabled {
|
if config.dot.enabled {
|
||||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||||
}
|
}
|
||||||
|
if doh_enabled {
|
||||||
|
row(
|
||||||
|
"DoH",
|
||||||
|
g,
|
||||||
|
&format!("https://:{}/dns-query", config.proxy.tls_port),
|
||||||
|
);
|
||||||
|
}
|
||||||
if config.lan.enabled {
|
if config.lan.enabled {
|
||||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||||
}
|
}
|
||||||
@@ -492,6 +496,15 @@ async fn main() -> numa::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spawn cache warming for user-configured domains
|
||||||
|
if !config.cache.warm.is_empty() {
|
||||||
|
let warm_ctx = Arc::clone(&ctx);
|
||||||
|
let warm_domains = config.cache.warm.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
cache_warm_loop(warm_ctx, warm_domains).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn HTTP API server
|
// Spawn HTTP API server
|
||||||
let api_ctx = Arc::clone(&ctx);
|
let api_ctx = Arc::clone(&ctx);
|
||||||
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
||||||
@@ -611,29 +624,19 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-detect upstream every 30s or on LAN IP change (UDP only —
|
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
|
||||||
// DoH upstreams are explicitly configured via URL, not auto-detected)
|
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
|
||||||
if ctx.upstream_auto
|
|
||||||
&& matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_))
|
|
||||||
&& (changed || tick.is_multiple_of(6))
|
|
||||||
{
|
|
||||||
let dns_info = numa::system_dns::discover_system_dns();
|
let dns_info = numa::system_dns::discover_system_dns();
|
||||||
let new_addr = dns_info
|
let new_addr = dns_info
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||||
if let Ok(new_sock) =
|
let mut pool = ctx.upstream_pool.lock().unwrap();
|
||||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
|
||||||
{
|
info!("upstream changed → {}", pool.label());
|
||||||
let new_upstream = Upstream::Udp(new_sock);
|
|
||||||
let mut upstream = ctx.upstream.lock().unwrap();
|
|
||||||
if *upstream != new_upstream {
|
|
||||||
info!("upstream changed: {} → {}", upstream, new_upstream);
|
|
||||||
*upstream = new_upstream;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Flush stale LAN peers on any network change
|
// Flush stale LAN peers on any network change
|
||||||
if changed {
|
if changed {
|
||||||
@@ -738,3 +741,53 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
|||||||
downloaded.len()
|
downloaded.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn warm_domain(ctx: &ServerCtx, domain: &str) {
|
||||||
|
use numa::question::QueryType;
|
||||||
|
|
||||||
|
for qtype in [QueryType::A, QueryType::AAAA] {
|
||||||
|
let query = numa::packet::DnsPacket::query(0, domain, qtype);
|
||||||
|
let result = if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
|
||||||
|
numa::recursive::resolve_recursive(
|
||||||
|
domain,
|
||||||
|
qtype,
|
||||||
|
&ctx.cache,
|
||||||
|
&query,
|
||||||
|
&ctx.root_hints,
|
||||||
|
&ctx.srtt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||||
|
numa::forward::forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
ctx.cache.write().unwrap().insert(domain, qtype, &resp);
|
||||||
|
log::debug!("cache warm: {} {:?}", domain, qtype);
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache_warm_loop(ctx: Arc<ServerCtx>, domains: Vec<String>) {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
for domain in &domains {
|
||||||
|
warm_domain(&ctx, domain).await;
|
||||||
|
}
|
||||||
|
info!("cache warm: {} domains resolved at startup", domains.len());
|
||||||
|
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
for domain in &domains {
|
||||||
|
let refresh = ctx.cache.read().unwrap().needs_warm(domain);
|
||||||
|
if refresh {
|
||||||
|
warm_domain(&ctx, domain).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ fn build_ca_payload(ca_pem: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
|
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
|
||||||
/// Pins the device to Numa as its system resolver over DoT with
|
|
||||||
/// `ServerName = "numa.numa"` (must match the DoT cert SAN).
|
|
||||||
fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#" <dict>
|
r#" <dict>
|
||||||
@@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
|||||||
<key>ServerName</key>
|
<key>ServerName</key>
|
||||||
<string>numa.numa</string>
|
<string>numa.numa</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>OnDemandRules</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>Action</key>
|
||||||
|
<string>Connect</string>
|
||||||
|
<key>InterfaceTypeMatch</key>
|
||||||
|
<string>WiFi</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>Action</key>
|
||||||
|
<string>Disconnect</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>PayloadDescription</key>
|
<key>PayloadDescription</key>
|
||||||
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
|
<string>Routes DNS queries through Numa over DoT when on Wi-Fi</string>
|
||||||
<key>PayloadDisplayName</key>
|
<key>PayloadDisplayName</key>
|
||||||
<string>Numa DNS-over-TLS</string>
|
<string>Numa DNS-over-TLS</string>
|
||||||
<key>PayloadIdentifier</key>
|
<key>PayloadIdentifier</key>
|
||||||
|
|||||||
36
src/proxy.rs
36
src/proxy.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Request, State};
|
use axum::extract::{Request, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::any;
|
use axum::routing::{any, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
@@ -18,6 +18,14 @@ use crate::ctx::ServerCtx;
|
|||||||
|
|
||||||
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
||||||
|
|
||||||
|
/// State passed to the DoH handler. Includes the remote address so
|
||||||
|
/// `resolve_query` can log the client IP.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DohState {
|
||||||
|
pub ctx: Arc<ServerCtx>,
|
||||||
|
pub remote_addr: Option<std::net::SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ProxyState {
|
struct ProxyState {
|
||||||
ctx: Arc<ServerCtx>,
|
ctx: Arc<ServerCtx>,
|
||||||
@@ -74,9 +82,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
|||||||
|
|
||||||
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
|
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
|
||||||
let tls_holder = Arc::clone(&ctx);
|
let tls_holder = Arc::clone(&ctx);
|
||||||
let state = ProxyState { ctx, client };
|
let proxy_state = ProxyState {
|
||||||
|
ctx: Arc::clone(&ctx),
|
||||||
|
client,
|
||||||
|
};
|
||||||
|
|
||||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
// DoH route (RFC 8484) served only on the TLS listener.
|
||||||
|
// DohState.remote_addr is set per-connection below.
|
||||||
|
let doh_state = DohState {
|
||||||
|
ctx,
|
||||||
|
remote_addr: None,
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||||
@@ -91,7 +107,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
|||||||
// unwrap safe: guarded by is_none() check above
|
// unwrap safe: guarded by is_none() check above
|
||||||
let acceptor =
|
let acceptor =
|
||||||
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
|
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
|
||||||
let app = app.clone();
|
|
||||||
|
let mut conn_doh_state = doh_state.clone();
|
||||||
|
conn_doh_state.remote_addr = Some(remote_addr);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/dns-query",
|
||||||
|
post(crate::doh::doh_post).with_state(conn_doh_state),
|
||||||
|
)
|
||||||
|
.fallback(any(proxy_handler))
|
||||||
|
.with_state(proxy_state.clone());
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||||
@@ -232,7 +258,7 @@ pre .str {{ color: #d48a5a }}
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_host(req: &Request) -> Option<String> {
|
pub fn extract_host(req: &Request) -> Option<String> {
|
||||||
req.headers()
|
req.headers()
|
||||||
.get(hyper::header::HOST)
|
.get(hyper::header::HOST)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
|
|||||||
@@ -622,6 +622,54 @@ CONF
|
|||||||
"10.0.0.1" \
|
"10.0.0.1" \
|
||||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== DNS-over-HTTPS (RFC 8484) ==="
|
||||||
|
|
||||||
|
DOH_QUERY_FILE=/tmp/numa-doh-query.bin
|
||||||
|
DOH_RESP_FILE=/tmp/numa-doh-resp.bin
|
||||||
|
|
||||||
|
# Build DNS wire-format query for dot-test.example A
|
||||||
|
printf '\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08dot-test\x07example\x00\x00\x01\x00\x01' > "$DOH_QUERY_FILE"
|
||||||
|
|
||||||
|
# POST valid DoH query
|
||||||
|
DOH_CODE=$(curl -sk -X POST \
|
||||||
|
--resolve "numa.numa:$PROXY_HTTPS_PORT:127.0.0.1" \
|
||||||
|
-H "Content-Type: application/dns-message" \
|
||||||
|
--data-binary @"$DOH_QUERY_FILE" \
|
||||||
|
--cacert "$CA" \
|
||||||
|
-o "$DOH_RESP_FILE" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"https://numa.numa:$PROXY_HTTPS_PORT/dns-query")
|
||||||
|
check "DoH POST returns HTTP 200" "200" "$DOH_CODE"
|
||||||
|
|
||||||
|
# Check response contains IP 10.0.0.1 (hex: 0a000001)
|
||||||
|
DOH_HEX=$(xxd -p "$DOH_RESP_FILE" | tr -d '\n')
|
||||||
|
if echo "$DOH_HEX" | grep -q "0a000001"; then
|
||||||
|
check "DoH response resolves dot-test.example → 10.0.0.1" "found" "found"
|
||||||
|
else
|
||||||
|
check "DoH response resolves dot-test.example → 10.0.0.1" "0a000001" "$DOH_HEX"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wrong Content-Type → 415
|
||||||
|
DOH_CT_CODE=$(curl -sk -X POST \
|
||||||
|
-H "Host: numa.numa" \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
--data-binary @"$DOH_QUERY_FILE" \
|
||||||
|
-o /dev/null -w "%{http_code}" \
|
||||||
|
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
|
||||||
|
check "DoH wrong Content-Type → 415" "415" "$DOH_CT_CODE"
|
||||||
|
|
||||||
|
# Wrong host → 404 (DoH only serves numa.numa)
|
||||||
|
DOH_HOST_CODE=$(curl -sk -X POST \
|
||||||
|
-H "Host: foo.numa" \
|
||||||
|
-H "Content-Type: application/dns-message" \
|
||||||
|
--data-binary @"$DOH_QUERY_FILE" \
|
||||||
|
-o /dev/null -w "%{http_code}" \
|
||||||
|
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
|
||||||
|
check "DoH wrong host → 404" "404" "$DOH_HOST_CODE"
|
||||||
|
|
||||||
|
rm -f "$DOH_QUERY_FILE" "$DOH_RESP_FILE"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Proxy TLS works with DoT enabled ==="
|
echo "=== Proxy TLS works with DoT enabled ==="
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user