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
uses: actions/checkout@v6
- name: Install pandoc
run: sudo apt-get install -y pandoc
uses: pandoc/actions/setup@v1
- name: Generate blog HTML
run: make blog
- name: Setup Pages

1
.gitignore vendored
View File

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

2
Cargo.lock generated
View File

@@ -1144,7 +1144,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arc-swap",
"axum",

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.11.0"
version = "0.12.0"
authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021"
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"
ring = "0.17"
rustls-pemfile = "2.2.0"
qrcode = { version = "0.14", default-features = false }
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies]
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] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
- [x] SRTT-based nameserver selection
- [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool
- [x] Cache warming — proactive resolution for configured domains
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
- [ ] Global `.numa` names — DHT-backed, no registrar

View File

@@ -132,20 +132,29 @@ $ numa setup-phone
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:
1. Open Camera, point at the QR code, tap the yellow banner
2. Allow the download when Safari asks
3. Settings "Profile Downloaded" → Install
4. Settings → General → About → Certificate Trust Settings
3. Open Settings — tap "Profile Downloaded" near the top
(or: Settings → General → VPN & Device Management → Numa DNS)
4. Tap Install (top right), enter passcode, Install again
5. Settings → General → About → Certificate Trust Settings
Toggle ON "Numa Local CA" — required for DoT to work
```
The same QR is available in the dashboard — click "Phone Setup" in the header and the popover renders an SVG QR code pointing at the mobileconfig URL. On mobile viewports it shows a direct download link instead.
<img src="../phone-setup-dashboard.png" alt="Numa dashboard with Phone Setup popover showing QR code and install instructions">
Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct.
I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work.

View File

@@ -12,10 +12,11 @@ api_port = 5380
# [upstream]
# mode = "forward" # "forward" (default) — relay to upstream
# # "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://cloudflare-dns.com/dns-query" # Cloudflare DoH
# address = "9.9.9.9" # plain UDP
# port = 53 # only for forward mode, plain UDP
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
# port = 53 # default port for addresses without :port
# timeout_ms = 3000
# root_hints = [ # only used in recursive mode
# "198.41.0.4", # a.root-servers.net (Verisign)
@@ -54,6 +55,7 @@ api_port = 5380
max_entries = 10000
min_ttl = 60
max_ttl = 86400
# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry
[proxy]
enabled = true
@@ -91,7 +93,7 @@ tld = "numa"
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
# [dot]
# enabled = false # opt-in: accept DoT queries
# enabled = true # on by default; set false to disable
# port = 853 # standard DoT port
# 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)

View File

@@ -7,18 +7,19 @@
# The script:
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
# 2. Generates DNS traffic (forward, cache hit, blocked)
# 3. Types "peekm" / "6419" into the Local Services form on camera
# 4. Shows LAN accessibility badge ("local only" / "LAN")
# 5. Checks a blocked domain
# 6. Opens peekm.numa to show the proxy working
# 7. Records via ffmpeg and converts to optimized GIF
# 3. Opens Phone Setup QR popover
# 4. Types "peekm" / "6419" into the Local Services form on camera
# 5. Shows LAN accessibility badge ("local only" / "LAN")
# 6. Checks a blocked domain
# 7. Opens peekm.numa to show the proxy working
# 8. Records via ffmpeg and converts to optimized GIF
set -euo pipefail
# --------------- Configuration ---------------
OUTPUT="${1:-assets/hero-demo.gif}"
PORT=5380
RECORD_SECONDS=20
RECORD_SECONDS=24
VIEWPORT_W=1800
VIEWPORT_H=1100
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
sleep 3
# --------------- Scene 2: Add peekm service via UI (3-7s) ---------------
log "Scene 2: Adding peekm.numa service..."
# --------------- Scene 2: Phone Setup popover (3-7s) ---------------
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
run_js "
@@ -249,18 +258,18 @@ sleep 0.3
run_js "document.querySelector('#serviceForm .btn-add').click();"
sleep 2
# --------------- Scene 3: Open peekm.numa (7-11s) ---------------
log "Scene 3: Opening peekm.numa in browser..."
# --------------- Scene 4: Open peekm.numa (11-15s) ---------------
log "Scene 4: Opening peekm.numa in browser..."
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
sleep 4
# --------------- Scene 4: Back to dashboard (11-14s) ---------------
log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..."
# --------------- Scene 5: Back to dashboard (15-18s) ---------------
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
sleep 3
# --------------- Scene 5: Check Domain blocker (14-17s) ---------------
log "Scene 5: Check Domain — blocked tracker..."
# --------------- Scene 6: Check Domain blocker (18-21s) ---------------
log "Scene 6: Check Domain — blocked tracker..."
# Scroll down to blocking panel
run_js "
var blockPanel = document.getElementById('blockingPanel');
@@ -273,8 +282,8 @@ sleep 0.3
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
sleep 2
# --------------- Scene 6: Terminal-style dig overlay (17-20s) ---------------
log "Scene 6: dig proof overlay..."
# --------------- Scene 7: Terminal-style dig overlay (21-24s) ---------------
log "Scene 7: dig proof overlay..."
DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1)
run_js "
var overlay = document.createElement('div');

View File

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

View File

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

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>
<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="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">
@@ -788,6 +802,34 @@ function formatTime(epoch) {
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) {
if (!addr) return '';
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('blockedCount').textContent = formatNumber(q.blocked);
const bl = stats.blocking;

View File

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

View File

@@ -57,6 +57,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/services/{name}/routes", post(add_route))
.route("/services/{name}/routes", delete(remove_route))
.route("/ca.pem", get(serve_ca))
.route("/qr", get(serve_qr))
.route("/fonts/fonts.css", get(serve_fonts_css))
.route(
"/fonts/dm-sans-latin.woff2",
@@ -170,9 +171,16 @@ struct StatsResponse {
overrides: OverrideStats,
blocking: BlockingStatsResponse,
lan: LanStatsResponse,
mobile: MobileStatsResponse,
memory: MemoryStats,
}
#[derive(Serialize)]
struct MobileStatsResponse {
enabled: bool,
port: u16,
}
#[derive(Serialize)]
struct LanStatsResponse {
enabled: bool,
@@ -403,9 +411,12 @@ async fn diagnose(
}
// Check upstream (async, no locks held)
let upstream = ctx.upstream.lock().unwrap().clone();
let (upstream_matched, upstream_detail) =
forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await;
let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned();
let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream {
forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await
} else {
(false, "no upstream configured".to_string())
};
steps.push(DiagnoseStep {
source: "upstream".to_string(),
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 {
"recursive (root hints)".to_string()
} else {
ctx.upstream.lock().unwrap().to_string()
ctx.upstream_pool.lock().unwrap().label()
};
Json(StatsResponse {
@@ -551,6 +562,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
enabled: ctx.lan_enabled,
peers: ctx.lan_peers.lock().unwrap().list().len(),
},
mobile: MobileStatsResponse {
enabled: ctx.mobile_enabled,
port: ctx.mobile_port,
},
memory: MemoryStats {
cache_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 {
(
[
@@ -982,8 +1019,11 @@ mod tests {
services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
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(),
)],
vec![],
)),
upstream_auto: false,
upstream_port: 53,
@@ -1005,6 +1045,8 @@ mod tests {
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
})
}

View File

@@ -82,6 +82,29 @@ impl DnsCache {
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) {
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
}
@@ -233,4 +256,66 @@ mod tests {
cache.insert("example.com", QueryType::A, &pkt);
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 {
#[serde(default)]
pub mode: UpstreamMode,
#[serde(default = "default_upstream_addr")]
pub address: String,
#[serde(default, deserialize_with = "string_or_vec")]
pub address: Vec<String>,
#[serde(default = "default_upstream_port")]
pub port: u16,
#[serde(default)]
pub fallback: Vec<String>,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_root_hints")]
@@ -115,8 +117,9 @@ impl Default for UpstreamConfig {
fn default() -> Self {
UpstreamConfig {
mode: UpstreamMode::default(),
address: default_upstream_addr(),
address: Vec::new(),
port: default_upstream_port(),
fallback: Vec::new(),
timeout_ms: default_timeout_ms(),
root_hints: default_root_hints(),
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 {
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 {
53
}
@@ -220,6 +247,8 @@ pub struct CacheConfig {
pub min_ttl: u32,
#[serde(default = "default_max_ttl")]
pub max_ttl: u32,
#[serde(default)]
pub warm: Vec<String>,
}
impl Default for CacheConfig {
@@ -228,6 +257,7 @@ impl Default for CacheConfig {
max_entries: default_max_entries(),
min_ttl: default_min_ttl(),
max_ttl: default_max_ttl(),
warm: Vec::new(),
}
}
}
@@ -381,7 +411,7 @@ pub struct DnssecConfig {
#[derive(Deserialize, Clone)]
pub struct DotConfig {
#[serde(default)]
#[serde(default = "default_dot_enabled")]
pub enabled: bool,
#[serde(default = "default_dot_port")]
pub port: u16,
@@ -398,7 +428,7 @@ pub struct DotConfig {
impl Default for DotConfig {
fn default() -> Self {
DotConfig {
enabled: false,
enabled: default_dot_enabled(),
port: default_dot_port(),
bind_addr: default_dot_bind_addr(),
cert_path: None,
@@ -407,6 +437,9 @@ impl Default for DotConfig {
}
}
fn default_dot_enabled() -> bool {
true
}
fn default_dot_port() -> u16 {
853
}
@@ -525,6 +558,33 @@ mod tests {
assert!(config.services[0].routes[0].strip);
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 {

View File

@@ -16,7 +16,7 @@ use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer;
use crate::cache::{DnsCache, DnssecStatus};
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::health::HealthMeta;
use crate::lan::PeerStore;
@@ -42,7 +42,7 @@ pub struct ServerCtx {
pub services: Mutex<ServiceStore>,
pub lan_peers: Mutex<PeerStore>,
pub forwarding_rules: Vec<ForwardingRule>,
pub upstream: Mutex<Upstream>,
pub upstream_pool: Mutex<UpstreamPool>,
pub upstream_auto: bool,
pub upstream_port: u16,
pub lan_ip: Mutex<std::net::Ipv4Addr>,
@@ -70,6 +70,8 @@ pub struct ServerCtx {
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
/// handlers to avoid per-request disk I/O on the hot path.
pub ca_pem: Option<String>,
pub mobile_enabled: bool,
pub mobile_port: u16,
}
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
@@ -108,6 +110,10 @@ pub async fn resolve_query(
300,
));
(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) {
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
let resp = special_use_response(&query, &qname, qtype);
@@ -156,10 +162,6 @@ pub async fn resolve_query(
60,
));
(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 {
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
if let Some((cached, cached_dnssec)) = cached {
@@ -218,12 +220,8 @@ pub async fn resolve_query(
}
(resp, path, DnssecStatus::Indeterminate)
} else {
let upstream =
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
Some(addr) => Upstream::Udp(addr),
None => ctx.upstream.lock().unwrap().clone(),
};
match forward_query(&query, &upstream, ctx.timeout).await {
let pool = ctx.upstream_pool.lock().unwrap().clone();
match forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await {
Ok(resp) => {
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(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()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
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_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
@@ -383,6 +386,8 @@ mod tests {
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();

View File

@@ -1,12 +1,14 @@
use std::fmt;
use std::net::SocketAddr;
use std::time::Duration;
use std::net::{IpAddr, SocketAddr};
use std::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::time::timeout;
use crate::buffer::BytePacketBuffer;
use crate::packet::DnsPacket;
use crate::srtt::SrttCache;
use crate::Result;
#[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(
query: &DnsPacket,
upstream: &Upstream,
@@ -271,4 +400,112 @@ mod tests {
let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await;
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,
mdns_enabled: bool,
blocking_enabled: bool,
doh_enabled: bool,
) -> Self {
let ca_path = data_dir.join("ca.pem");
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
let mut features = Vec::new();
if doh_enabled {
features.push("doh".to_string());
}
if dot_enabled {
features.push("dot".to_string());
}

View File

@@ -5,6 +5,7 @@ pub mod cache;
pub mod config;
pub mod ctx;
pub mod dnssec;
pub mod doh;
pub mod dot;
pub mod forward;
pub mod header;

View File

@@ -11,7 +11,7 @@ use numa::buffer::BytePacketBuffer;
use numa::cache::DnsCache;
use numa::config::{build_zone_map, load_config, ConfigLoad};
use numa::ctx::{handle_query, ServerCtx};
use numa::forward::Upstream;
use numa::forward::{parse_upstream, Upstream, UpstreamPool};
use numa::override_store::OverrideStore;
use numa::query_log::QueryLog;
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 (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 => {
info!("auto mode: probing recursive resolution...");
if numa::recursive::probe_recursive(&root_hints).await {
info!("recursive probe succeeded — self-sovereign mode");
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
let (pool, label) = recursive_pool();
(numa::config::UpstreamMode::Recursive, false, pool, label)
} else {
log::warn!("recursive probe failed — falling back to Quad9 DoH");
let client = reqwest::Client::builder()
@@ -149,55 +149,45 @@ async fn main() -> numa::Result<()> {
.unwrap_or_default();
let url = DOH_FALLBACK.to_string();
let label = url.clone();
(
numa::config::UpstreamMode::Forward,
false,
Upstream::Doh { url, client },
label,
)
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
(numa::config::UpstreamMode::Forward, false, pool, label)
}
}
numa::config::UpstreamMode::Recursive => {
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
let (pool, label) = recursive_pool();
(numa::config::UpstreamMode::Recursive, false, pool, label)
}
numa::config::UpstreamMode::Forward => {
let upstream_addr = if config.upstream.address.is_empty() {
system_dns
let addrs = if config.upstream.address.is_empty() {
let detected = system_dns
.default_upstream
.or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| {
info!("could not detect system DNS, falling back to Quad9 DoH");
DOH_FALLBACK.to_string()
})
});
vec![detected]
} else {
config.upstream.address.clone()
};
let upstream: Upstream = if upstream_addr.starts_with("https://") {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
Upstream::Doh {
url: upstream_addr,
client,
}
} else {
let addr: SocketAddr =
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr)
};
let label = upstream.to_string();
let primary: Vec<Upstream> = addrs
.iter()
.map(|s| parse_upstream(s, config.upstream.port))
.collect::<numa::Result<Vec<_>>>()?;
let fallback: Vec<Upstream> = config
.upstream
.fallback
.iter()
.map(|s| parse_upstream(s, config.upstream.port))
.collect::<numa::Result<Vec<_>>>()?;
let pool = UpstreamPool::new(primary, fallback);
let label = pool.label();
(
numa::config::UpstreamMode::Forward,
config.upstream.address.is_empty(),
upstream,
pool,
label,
)
}
@@ -253,6 +243,7 @@ async fn main() -> numa::Result<()> {
None
};
let doh_enabled = initial_tls.is_some();
let health_meta = numa::health::HealthMeta::build(
&resolved_data_dir,
config.dot.enabled,
@@ -262,6 +253,7 @@ async fn main() -> numa::Result<()> {
resolved_mode == numa::config::UpstreamMode::Recursive,
config.lan.enabled,
config.blocking.enabled,
doh_enabled,
);
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),
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
forwarding_rules,
upstream: Mutex::new(upstream),
upstream_pool: Mutex::new(pool),
upstream_auto,
upstream_port: config.upstream.port,
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,
health_meta,
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();
@@ -410,6 +404,9 @@ async fn main() -> numa::Result<()> {
g,
&format!("max {} entries", config.cache.max_entries),
);
if !config.cache.warm.is_empty() {
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
}
row(
"Blocking",
g,
@@ -436,6 +433,13 @@ async fn main() -> numa::Result<()> {
if config.dot.enabled {
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 {
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
let api_ctx = Arc::clone(&ctx);
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
// DoH upstreams are explicitly configured via URL, not auto-detected)
if ctx.upstream_auto
&& matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_))
&& (changed || tick.is_multiple_of(6))
{
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
let dns_info = numa::system_dns::discover_system_dns();
let new_addr = dns_info
.default_upstream
.or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| QUAD9_IP.to_string());
if let Ok(new_sock) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{
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;
let mut pool = ctx.upstream_pool.lock().unwrap();
if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
info!("upstream changed → {}", pool.label());
changed = true;
}
}
}
// Flush stale LAN peers on any network change
if changed {
@@ -738,3 +741,53 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
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.
/// 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 {
format!(
r#" <dict>
@@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
<key>ServerName</key>
<string>numa.numa</string>
</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>
<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>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::response::IntoResponse;
use axum::routing::any;
use axum::routing::{any, post};
use axum::Router;
use http_body_util::BodyExt;
use hyper::StatusCode;
@@ -18,6 +18,14 @@ use crate::ctx::ServerCtx;
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)]
struct ProxyState {
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
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 {
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
let acceptor =
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 {
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()
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())

View File

@@ -622,6 +622,54 @@ CONF
"10.0.0.1" \
"$($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 "=== Proxy TLS works with DoT enabled ==="