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/numa.toml b/numa.toml index 77ba231..4389fdb 100644 --- a/numa.toml +++ b/numa.toml @@ -102,3 +102,22 @@ tld = "numa" # enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) # broadcast_interval_secs = 30 # peer_timeout_secs = 90 + +# Mobile API — persistent HTTP listener serving read-only routes +# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable +# port. Consumed by the iOS/Android companion apps for discovery and +# profile fetching, and by `numa setup-phone` for QR-based onboarding. +# +# Opt-in because the listener binds to the LAN by default. None of the +# exposed routes are cryptographically sensitive (no private keys, no +# state mutations, all idempotent GETs), but enabling it does add a new +# listener to any device on the LAN that scans port 8765. +# +# Safe for home LANs. Think twice before enabling on untrusted LANs +# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network +# could run a competing Numa instance that shadows yours via mDNS and +# trick companion apps into installing their profile instead of yours. +[mobile] +enabled = true # opt-in to the mobile API listener +# port = 8765 # default; matches Discovery.swift defaultAPIPort +# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only diff --git a/site/dashboard.html b/site/dashboard.html index ffc6e0d..c78c48f 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -288,6 +288,7 @@ body { .path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } .path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); } +.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; } /* Sidebar panels */ .sidebar { @@ -787,6 +788,13 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } +function shortSrc(addr) { + if (!addr) return ''; + const ip = addr.replace(/:\d+$/, ''); + if (ip === '127.0.0.1' || ip === '::1') return 'localhost'; + return ip; +} + function formatRemaining(secs) { if (secs == null) return 'permanent'; if (secs < 60) return `${secs}s left`; @@ -912,8 +920,8 @@ function applyLogFilter() { ? ` ` : ''; return ` - - ${formatTime(e.timestamp_epoch)} + + ${formatTime(e.timestamp_epoch)}
${shortSrc(e.src)} ${e.query_type} ${e.domain}${allowBtn} ${e.path} diff --git a/src/api.rs b/src/api.rs index 59938b4..fed7d5b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -592,8 +592,19 @@ async fn flush_cache_domain( StatusCode::NO_CONTENT } -async fn health() -> Json { - Json(serde_json::json!({ "status": "ok" })) +/// Enriched `/health` handler shared between the main API and the mobile API. +/// +/// Returns the cached `HealthMeta` assembled with live fields (LAN IP, +/// uptime). Backward compatible with the previous minimal response in +/// that `status` is still the first field and `"ok"` is still the value. +/// The iOS companion app's `HealthInfo` Swift struct decodes the full +/// response; any HTTP client asserting only on `"status"` keeps working. +pub async fn health(State(ctx): State>) -> Json { + let lan_ip = Some(*ctx.lan_ip.lock().unwrap()); + Json(crate::health::HealthResponse::build( + &ctx.health_meta, + lan_ip, + )) } // --- Blocking handlers --- @@ -905,12 +916,8 @@ async fn remove_route( } } -async fn serve_ca(State(ctx): State>) -> Result { - let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME); - let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map_err(|_| StatusCode::NOT_FOUND)?; +pub async fn serve_ca(State(ctx): State>) -> Result { + let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; Ok(( [ (header::CONTENT_TYPE, "application/x-pem-file"), @@ -920,7 +927,7 @@ async fn serve_ca(State(ctx): State>) -> Result String { "0.0.0.0".to_string() } +/// Configuration for the mobile API — a persistent HTTP listener that +/// serves a read-only subset of routes (`/health`, `/ca.pem`, +/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for +/// consumption by the iOS/Android companion apps. +/// +/// Unlike the main API (port 5380, localhost-only by default, supports +/// state-mutating routes), the mobile API is safe to expose on the LAN +/// because every route is idempotent and read-only. +#[derive(Deserialize, Clone)] +pub struct MobileConfig { + /// If true, spawn the mobile API listener at startup. **Default false.** + /// Opt-in because the listener binds to the LAN by default and exposes + /// a few read-only endpoints to any device on the same network (`/health`, + /// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are + /// cryptographically sensitive (the CA private key is never served), + /// but users should enable this explicitly rather than have a new + /// LAN-reachable port appear after an upgrade. + #[serde(default)] + pub enabled: bool, + /// Port for the mobile API. Default 8765. + #[serde(default = "default_mobile_port")] + pub port: u16, + /// Bind address for the mobile API. Default "0.0.0.0" (all interfaces) + /// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict + /// to localhost — useful if you're running behind another front-end. + #[serde(default = "default_mobile_bind_addr")] + pub bind_addr: String, +} + +impl Default for MobileConfig { + fn default() -> Self { + MobileConfig { + enabled: false, + port: default_mobile_port(), + bind_addr: default_mobile_bind_addr(), + } + } +} + +fn default_mobile_port() -> u16 { + 8765 +} + +fn default_mobile_bind_addr() -> String { + "0.0.0.0".to_string() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ctx.rs b/src/ctx.rs index 17a4979..cf3522d 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -18,6 +18,7 @@ use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; use crate::forward::{forward_query, Upstream}; use crate::header::ResultCode; +use crate::health::HealthMeta; use crate::lan::PeerStore; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; @@ -60,6 +61,15 @@ pub struct ServerCtx { pub inflight: Mutex, pub dnssec_enabled: bool, pub dnssec_strict: bool, + /// Cached health metadata (version, hostname, DoT config, CA + /// fingerprint, features). Shared between the main and mobile + /// API `/health` handlers. Built once at startup in `main.rs`. + pub health_meta: HealthMeta, + /// CA certificate in PEM form, cached at startup. `None` if no + /// TLS-using feature is enabled and the CA hasn't been generated. + /// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig` + /// handlers to avoid per-request disk I/O on the hot path. + pub ca_pem: Option, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, diff --git a/src/dot.rs b/src/dot.rs index a09b160..32d32ba 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -381,6 +381,8 @@ mod tests { inflight: Mutex::new(HashMap::new()), dnssec_enabled: false, dnssec_strict: false, + health_meta: crate::health::HealthMeta::test_fixture(), + ca_pem: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..b2359c4 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,254 @@ +//! Health metadata and `/health` response shape, shared between the main +//! HTTP API and the mobile API. +//! +//! The static fields (version, hostname, DoT config, CA fingerprint, +//! feature list) are computed once at startup and stored in [`HealthMeta`] +//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live. +//! Both handlers call [`HealthResponse::build`] to assemble the JSON +//! response from `HealthMeta` + live inputs. +//! +//! JSON schema is documented in `docs/implementation/ios-companion-app.md` +//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical +//! consumer; any change to this response must keep that struct decoding +//! cleanly (all consumed fields are optional on the Swift side, but +//! `lan_ip` is load-bearing for the pipeline). + +use std::net::Ipv4Addr; +use std::path::Path; +use std::time::Instant; + +use ring::digest::{digest, SHA256}; +use serde::Serialize; + +/// Immutable health metadata cached on `ServerCtx`. Built once at startup +/// from config + file-system state (CA cert). +#[derive(Clone)] +pub struct HealthMeta { + pub version: &'static str, + pub hostname: String, + pub sni: String, + pub dot_enabled: bool, + pub dot_port: u16, + pub api_port: u16, + pub ca_fingerprint_sha256: Option, + pub features: Vec, + pub started_at: Instant, +} + +impl HealthMeta { + /// Minimal `HealthMeta` for unit tests that construct a `ServerCtx` + /// without needing the real startup flow (CA file reads, hostname + /// detection, etc.). Deterministic values so test JSON assertions + /// stay stable. + #[cfg(test)] + pub fn test_fixture() -> Self { + HealthMeta { + version: env!("CARGO_PKG_VERSION"), + hostname: "test-host".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: false, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: None, + features: vec![], + started_at: Instant::now(), + } + } + + /// Build a new HealthMeta from config + startup-time environment. + /// Call once at server boot; the returned value is cheap to clone + /// (small number of short strings) and lives on `ServerCtx`. + /// + /// The argument count is deliberate — each flag corresponds to a + /// specific config value and is clearly named at the call site. + /// Collapsing into a struct hides nothing meaningful for a one-call + /// initializer. + #[allow(clippy::too_many_arguments)] + pub fn build( + data_dir: &Path, + dot_enabled: bool, + dot_port: u16, + api_port: u16, + dnssec_enabled: bool, + recursive_enabled: bool, + mdns_enabled: bool, + blocking_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 dot_enabled { + features.push("dot".to_string()); + } + if recursive_enabled { + features.push("recursive".to_string()); + } + if blocking_enabled { + features.push("blocking".to_string()); + } + if mdns_enabled { + features.push("mdns".to_string()); + } + if dnssec_enabled { + features.push("dnssec".to_string()); + } + + HealthMeta { + version: env!("CARGO_PKG_VERSION"), + hostname: crate::hostname(), + sni: "numa.numa".to_string(), + dot_enabled, + dot_port, + api_port, + ca_fingerprint_sha256, + features, + started_at: Instant::now(), + } + } +} + +/// JSON response shape returned by `GET /health` on both main and mobile APIs. +/// +/// Fields are organized to match the iOS companion app's +/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2. +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub version: &'static str, + pub uptime_secs: u64, + pub hostname: String, + pub lan_ip: Option, + pub sni: String, + pub dot: DotBlock, + pub api: ApiBlock, + pub ca: CaBlock, + pub features: Vec, +} + +#[derive(Serialize)] +pub struct DotBlock { + pub enabled: bool, + pub port: Option, +} + +#[derive(Serialize)] +pub struct ApiBlock { + pub port: u16, +} + +#[derive(Serialize)] +pub struct CaBlock { + pub present: bool, + pub fingerprint_sha256: Option, +} + +impl HealthResponse { + /// Assemble a fresh `HealthResponse` from the cached metadata and + /// the current LAN IP (which may change across network transitions). + /// Pass `None` for `lan_ip` if detection fails — the response still + /// returns 200 OK, just without the LAN address. + pub fn build(meta: &HealthMeta, lan_ip: Option) -> Self { + HealthResponse { + status: "ok", + version: meta.version, + uptime_secs: meta.started_at.elapsed().as_secs(), + hostname: meta.hostname.clone(), + lan_ip: lan_ip.map(|ip| ip.to_string()), + sni: meta.sni.clone(), + dot: DotBlock { + enabled: meta.dot_enabled, + port: if meta.dot_enabled { + Some(meta.dot_port) + } else { + None + }, + }, + api: ApiBlock { + port: meta.api_port, + }, + ca: CaBlock { + present: meta.ca_fingerprint_sha256.is_some(), + fingerprint_sha256: meta.ca_fingerprint_sha256.clone(), + }, + features: meta.features.clone(), + } + } +} + +/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a +/// lowercase hex string, or None if the file doesn't exist or can't be read. +/// +/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based +/// fingerprint would require parsing the PEM → DER → extracting +/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit +/// for our use case (the iOS app uses the fingerprint only for display +/// and to detect rotation). +fn compute_ca_fingerprint(ca_path: &Path) -> Option { + let pem = std::fs::read(ca_path).ok()?; + let hash = digest(&SHA256, &pem); + let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect(); + Some(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_response_contains_required_fields() { + let meta = HealthMeta { + version: "0.10.0", + hostname: "test-host".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: true, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: Some("abcd1234".to_string()), + features: vec!["dot".to_string(), "dnssec".to_string()], + started_at: Instant::now(), + }; + + let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50))); + let json = serde_json::to_string(&response).unwrap(); + + assert!(json.contains("\"status\":\"ok\"")); + assert!(json.contains("\"version\":\"0.10.0\"")); + assert!(json.contains("\"hostname\":\"test-host\"")); + assert!(json.contains("\"lan_ip\":\"192.168.1.50\"")); + assert!(json.contains("\"sni\":\"numa.numa\"")); + assert!(json.contains("\"port\":853")); + assert!(json.contains("\"port\":8765")); + assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\"")); + assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]")); + } + + #[test] + fn health_response_omits_dot_port_when_disabled() { + let meta = HealthMeta { + version: "0.10.0", + hostname: "t".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: false, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: None, + features: vec![], + started_at: Instant::now(), + }; + + let response = HealthResponse::build(&meta, None); + let json = serde_json::to_string(&response).unwrap(); + + assert!(json.contains("\"enabled\":false")); + assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}")); + assert!(json.contains("\"present\":false")); + assert!(json.contains("\"lan_ip\":null")); + } + + #[test] + fn ca_fingerprint_returns_none_for_missing_file() { + let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem")); + assert!(fp.is_none()); + } +} diff --git a/src/lan.rs b/src/lan.rs index db210e9..8d0b9cf 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -9,6 +9,7 @@ use crate::buffer::BytePacketBuffer; use crate::config::LanConfig; use crate::ctx::ServerCtx; use crate::header::DnsHeader; +use crate::health::HealthMeta; use crate::question::{DnsQuestion, QueryType}; // --- Constants --- @@ -18,6 +19,18 @@ const MDNS_PORT: u16 = 5353; const SERVICE_TYPE: &str = "_numa._tcp.local"; const MDNS_TTL: u32 = 120; +// TXT record key prefixes (including the trailing `=`). Shared between +// the sender (`build_announcement`) and the receiver (`parse_mdns_response`) +// to prevent drift — both sides match on the same literal, not on two +// independent string constants that could diverge. +const TXT_SERVICES: &str = "services="; +const TXT_ID: &str = "id="; +const TXT_VERSION: &str = "version="; +const TXT_API_PORT: &str = "api_port="; +const TXT_PROTO: &str = "proto="; +const TXT_DOT_PORT: &str = "dot_port="; +const TXT_CA_FP: &str = "ca_fp="; + // --- Peer Store --- pub struct PeerStore { @@ -97,14 +110,16 @@ pub fn detect_lan_ip() -> Option { } } +/// Short hostname for mDNS instance names (`._numa._tcp.local`). +/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`. +/// Uses the shared `crate::hostname()` helper as the source. fn get_hostname() -> String { - std::process::Command::new("hostname") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| "numa".to_string()) + crate::hostname() + .split('.') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("numa") + .to_string() } /// Generate a per-process instance ID for self-filtering on multi-instance hosts @@ -168,13 +183,22 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { .map(|e| (e.name.clone(), e.target_port)) .collect() }; - if services.is_empty() { - continue; - } + // Note: we always announce ourselves, even when the + // services list is empty. The announcement still carries + // the mobile API port + version + CA fingerprint in TXT, + // which is what the iOS companion app browses for via + // NWBrowser on `_numa._tcp.local`. Other Numa peers + // receive these empty-services announcements too and + // correctly ignore them in parse_mdns_response (the + // receiver only processes when services is non-empty). let current_ip = *sender_ctx.lan_ip.lock().unwrap(); - if let Ok(pkt) = - build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) - { + if let Ok(pkt) = build_announcement( + &sender_hostname, + current_ip, + &services, + &sender_instance_id, + &sender_ctx.health_meta, + ) { let _ = sender_socket.send_to(pkt.filled(), dest).await; } } @@ -240,6 +264,7 @@ fn build_announcement( ip: Ipv4Addr, services: &[(String, u16)], inst_id: &str, + meta: &HealthMeta, ) -> crate::Result { let mut buf = BytePacketBuffer::new(); let instance_name = format!("{}._numa._tcp.local", hostname); @@ -260,7 +285,11 @@ fn build_announcement( patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // SRV: ._numa._tcp.local → .local - // Port in SRV is informational; actual service ports are in TXT + // Port = mobile API port, which is what the iOS companion app resolves + // the SRV record for. Legacy Numa peers don't read the SRV port (see + // parse_mdns_response — it only uses TXT services= for peer discovery), + // so changing the SRV port from "first service's port" to the mobile + // API port is backwards compatible. write_record_header( &mut buf, &instance_name, @@ -273,11 +302,13 @@ fn build_announcement( let rdata_start = buf.pos(); buf.write_u16(0)?; // priority buf.write_u16(0)?; // weight - buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display + buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app buf.write_qname(&host_local)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; - // TXT: services + instance ID for self-filtering + // TXT: legacy peer-discovery entries (services, id) + enriched entries + // for the iOS companion app (version, api_port, proto, dot_port, ca_fp). + // All in one TXT RRset per mDNS convention. write_record_header( &mut buf, &instance_name, @@ -293,8 +324,21 @@ fn build_announcement( .map(|(name, port)| format!("{}:{}", name, port)) .collect::>() .join(","); - write_txt_string(&mut buf, &format!("services={}", svc_str))?; - write_txt_string(&mut buf, &format!("id={}", inst_id))?; + // Legacy peer-discovery entries (consumed by parse_mdns_response) + write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?; + // Enriched entries (consumed by the iOS/Android companion apps) + write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?; + if meta.dot_enabled { + write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?; + } else { + write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?; + } + if let Some(fp) = &meta.ca_fingerprint_sha256 { + write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?; + } patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // A: .local → IP @@ -408,7 +452,7 @@ fn parse_mdns_response(data: &[u8]) -> Option { break; } if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { - if let Some(val) = txt.strip_prefix("services=") { + if let Some(val) = txt.strip_prefix(TXT_SERVICES) { let svcs: Vec<(String, u16)> = val .split(',') .filter_map(|s| { @@ -421,7 +465,7 @@ fn parse_mdns_response(data: &[u8]) -> Option { if !svcs.is_empty() { txt_services = Some(svcs); } - } else if let Some(id) = txt.strip_prefix("id=") { + } else if let Some(id) = txt.strip_prefix(TXT_ID) { peer_instance_id = Some(id.to_string()); } } diff --git a/src/lib.rs b/src/lib.rs index 6455506..066c7ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,10 @@ pub mod dnssec; pub mod dot; pub mod forward; pub mod header; +pub mod health; pub mod lan; +pub mod mobile_api; +pub mod mobileconfig; pub mod override_store; pub mod packet; pub mod proxy; @@ -17,6 +20,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; @@ -25,6 +29,20 @@ pub mod tls; pub type Error = Box; pub type Result = std::result::Result; +/// Detect the machine hostname via the `hostname` command. Returns the +/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command +/// fails. Call sites that need the short form (e.g., mDNS instance +/// names) should truncate at the first `.`. +pub fn hostname() -> String { + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "numa".to_string()) +} + /// Shared config directory for persistent data (services.json, etc). /// Unix users: ~/.config/numa/ /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa diff --git a/src/main.rs b/src/main.rs index b335016..70bc3f9 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,12 +88,27 @@ 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"); return Ok(()); } - _ => {} + _ => { + if !arg1.is_empty() + && arg1 != "run" + && !arg1.contains('/') + && !arg1.contains('\\') + && !arg1.ends_with(".toml") + { + eprintln!( + "\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n", + arg1 + ); + eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands."); + std::process::exit(1); + } + } } let config_path = if arg1.is_empty() || arg1 == "run" { @@ -235,6 +253,19 @@ async fn main() -> numa::Result<()> { None }; + let health_meta = numa::health::HealthMeta::build( + &resolved_data_dir, + config.dot.enabled, + config.dot.port, + config.mobile.port, + config.dnssec.enabled, + resolved_mode == numa::config::UpstreamMode::Recursive, + config.lan.enabled, + config.blocking.enabled, + ); + + let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); + let socket = match UdpSocket::bind(&config.server.bind_addr).await { Ok(s) => s, Err(e) => { @@ -286,6 +317,8 @@ async fn main() -> numa::Result<()> { inflight: std::sync::Mutex::new(std::collections::HashMap::new()), dnssec_enabled: config.dnssec.enabled, dnssec_strict: config.dnssec.strict, + health_meta, + ca_pem, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); @@ -469,6 +502,21 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Spawn Mobile API listener (read-only subset for iOS/Android companion + // apps, LAN-bound by default so phones can reach it). Only idempotent + // GETs; no state-mutating routes are exposed here regardless of + // the main API's bind address. + if config.mobile.enabled { + let mobile_ctx = Arc::clone(&ctx); + let mobile_bind = config.mobile.bind_addr.clone(); + let mobile_port = config.mobile.port; + tokio::spawn(async move { + if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await { + log::warn!("Mobile API listener failed: {}", e); + } + }); + } + let proxy_bind: std::net::Ipv4Addr = config .proxy .bind_addr diff --git a/src/mobile_api.rs b/src/mobile_api.rs new file mode 100644 index 0000000..8925846 --- /dev/null +++ b/src/mobile_api.rs @@ -0,0 +1,107 @@ +//! Mobile API — persistent HTTP listener for iOS/Android companion apps. +//! +//! Read-only subset of Numa's HTTP surface served on a separate port +//! (default 8765) bound to the LAN. Unlike the main API on port 5380 +//! (which defaults to `127.0.0.1` and serves mutating routes like +//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener +//! is safe to expose on the LAN because every route is idempotent and +//! read-only. +//! +//! Routes (all GET): +//! +//! - `/health` — enriched status + metadata, shares the handler with the +//! main API via `crate::api::health` +//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the +//! main API via `crate::api::serve_ca` +//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode) +//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override) +//! +//! The mobile API does NOT include the mutating routes (overrides, cache +//! flush, blocking toggle, service CRUD, etc.). Even if a user sets +//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on +//! port 5380; the mobile API on port 8765 never serves them. This is the +//! primary security boundary: anything exposed to the LAN is read-only. + +use std::net::Ipv4Addr; +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use log::info; + +use crate::ctx::ServerCtx; +use crate::mobileconfig::{build_mobileconfig, ProfileMode}; + +/// Content-Disposition for the full CA + DNS profile download. +const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\""; + +/// Content-Disposition for the CA-only profile download. +const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\""; + +/// Build the axum router for the mobile API. +/// +/// Shares handler functions with the main API where possible (`health`, +/// `serve_ca`) so the response shapes are identical across both ports. +pub fn router(ctx: Arc) -> Router { + Router::new() + .route("/health", get(crate::api::health)) + .route("/ca.pem", get(crate::api::serve_ca)) + .route("/mobileconfig", get(serve_full_mobileconfig)) + .route("/ca.mobileconfig", get(serve_ca_only_mobileconfig)) + .with_state(ctx) +} + +/// Start the mobile API listener on `bind_addr:port`. Runs until the +/// caller cancels the spawned task. Logs the URL on successful bind. +pub async fn start(ctx: Arc, bind_addr: String, port: u16) -> crate::Result<()> { + let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!("Mobile API listening on http://{}", addr); + + let app = router(ctx); + axum::serve(listener, app).await?; + + Ok(()) +} + +/// Serve the full mobileconfig profile (CA + DNS settings), with the +/// DNS payload pointing at the current LAN IP. Each request reads the +/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the +/// laptop's current network state. +async fn serve_full_mobileconfig( + State(ctx): State>, +) -> Result { + let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; + let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap(); + let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem); + Ok(profile_response(profile, FULL_PROFILE_DISPOSITION)) +} + +/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but +/// does NOT change the device's DNS settings. Used by the iOS companion +/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager` +/// and only needs the system trust store to accept Numa's self-signed cert. +async fn serve_ca_only_mobileconfig( + State(ctx): State>, +) -> Result { + let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; + let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem); + Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION)) +} + +/// Shared response constructor for both mobileconfig variants. +/// Identical headers; only the Content-Disposition filename differs. +fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/x-apple-aspen-config"), + (header::CONTENT_DISPOSITION, disposition), + (header::CACHE_CONTROL, "no-store"), + ], + profile, + ) +} diff --git a/src/mobileconfig.rs b/src/mobileconfig.rs new file mode 100644 index 0000000..513d198 --- /dev/null +++ b/src/mobileconfig.rs @@ -0,0 +1,294 @@ +//! Apple `.mobileconfig` profile generator. +//! +//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap +//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered +//! via `format!` — no plist crate dependency, deterministic output, small +//! binary footprint. +//! +//! Two modes: +//! +//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing +//! at a specific LAN IP over DoT. This is what `numa setup-phone` has +//! always produced — the user scans a QR, installs this profile, and the +//! phone is configured for DoT through Numa in a single step (after the +//! iOS Certificate Trust Settings toggle, which is a separate system +//! gate we can't bypass). +//! +//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used +//! by the future iOS companion app flow where `NEDNSSettingsManager` +//! configures DNS programmatically and we only need the system trust +//! store to accept Numa's DoT cert. Installing this profile does NOT +//! change the user's DNS at all. +//! +//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces +//! the existing profile on re-install rather than accumulating duplicates. +//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they +//! can coexist as separate installed profiles, but they share the same CA +//! payload UUID since the CA itself is the same trust anchor in both. + +use std::net::Ipv4Addr; + +/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS). +/// Changing this breaks in-place replacement on existing iOS installs. +const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF"; +const FULL_PROFILE_ID: &str = "com.numa.dns.profile"; + +/// Top-level UUID and PayloadIdentifier for the CA-only profile. +/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other, +/// or both without the latest install silently replacing a different mode. +const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0"; +const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile"; + +/// CA trust payload UUID. Same in both modes — iOS will see "the same CA +/// trust anchor" regardless of which wrapping profile contains it. +const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901"; +const CA_PAYLOAD_ID: &str = "com.numa.dns.ca"; + +/// DNS settings payload UUID (Full mode only). +const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"; +const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot"; + +/// Profile mode determines which payloads are included in the generated +/// `.mobileconfig`. +#[derive(Debug, Clone)] +pub enum ProfileMode { + /// Full profile: CA trust anchor + managed DNS settings payload + /// pointing at the given LAN IP over DoT. This is what the classic + /// `numa setup-phone` QR flow serves. + Full { lan_ip: Ipv4Addr }, + + /// CA-only profile: just the trust anchor, no DNS settings. For use + /// with the iOS companion app which manages DNS programmatically via + /// `NEDNSSettingsManager` and only needs the system trust store to + /// accept Numa's self-signed DoT cert. + CaOnly, +} + +/// Build a full `.mobileconfig` profile as an XML plist string. +pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String { + let ca_payload = build_ca_payload(ca_pem); + + match mode { + ProfileMode::Full { lan_ip } => { + let dns_payload = build_dns_payload(lan_ip); + let payloads = format!("{}\n{}", ca_payload, dns_payload); + let description = format!( + "Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})" + ); + wrap_plist( + &payloads, + FULL_PROFILE_UUID, + FULL_PROFILE_ID, + &description, + "Numa DNS", + ) + } + ProfileMode::CaOnly => wrap_plist( + &ca_payload, + CA_ONLY_PROFILE_UUID, + CA_ONLY_PROFILE_ID, + "Trusts the Numa local Certificate Authority. Does not change your DNS settings.", + "Numa CA", + ), + } +} + +/// 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::() +} + +/// Wrap the base64 CA cert at 52 chars per line for plist readability +/// (matches Apple convention in hand-written profiles). +fn chunk_base64(base64: &str) -> String { + base64 + .chars() + .collect::>() + .chunks(52) + .map(|chunk| format!("\t\t\t{}", chunk.iter().collect::())) + .collect::>() + .join("\n") +} + +/// Render the `com.apple.security.root` payload dict containing the CA cert. +fn build_ca_payload(ca_pem: &str) -> String { + let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem)); + format!( + r#" + PayloadCertificateFileName + numa-ca.pem + PayloadContent + +{ca} + + PayloadDescription + Numa local Certificate Authority — required for DoT trust + PayloadDisplayName + Numa Local CA + PayloadIdentifier + {ca_id} + PayloadType + com.apple.security.root + PayloadUUID + {ca_uuid} + PayloadVersion + 1 + "#, + ca = ca_wrapped, + ca_id = CA_PAYLOAD_ID, + ca_uuid = CA_PAYLOAD_UUID, + ) +} + +/// 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#" + 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 + {dns_id} + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + {dns_uuid} + PayloadVersion + 1 + "#, + ip = lan_ip, + dns_id = DNS_PAYLOAD_ID, + dns_uuid = DNS_PAYLOAD_UUID, + ) +} + +/// Wrap one or more payload dicts in the top-level plist structure +/// with Configuration type, PayloadContent array, and profile metadata. +fn wrap_plist( + payloads: &str, + top_uuid: &str, + top_id: &str, + description: &str, + display_name: &str, +) -> String { + format!( + r#" + + + + PayloadContent + +{payloads} + + PayloadDescription + {description} + PayloadDisplayName + {display_name} + PayloadIdentifier + {top_id} + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + {top_uuid} + PayloadVersion + 1 + + +"#, + payloads = payloads, + description = description, + display_name = display_name, + top_id = top_id, + top_uuid = top_uuid, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PEM: &str = + "-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n"; + + #[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 full_profile_contains_ip_and_ca() { + let config = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(192, 168, 1, 100), + }, + SAMPLE_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")); + assert!(config.contains(FULL_PROFILE_UUID)); + assert!(config.contains(FULL_PROFILE_ID)); + } + + #[test] + fn ca_only_profile_contains_ca_but_not_dns() { + let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST")); + assert!(config.contains("com.apple.security.root")); + assert!(!config.contains("com.apple.dnsSettings.managed")); + assert!(!config.contains("DNSProtocol")); + assert!(!config.contains("ServerAddresses")); + assert!(config.contains(CA_ONLY_PROFILE_UUID)); + assert!(config.contains(CA_ONLY_PROFILE_ID)); + } + + #[test] + fn full_and_ca_only_have_distinct_top_uuids() { + let full = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(10, 0, 0, 1), + }, + SAMPLE_PEM, + ); + let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(full.contains(FULL_PROFILE_UUID)); + assert!(!full.contains(CA_ONLY_PROFILE_UUID)); + assert!(ca_only.contains(CA_ONLY_PROFILE_UUID)); + assert!(!ca_only.contains(FULL_PROFILE_UUID)); + } + + #[test] + fn both_modes_share_ca_payload_uuid() { + let full = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(10, 0, 0, 1), + }, + SAMPLE_PEM, + ); + let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(full.contains(CA_PAYLOAD_UUID)); + assert!(ca_only.contains(CA_PAYLOAD_UUID)); + } +} diff --git a/src/setup_phone.rs b/src/setup_phone.rs new file mode 100644 index 0000000..fd37c84 --- /dev/null +++ b/src/setup_phone.rs @@ -0,0 +1,126 @@ +//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API. +//! +//! Before the mobile API existed, this command spawned its own one-shot +//! HTTP server on port 8765 to serve a freshly-generated mobileconfig +//! for a single download. That role now belongs to +//! [`crate::mobile_api`], which runs persistently alongside the main +//! API and serves `/mobileconfig` at the same port whenever Numa is +//! running. +//! +//! This command is now a thin terminal-side wrapper: +//! +//! 1. Detect the current LAN IP +//! 2. Render a terminal QR code pointing at +//! `http://:8765/mobileconfig` +//! 3. Print install instructions and exit +//! +//! The user scans the QR, iOS fetches the profile from the mobile API +//! (which is always up as long as `numa` is running), installs, and the +//! user walks through Settings → Certificate Trust Settings to enable +//! trust. +//! +//! Numa must be running for the profile download to succeed; if the +//! mobile API is not listening on port 8765, the download will fail +//! and the user will see Safari's "Cannot Connect to Server" error. +//! The CLI prints a reminder about this at the bottom of the output. + +use qrcode::render::unicode; +use qrcode::QrCode; + +/// Default port where the persistent mobile API serves `/mobileconfig`. +/// Matches `MobileConfig::default().port` in `config.rs`. If the user +/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to +/// adjust the URL manually — this CLI uses the default without parsing +/// `numa.toml`. +const SETUP_PORT: u16 = 8765; + +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()) +} + +/// 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 addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT)); + let api_reachable = tokio::time::timeout( + std::time::Duration::from_millis(500), + tokio::net::TcpStream::connect(addr), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false); + + if !api_reachable { + eprintln!(); + eprintln!( + " \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.", + SETUP_PORT + ); + eprintln!(); + eprintln!(" The phone won't be able to download the profile until the mobile"); + eprintln!(" API is running. Add this to your numa.toml and restart Numa:"); + eprintln!(); + eprintln!(" [mobile]"); + eprintln!(" enabled = true"); + eprintln!(); + return Err("mobile API not running".into()); + } + + let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT); + let qr = render_qr(&url)?; + + eprintln!(); + eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m"); + eprintln!(); + eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url); + eprintln!(); + 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-scan this QR — iOS will replace the"); + eprintln!(" existing profile automatically (fixed UUID)."); + eprintln!(); + eprintln!( + " \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m", + SETUP_PORT + ); + eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m"); + eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m"); + eprintln!(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_qr_produces_unicode() { + let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap(); + assert!(!qr.is_empty()); + // Dense1x2 uses these block characters + assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' '))); + } +}