From 2afb8adc7d87233783b4fa5009a7b1c99ff1617b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 19:49:14 +0300 Subject: [PATCH 1/5] fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + src/mobileconfig.rs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1c510fd..649d86b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ CLAUDE.md docs/ site/blog/posts/ +ios/ diff --git a/src/mobileconfig.rs b/src/mobileconfig.rs index 513d198..4ef1740 100644 --- a/src/mobileconfig.rs +++ b/src/mobileconfig.rs @@ -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#" @@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String { ServerName numa.numa + OnDemandRules + + + Action + Connect + InterfaceTypeMatch + WiFi + + + Action + Disconnect + + PayloadDescription - Routes all DNS queries through Numa over DNS-over-TLS + Routes DNS queries through Numa over DoT when on Wi-Fi PayloadDisplayName Numa DNS-over-TLS PayloadIdentifier -- 2.34.1 From a2a8fb8c59ebaf2c369c86dda7fd87232edc2e32 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 20:18:44 +0300 Subject: [PATCH 2/5] 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) --- Cargo.toml | 2 +- site/dashboard.html | 47 +++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 30 +++++++++++++++++++++++++++++ src/ctx.rs | 2 ++ src/dot.rs | 2 ++ src/main.rs | 2 ++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4b881c5..95e094b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/site/dashboard.html b/site/dashboard.html index c78c48f..f68fc27 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -554,6 +554,15 @@ body {
DNS that governs itself
+
@@ -788,6 +797,36 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } +let qrLoaded = 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 && !qrLoaded) { + 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 = `Install Profile`; + } else { + fetch(API + '/qr').then(r => r.text()).then(svg => { + document.getElementById('qrContainer').innerHTML = svg; + qrLoaded = true; + }).catch(() => { + document.getElementById('qrContainer').innerHTML = '
Could not load QR
'; + }); + } + } +} +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 +1097,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; diff --git a/src/api.rs b/src/api.rs index fed7d5b..56e5b75 100644 --- a/src/api.rs +++ b/src/api.rs @@ -57,6 +57,7 @@ pub fn router(ctx: Arc) -> 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, @@ -551,6 +559,10 @@ async fn stats(State(ctx): State>) -> Json { 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 +943,22 @@ pub async fn serve_ca(State(ctx): State>) -> Result>) -> Result { + 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::() + .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")], svg)) +} + async fn serve_fonts_css() -> impl IntoResponse { ( [ @@ -1005,6 +1033,8 @@ mod tests { dnssec_strict: false, health_meta: crate::health::HealthMeta::test_fixture(), ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, }) } diff --git a/src/ctx.rs b/src/ctx.rs index cf3522d..6b774eb 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -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, + pub mobile_enabled: bool, + pub mobile_port: u16, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, diff --git a/src/dot.rs b/src/dot.rs index 32d32ba..3ed47ba 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -383,6 +383,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(); diff --git a/src/main.rs b/src/main.rs index 70bc3f9..62acb69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,6 +319,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(); -- 2.34.1 From c1a988bfe14a36df6acca2250d15af53b5103101 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 20:24:11 +0300 Subject: [PATCH 3/5] 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) --- site/dashboard.html | 4 +--- src/api.rs | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index f68fc27..cad3743 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -797,13 +797,12 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } -let qrLoaded = 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 && !qrLoaded) { + if (!isOpen) { if (window.innerWidth <= 700) { document.getElementById('qrContainer').style.display = 'none'; const linkEl = document.getElementById('phoneSetupLink'); @@ -813,7 +812,6 @@ function togglePhoneSetup() { } else { fetch(API + '/qr').then(r => r.text()).then(svg => { document.getElementById('qrContainer').innerHTML = svg; - qrLoaded = true; }).catch(() => { document.getElementById('qrContainer').innerHTML = '
Could not load QR
'; }); diff --git a/src/api.rs b/src/api.rs index 56e5b75..e036506 100644 --- a/src/api.rs +++ b/src/api.rs @@ -956,7 +956,10 @@ async fn serve_qr(State(ctx): State>) -> Result impl IntoResponse { -- 2.34.1 From bc44e8090ec98a218aeef87a086400c2986a314c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:17:45 +0300 Subject: [PATCH 4/5] style: rustfmt serve_qr response tuple Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index e036506..2e66931 100644 --- a/src/api.rs +++ b/src/api.rs @@ -956,10 +956,13 @@ async fn serve_qr(State(ctx): State>) -> Result impl IntoResponse { -- 2.34.1 From 44fb5b3bcf9d43e570a0b08c3702ec2c3a55588f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:22:43 +0300 Subject: [PATCH 5/5] 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) --- site/dashboard.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index cad3743..5fa9777 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -560,7 +560,12 @@ body {
Phone Setup
-
Scan to install Numa DNS on your phone.
+
+ 1. Scan QR → allow download
+ 2. Settings → Profile Downloaded → Install
+ 3. Settings → General → About →
+    Certificate Trust Settings → toggle ON +
-- 2.34.1