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, '█' | '▀' | '▄' | ' ')));
+ }
+}