From 3e0e85a761403993be1a3fccaefada4280f6e4cf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 11:19:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20numa=20setup-phone=20=E2=80=94=20QR-bas?= =?UTF-8?q?ed=20mobile=20DoT=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CLI subcommand that generates a one-time mobileconfig profile containing both the Numa local CA (as a com.apple.security.root payload) and the DoT DNS settings, then serves it via a temporary HTTP server and prints a scannable QR code in the terminal. Flow: 1. User runs `numa setup-phone` (no sudo needed) 2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem 3. Builds combined mobileconfig (CA trust + DoT) 4. Renders QR code with qrcode crate (Unicode block characters) 5. Serves the profile on port 8765, stays open until Ctrl+C 6. Counts successful downloads (multi-device households) Important caveat documented in instructions: even with the CA bundled in the profile, iOS still requires the user to manually enable trust in Settings → General → About → Certificate Trust Settings. Verified on a real iPhone. Stable PayloadIdentifiers/UUIDs ensure re-running replaces the existing profile on iOS rather than accumulating duplicates. - New module: src/setup_phone.rs (~270 lines) - New CLI subcommand: `numa setup-phone` - New dependency: qrcode = "0.14" (default-features = false) - tokio "signal" feature added for Ctrl+C handling - 3 unit tests: PEM stripping, mobileconfig generation, QR rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 51 +++++++-- Cargo.toml | 3 +- src/lib.rs | 1 + src/main.rs | 4 + src/setup_phone.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 src/setup_phone.rs diff --git a/Cargo.lock b/Cargo.lock index 01b6abf..72223f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -522,6 +522,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -757,9 +767,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -772,6 +782,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1145,6 +1156,7 @@ dependencies = [ "hyper", "hyper-util", "log", + "qrcode", "rcgen", "reqwest", "ring", @@ -1219,6 +1231,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "plotters" version = "0.3.7" @@ -1295,6 +1313,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quinn" version = "0.11.9" @@ -1674,6 +1698,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1833,14 +1867,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1848,9 +1883,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9aaeb27..79a42a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool categories = ["network-programming", "development-tools"] [dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] } axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -30,6 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" rustls-pemfile = "2.2.0" +qrcode = { version = "0.14", default-features = false } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/src/lib.rs b/src/lib.rs index 6455506..3677072 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod question; pub mod record; pub mod recursive; pub mod service_store; +pub mod setup_phone; pub mod srtt; pub mod stats; pub mod system_dns; diff --git a/src/main.rs b/src/main.rs index b335016..77b082c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,9 @@ async fn main() -> numa::Result<()> { } }; } + "setup-phone" => { + return numa::setup_phone::run().await.map_err(|e| e.into()); + } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); let config_path = std::env::args() @@ -85,6 +88,7 @@ async fn main() -> numa::Result<()> { eprintln!(" service status Check if the service is running"); eprintln!(" lan on Enable LAN service discovery (mDNS)"); eprintln!(" lan off Disable LAN service discovery"); + eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone"); eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); diff --git a/src/setup_phone.rs b/src/setup_phone.rs new file mode 100644 index 0000000..4d2e602 --- /dev/null +++ b/src/setup_phone.rs @@ -0,0 +1,267 @@ +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use qrcode::render::unicode; +use qrcode::QrCode; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +const SETUP_PORT: u16 = 8765; +const RUST_OK_HEADERS: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/x-apple-aspen-config\r\nContent-Disposition: attachment; filename=\"numa.mobileconfig\"\r\nConnection: close\r\nContent-Length: "; +const RUST_NOT_FOUND: &str = + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + +/// Strip the PEM header/footer and newlines from a CA cert, leaving raw base64 +/// for embedding in a plist `` block. +fn pem_to_base64(pem: &str) -> String { + pem.lines() + .filter(|line| !line.starts_with("-----")) + .collect::() +} + +/// Build a combined `.mobileconfig` containing: +/// 1. Root CA payload — installs and trusts the Numa local CA +/// 2. DNS payload — points the device at Numa over DoT +/// +/// UUIDs and PayloadIdentifiers are intentionally fixed (not randomized) so +/// that re-running `numa setup-phone` after an IP change replaces the existing +/// profile rather than accumulating duplicates in iOS Settings. +fn build_mobileconfig(lan_ip: Ipv4Addr, ca_pem: &str) -> String { + let ca_base64 = pem_to_base64(ca_pem); + + // Wrap base64 at 52 chars per line for plist readability (matches Apple convention) + let ca_wrapped: String = ca_base64 + .chars() + .collect::>() + .chunks(52) + .map(|chunk| format!("\t\t\t{}", chunk.iter().collect::())) + .collect::>() + .join("\n"); + + format!( + r#" + + + + PayloadContent + + + PayloadCertificateFileName + numa-ca.pem + PayloadContent + +{ca} + + PayloadDescription + Numa local Certificate Authority — required for DoT trust + PayloadDisplayName + Numa Local CA + PayloadIdentifier + com.numa.dns.ca + PayloadType + com.apple.security.root + PayloadUUID + B2C3D4E5-F6A7-8901-BCDE-F12345678901 + PayloadVersion + 1 + + + DNSSettings + + DNSProtocol + TLS + ServerAddresses + + {ip} + + ServerName + numa.numa + + PayloadDescription + Routes all DNS queries through Numa over DNS-over-TLS + PayloadDisplayName + Numa DNS-over-TLS + PayloadIdentifier + com.numa.dns.dot + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + A1B2C3D4-E5F6-7890-ABCD-EF1234567890 + PayloadVersion + 1 + + + PayloadDescription + Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({ip}) + PayloadDisplayName + Numa DNS + PayloadIdentifier + com.numa.dns.profile + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + F1E2D3C4-B5A6-7890-1234-567890ABCDEF + PayloadVersion + 1 + + +"#, + ca = ca_wrapped, + ip = lan_ip + ) +} + +fn render_qr(url: &str) -> Result { + let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?; + Ok(code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build()) +} + +async fn accept_loop(listener: TcpListener, profile: Arc, count: Arc) { + loop { + let (mut stream, peer) = match listener.accept().await { + Ok(c) => c, + Err(_) => continue, + }; + + let profile = Arc::clone(&profile); + let count = Arc::clone(&count); + + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = match tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf)).await + { + Ok(Ok(n)) => n, + _ => return, + }; + + let request = String::from_utf8_lossy(&buf); + if request.starts_with("GET /setup") || request.starts_with("GET / ") { + let body = profile.as_bytes(); + let mut response = + format!("{}{}\r\n\r\n", RUST_OK_HEADERS, body.len()).into_bytes(); + response.extend_from_slice(body); + let _ = stream.write_all(&response).await; + let _ = stream.flush().await; + let n = count.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + " \x1b[32m✓\x1b[0m Profile downloaded by {} ({} total)", + peer.ip(), + n + ); + } else { + let _ = stream.write_all(RUST_NOT_FOUND.as_bytes()).await; + } + }); + } +} + +/// Run the `numa setup-phone` flow. +pub async fn run() -> Result<(), String> { + let lan_ip = crate::lan::detect_lan_ip() + .ok_or("could not detect LAN IP — are you connected to a network?")?; + + let ca_path: PathBuf = crate::data_dir().join("ca.pem"); + let ca_pem = std::fs::read_to_string(&ca_path).map_err(|e| { + format!( + "could not read CA at {}: {} — is Numa installed and has the service started at least once?", + ca_path.display(), + e + ) + })?; + + let profile = build_mobileconfig(lan_ip, &ca_pem); + let url = format!("http://{}:{}/setup", lan_ip, SETUP_PORT); + let qr = render_qr(&url)?; + + let bind_addr = SocketAddr::from(([0, 0, 0, 0], SETUP_PORT)); + let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + format!( + "could not bind setup server on port {}: {} — is another setup running?", + SETUP_PORT, e + ) + })?; + + eprintln!(); + eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m\n"); + eprintln!(" Serving setup profile at: \x1b[36m{}\x1b[0m\n", url); + for line in qr.lines() { + eprintln!(" {}", line); + } + eprintln!(); + eprintln!(" \x1b[1mOn your iPhone:\x1b[0m"); + eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner"); + eprintln!(" 2. Allow the download when Safari asks"); + eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top"); + eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)"); + eprintln!(" 4. Tap Install (top right), enter passcode, Install again"); + eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m"); + eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work"); + eprintln!(); + eprintln!( + " \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your", + lan_ip + ); + eprintln!(" laptop changes networks, re-run this command — iOS will replace the"); + eprintln!(" existing profile automatically."); + eprintln!(); + eprintln!(" Waiting for download (Ctrl+C to exit)..."); + eprintln!(); + + let count = Arc::new(AtomicUsize::new(0)); + let server = tokio::spawn(accept_loop(listener, Arc::new(profile), Arc::clone(&count))); + + let _ = tokio::signal::ctrl_c().await; + server.abort(); + eprintln!(); + let total = count.load(Ordering::Relaxed); + if total > 0 { + eprintln!( + " Setup ended — {} download{} served", + total, + if total == 1 { "" } else { "s" } + ); + } else { + eprintln!(" Setup cancelled — no downloads served"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pem_to_base64_strips_headers() { + let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n"; + assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL"); + } + + #[test] + fn mobileconfig_contains_ip_and_ca() { + let pem = + "-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n"; + let config = build_mobileconfig(Ipv4Addr::new(192, 168, 1, 100), pem); + assert!(config.contains("192.168.1.100")); + assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST")); + assert!(config.contains("com.apple.security.root")); + assert!(config.contains("com.apple.dnsSettings.managed")); + assert!(config.contains("DNSProtocol")); + } + + #[test] + fn render_qr_produces_unicode() { + let qr = render_qr("http://192.168.1.9:8765/setup").unwrap(); + assert!(!qr.is_empty()); + // Dense1x2 uses these block characters + assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' '))); + } +}