Compare commits
9 Commits
fix/allowl
...
v0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de1bc2efc | ||
|
|
156b68de87 | ||
|
|
7d6b0ed568 | ||
|
|
7770129589 | ||
|
|
8abcd91f95 | ||
|
|
a96b84fdeb | ||
|
|
23ff3ce455 | ||
|
|
2c20c56421 | ||
|
|
921ed68d54 |
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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
54
src/api.rs
54
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(
|
||||||
"127.0.0.1:53".parse().unwrap(),
|
vec![crate::forward::Upstream::Udp(
|
||||||
|
"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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
169
src/main.rs
169
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,27 +624,17 @@ 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);
|
changed = true;
|
||||||
let mut upstream = ctx.upstream.lock().unwrap();
|
|
||||||
if *upstream != new_upstream {
|
|
||||||
info!("upstream changed: {} → {}", upstream, new_upstream);
|
|
||||||
*upstream = new_upstream;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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