feat: numa setup-phone — QR-based mobile DoT onboarding

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) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-04-07 11:19:11 +03:00
parent 6f961c5ec2
commit 3e0e85a761
5 changed files with 317 additions and 9 deletions

51
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

267
src/setup_phone.rs Normal file
View File

@@ -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 `<data>` block.
fn pem_to_base64(pem: &str) -> String {
pem.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<String>()
}
/// 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::<Vec<_>>()
.chunks(52)
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadCertificateFileName</key>
<string>numa-ca.pem</string>
<key>PayloadContent</key>
<data>
{ca}
</data>
<key>PayloadDescription</key>
<string>Numa local Certificate Authority — required for DoT trust</string>
<key>PayloadDisplayName</key>
<string>Numa Local CA</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>B2C3D4E5-F6A7-8901-BCDE-F12345678901</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>TLS</string>
<key>ServerAddresses</key>
<array>
<string>{ip}</string>
</array>
<key>ServerName</key>
<string>numa.numa</string>
</dict>
<key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
<key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.dot</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>A1B2C3D4-E5F6-7890-ABCD-EF1234567890</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({ip})</string>
<key>PayloadDisplayName</key>
<string>Numa DNS</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.profile</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>F1E2D3C4-B5A6-7890-1234-567890ABCDEF</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
"#,
ca = ca_wrapped,
ip = lan_ip
)
}
fn render_qr(url: &str) -> Result<String, String> {
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
Ok(code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build())
}
async fn accept_loop(listener: TcpListener, profile: Arc<String>, count: Arc<AtomicUsize>) {
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, '█' | '▀' | '▄' | ' ')));
}
}