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/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 3677072..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; @@ -26,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 77b082c..ebe4aa9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -239,6 +239,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) => { @@ -290,6 +303,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(); @@ -473,6 +488,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 index 4d2e602..7f9b02a 100644 --- a/src/setup_phone.rs +++ b/src/setup_phone.rs @@ -1,120 +1,38 @@ -use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; +//! `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; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; +/// 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; -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))?; @@ -125,74 +43,19 @@ fn render_qr(url: &str) -> Result { .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 url = format!("http://{}:{}/mobileconfig", 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); + 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); } @@ -210,28 +73,17 @@ pub async fn run() -> Result<(), String> { " \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!(" laptop changes networks, re-scan this QR — iOS will replace the"); + eprintln!(" existing profile automatically (fixed UUID)."); eprintln!(); - eprintln!(" Waiting for download (Ctrl+C to exit)..."); + 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!(); - 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(()) } @@ -239,27 +91,9 @@ pub async fn run() -> Result<(), String> { 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(); + 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, '█' | '▀' | '▄' | ' ')));