9 Commits

Author SHA1 Message Date
Razvan Dimescu
2de1bc2efc chore: bump version to 0.12.0 2026-04-11 12:15:40 +03:00
Razvan Dimescu
156b68de87 fix: replace unscannable QR art with placeholder in blog post (#80)
The Unicode block-character QR code in the DoT blog post can't be
scanned by phone cameras due to HTML font metrics distorting the grid.
Replace with a bordered placeholder box — the dashboard screenshot
already shows a working QR.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 04:17:46 +03:00
Razvan Dimescu
7d6b0ed568 feat: DoH server endpoint + DoT enabled by default (#79)
* chore: document multi-forwarder and cache warming in config and README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: DNS-over-HTTPS server endpoint (RFC 8484)

Serve DoH at POST /dns-query on the existing HTTPS proxy (port 443).
Automatically enabled when proxy TLS is active — no config needed.
Also fix zone map priority so local zones override RFC 6762 .local
special-use handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove GoatCounter analytics from site

GoatCounter domains (goatcounter.com, gc.zgo.at) are blocked by
Hagezi Pro, which is Numa's default blocklist. A DNS privacy tool
should not embed analytics that its own resolver blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enable DoT listener by default

DoT now starts automatically with `sudo numa`, matching the proxy and
DoH which are already on by default. The self-signed CA infrastructure
is shared with the proxy, so there is no additional setup. This makes
`numa setup-phone` work out of the box.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 04:06:17 +03:00
Razvan Dimescu
7770129589 feat: cache warming — proactive DNS resolution for configured domains (#78)
Resolves A + AAAA at startup for domains listed in [cache] warm,
then re-resolves before TTL expiry (at 75% elapsed). Keeps critical
domains always hot in cache with zero client-visible latency.

Closes #34 (item 4)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:14:04 +03:00
Razvan Dimescu
8abcd91f95 feat: multi-forwarder with SRTT-based failover (#77)
* feat: multi-forwarder with SRTT-based failover

address accepts string or array, with optional per-server port override.
New fallback pool tried only when all primaries fail. Sequential failover
with SRTT ranking ensures fastest upstream is tried first.

Closes #34 (items 1, 2, 3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify failover candidate list and deduplicate recursive pool

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract maybe_update_primary for testable upstream re-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:26:58 +03:00
Razvan Dimescu
a96b84fdeb ci: use pandoc/actions/setup instead of apt-get (#76)
apt-get install pandoc took ~27 minutes due to apt index refresh.
The prebuilt binary action completes in seconds.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:16:59 +03:00
Razvan Dimescu
23ff3ce455 chore: blog full QR output + dashboard screenshot, hero script phone setup scene (#75)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:34:41 +03:00
Razvan Dimescu
2c20c56421 feat: mobile setup — QR onboarding, Wi-Fi scoped mobileconfig (#73)
* fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules

Without OnDemandRules, iOS applies the DoT profile globally —
cellular DNS breaks when the phone leaves the LAN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: phone setup QR code in dashboard header

- Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature)
- Header popover: QR on desktop, direct download link on mobile viewports
- Only visible when [mobile] enabled = true in config
- Expose mobile.enabled and mobile.port in /stats response
- Lazy-load QR on first click, dismiss on outside click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add Cache-Control to /qr, re-fetch QR on each popover open

Cache-Control: no-store prevents stale QR after LAN IP change.
Remove qrLoaded flag so the QR always reflects the current IP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: rustfmt serve_qr response tuple

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add iOS install steps to phone setup popover

iOS shows "Profile Downloaded" with no guidance. The popover
now includes the 3-step install flow including the buried
Certificate Trust Settings toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:21:51 +03:00
Razvan Dimescu
921ed68d54 fix: allowlist parent domain unblocks subdomains (#74)
* fix: allowlist parent domain unblocks subdomains in blocklist

The allowlist walk-up was interleaved with the blocklist walk-up,
so an exact blocklist match on www.example.com short-circuited
before reaching example.com in the allowlist. Now allowlist is
checked at all parent levels before consulting the blocklist.

Deduplicate is_blocked/check via find_in_set helper; is_blocked
delegates to check. Adds 7 new blocklist tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: rustfmt blocklist tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: zero-alloc is_blocked hot path, normalize trailing dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt add_to_allowlist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract normalize() for domain lowering + dot stripping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:43:40 +03:00
26 changed files with 956 additions and 131 deletions

View File

@@ -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
View File

@@ -3,3 +3,4 @@
CLAUDE.md CLAUDE.md
docs/ docs/
site/blog/posts/ site/blog/posts/
ios/

2
Cargo.lock generated
View File

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

View File

@@ -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"] }

View File

@@ -139,6 +139,8 @@ 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 - [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

View File

@@ -132,20 +132,29 @@ $ numa setup-phone
Numa Phone Setup Numa Phone Setup
Profile URL: http://192.168.1.16:8765/mobileconfig Profile URL: http://192.168.1.10:8765/mobileconfig
▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀ ████████████████████████████
█▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █ █
... ██ [QR code rendered in ██
██ your terminal] ██
██ ██
██████████████████████████████
On your iPhone: On your iPhone:
1. Open Camera, point at the QR code, tap the yellow banner 1. Open Camera, point at the QR code, tap the yellow banner
2. Allow the download when Safari asks 2. Allow the download when Safari asks
3. Settings "Profile Downloaded" → Install 3. Open Settings — tap "Profile Downloaded" near the top
4. Settings → General → About → Certificate Trust Settings (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 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. 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. 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.

View File

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

View File

@@ -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');

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@@ -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 &rarr; allow download<br>
2. Settings &rarr; Profile Downloaded &rarr; Install<br>
3. Settings &rarr; General &rarr; About &rarr;<br>
&nbsp;&nbsp;&nbsp;Certificate Trust Settings &rarr; 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ==="