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

Merged
razvandimescu merged 3 commits from feat/setup-phone into main 2026-04-11 00:08:56 +08:00
12 changed files with 908 additions and 239 deletions
Showing only changes of commit ca04157229 - Show all commits

View File

@@ -102,3 +102,22 @@ tld = "numa"
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) # enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
# broadcast_interval_secs = 30 # broadcast_interval_secs = 30
# peer_timeout_secs = 90 # 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

View File

@@ -592,8 +592,19 @@ async fn flush_cache_domain(
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
async fn health() -> Json<serde_json::Value> { /// Enriched `/health` handler shared between the main API and the mobile API.
Json(serde_json::json!({ "status": "ok" })) ///
/// 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<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
Json(crate::health::HealthResponse::build(
&ctx.health_meta,
lan_ip,
))
} }
// --- Blocking handlers --- // --- Blocking handlers ---
@@ -905,12 +916,8 @@ async fn remove_route(
} }
} }
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> { pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME); let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_TYPE, "application/x-pem-file"),
@@ -920,7 +927,7 @@ async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse
), ),
(header::CACHE_CONTROL, "public, max-age=86400"), (header::CACHE_CONTROL, "public, max-age=86400"),
], ],
bytes, pem.to_string(),
)) ))
} }
@@ -996,6 +1003,8 @@ mod tests {
inflight: Mutex::new(std::collections::HashMap::new()), inflight: Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: false, dnssec_enabled: false,
dnssec_strict: false, dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
}) })
} }

View File

@@ -31,6 +31,8 @@ pub struct Config {
pub dnssec: DnssecConfig, pub dnssec: DnssecConfig,
#[serde(default)] #[serde(default)]
pub dot: DotConfig, pub dot: DotConfig,
#[serde(default)]
pub mobile: MobileConfig,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -412,6 +414,53 @@ fn default_dot_bind_addr() -> String {
"0.0.0.0".to_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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -18,6 +18,7 @@ use crate::cache::{DnsCache, DnssecStatus};
use crate::config::{UpstreamMode, ZoneMap}; use crate::config::{UpstreamMode, ZoneMap};
use crate::forward::{forward_query, Upstream}; use crate::forward::{forward_query, Upstream};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::health::HealthMeta;
use crate::lan::PeerStore; use crate::lan::PeerStore;
use crate::override_store::OverrideStore; use crate::override_store::OverrideStore;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
@@ -60,6 +61,15 @@ pub struct ServerCtx {
pub inflight: Mutex<InflightMap>, pub inflight: Mutex<InflightMap>,
pub dnssec_enabled: bool, pub dnssec_enabled: bool,
pub dnssec_strict: 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<String>,
} }
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,

View File

@@ -381,6 +381,8 @@ mod tests {
inflight: Mutex::new(HashMap::new()), inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false, dnssec_enabled: false,
dnssec_strict: 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(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();

254
src/health.rs Normal file
View File

@@ -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<String>,
pub features: Vec<String>,
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<String>,
pub sni: String,
pub dot: DotBlock,
pub api: ApiBlock,
pub ca: CaBlock,
pub features: Vec<String>,
}
#[derive(Serialize)]
pub struct DotBlock {
pub enabled: bool,
pub port: Option<u16>,
}
#[derive(Serialize)]
pub struct ApiBlock {
pub port: u16,
}
#[derive(Serialize)]
pub struct CaBlock {
pub present: bool,
pub fingerprint_sha256: Option<String>,
}
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<Ipv4Addr>) -> 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<String> {
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());
}
}

View File

@@ -9,6 +9,7 @@ use crate::buffer::BytePacketBuffer;
use crate::config::LanConfig; use crate::config::LanConfig;
use crate::ctx::ServerCtx; use crate::ctx::ServerCtx;
use crate::header::DnsHeader; use crate::header::DnsHeader;
use crate::health::HealthMeta;
use crate::question::{DnsQuestion, QueryType}; use crate::question::{DnsQuestion, QueryType};
// --- Constants --- // --- Constants ---
@@ -18,6 +19,18 @@ const MDNS_PORT: u16 = 5353;
const SERVICE_TYPE: &str = "_numa._tcp.local"; const SERVICE_TYPE: &str = "_numa._tcp.local";
const MDNS_TTL: u32 = 120; 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 --- // --- Peer Store ---
pub struct PeerStore { pub struct PeerStore {
@@ -97,14 +110,16 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
} }
} }
/// Short hostname for mDNS instance names (`<short>._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 { fn get_hostname() -> String {
std::process::Command::new("hostname") crate::hostname()
.output() .split('.')
.ok() .next()
.and_then(|o| String::from_utf8(o.stdout).ok()) .filter(|s| !s.is_empty())
.map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) .unwrap_or("numa")
.filter(|h| !h.is_empty()) .to_string()
.unwrap_or_else(|| "numa".to_string())
} }
/// Generate a per-process instance ID for self-filtering on multi-instance hosts /// 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<ServerCtx>, config: &LanConfig) {
.map(|e| (e.name.clone(), e.target_port)) .map(|e| (e.name.clone(), e.target_port))
.collect() .collect()
}; };
if services.is_empty() { // Note: we always announce ourselves, even when the
continue; // 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(); let current_ip = *sender_ctx.lan_ip.lock().unwrap();
if let Ok(pkt) = if let Ok(pkt) = build_announcement(
build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) &sender_hostname,
{ current_ip,
&services,
&sender_instance_id,
&sender_ctx.health_meta,
) {
let _ = sender_socket.send_to(pkt.filled(), dest).await; let _ = sender_socket.send_to(pkt.filled(), dest).await;
} }
} }
@@ -240,6 +264,7 @@ fn build_announcement(
ip: Ipv4Addr, ip: Ipv4Addr,
services: &[(String, u16)], services: &[(String, u16)],
inst_id: &str, inst_id: &str,
meta: &HealthMeta,
) -> crate::Result<BytePacketBuffer> { ) -> crate::Result<BytePacketBuffer> {
let mut buf = BytePacketBuffer::new(); let mut buf = BytePacketBuffer::new();
let instance_name = format!("{}._numa._tcp.local", hostname); let instance_name = format!("{}._numa._tcp.local", hostname);
@@ -260,7 +285,11 @@ fn build_announcement(
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// SRV: <instance>._numa._tcp.local → <hostname>.local // SRV: <instance>._numa._tcp.local → <hostname>.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( write_record_header(
&mut buf, &mut buf,
&instance_name, &instance_name,
@@ -273,11 +302,13 @@ fn build_announcement(
let rdata_start = buf.pos(); let rdata_start = buf.pos();
buf.write_u16(0)?; // priority buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight 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)?; buf.write_qname(&host_local)?;
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; 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( write_record_header(
&mut buf, &mut buf,
&instance_name, &instance_name,
@@ -293,8 +324,21 @@ fn build_announcement(
.map(|(name, port)| format!("{}:{}", name, port)) .map(|(name, port)| format!("{}:{}", name, port))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
write_txt_string(&mut buf, &format!("services={}", svc_str))?; // Legacy peer-discovery entries (consumed by parse_mdns_response)
write_txt_string(&mut buf, &format!("id={}", inst_id))?; 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)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// A: <hostname>.local → IP // A: <hostname>.local → IP
@@ -408,7 +452,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
break; break;
} }
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { 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 let svcs: Vec<(String, u16)> = val
.split(',') .split(',')
.filter_map(|s| { .filter_map(|s| {
@@ -421,7 +465,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
if !svcs.is_empty() { if !svcs.is_empty() {
txt_services = Some(svcs); 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()); peer_instance_id = Some(id.to_string());
} }
} }

View File

@@ -8,7 +8,10 @@ pub mod dnssec;
pub mod dot; pub mod dot;
pub mod forward; pub mod forward;
pub mod header; pub mod header;
pub mod health;
pub mod lan; pub mod lan;
pub mod mobile_api;
pub mod mobileconfig;
pub mod override_store; pub mod override_store;
pub mod packet; pub mod packet;
pub mod proxy; pub mod proxy;
@@ -26,6 +29,20 @@ pub mod tls;
pub type Error = Box<dyn std::error::Error + Send + Sync>; pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
/// 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). /// Shared config directory for persistent data (services.json, etc).
/// Unix users: ~/.config/numa/ /// Unix users: ~/.config/numa/
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa

View File

@@ -239,6 +239,19 @@ async fn main() -> numa::Result<()> {
None 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 { let socket = match UdpSocket::bind(&config.server.bind_addr).await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -290,6 +303,8 @@ async fn main() -> numa::Result<()> {
inflight: std::sync::Mutex::new(std::collections::HashMap::new()), inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled, dnssec_enabled: config.dnssec.enabled,
dnssec_strict: config.dnssec.strict, dnssec_strict: config.dnssec.strict,
health_meta,
ca_pem,
}); });
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); 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(); 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 let proxy_bind: std::net::Ipv4Addr = config
.proxy .proxy
.bind_addr .bind_addr

107
src/mobile_api.rs Normal file
View File

@@ -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<ServerCtx>) -> 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<ServerCtx>, 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<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
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<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
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,
)
}

294
src/mobileconfig.rs Normal file
View File

@@ -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 `<data>` block.
fn pem_to_base64(pem: &str) -> String {
pem.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<String>()
}
/// 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::<Vec<_>>()
.chunks(52)
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
.collect::<Vec<_>>()
.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#" <dict>
<key>PayloadCertificateFileName</key>
<string>numa-ca.pem</string>
<key>PayloadContent</key>
<data>
{ca}
</data>
<key>PayloadDescription</key>
<string>Numa local Certificate Authority — required for DoT trust</string>
<key>PayloadDisplayName</key>
<string>Numa Local CA</string>
<key>PayloadIdentifier</key>
<string>{ca_id}</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>{ca_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
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#" <dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>TLS</string>
<key>ServerAddresses</key>
<array>
<string>{ip}</string>
</array>
<key>ServerName</key>
<string>numa.numa</string>
</dict>
<key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
<key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>
<string>{dns_id}</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>{dns_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
{payloads}
</array>
<key>PayloadDescription</key>
<string>{description}</string>
<key>PayloadDisplayName</key>
<string>{display_name}</string>
<key>PayloadIdentifier</key>
<string>{top_id}</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>{top_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
"#,
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));
}
}

View File

@@ -1,120 +1,38 @@
use std::net::{Ipv4Addr, SocketAddr}; //! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
use std::path::PathBuf; //!
use std::sync::atomic::{AtomicUsize, Ordering}; //! Before the mobile API existed, this command spawned its own one-shot
use std::sync::Arc; //! HTTP server on port 8765 to serve a freshly-generated mobileconfig
use std::time::Duration; //! 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://<lan_ip>: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::render::unicode;
use qrcode::QrCode; 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 SETUP_PORT: u16 = 8765;
const RUST_OK_HEADERS: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/x-apple-aspen-config\r\nContent-Disposition: attachment; filename=\"numa.mobileconfig\"\r\nConnection: close\r\nContent-Length: ";
const RUST_NOT_FOUND: &str =
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
/// Strip the PEM header/footer and newlines from a CA cert, leaving raw base64
/// for embedding in a plist `<data>` block.
fn pem_to_base64(pem: &str) -> String {
pem.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<String>()
}
/// Build a combined `.mobileconfig` containing:
/// 1. Root CA payload — installs and trusts the Numa local CA
/// 2. DNS payload — points the device at Numa over DoT
///
/// UUIDs and PayloadIdentifiers are intentionally fixed (not randomized) so
/// that re-running `numa setup-phone` after an IP change replaces the existing
/// profile rather than accumulating duplicates in iOS Settings.
fn build_mobileconfig(lan_ip: Ipv4Addr, ca_pem: &str) -> String {
let ca_base64 = pem_to_base64(ca_pem);
// Wrap base64 at 52 chars per line for plist readability (matches Apple convention)
let ca_wrapped: String = ca_base64
.chars()
.collect::<Vec<_>>()
.chunks(52)
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadCertificateFileName</key>
<string>numa-ca.pem</string>
<key>PayloadContent</key>
<data>
{ca}
</data>
<key>PayloadDescription</key>
<string>Numa local Certificate Authority — required for DoT trust</string>
<key>PayloadDisplayName</key>
<string>Numa Local CA</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>B2C3D4E5-F6A7-8901-BCDE-F12345678901</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>TLS</string>
<key>ServerAddresses</key>
<array>
<string>{ip}</string>
</array>
<key>ServerName</key>
<string>numa.numa</string>
</dict>
<key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
<key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.dot</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>A1B2C3D4-E5F6-7890-ABCD-EF1234567890</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({ip})</string>
<key>PayloadDisplayName</key>
<string>Numa DNS</string>
<key>PayloadIdentifier</key>
<string>com.numa.dns.profile</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>F1E2D3C4-B5A6-7890-1234-567890ABCDEF</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
"#,
ca = ca_wrapped,
ip = lan_ip
)
}
fn render_qr(url: &str) -> Result<String, String> { fn render_qr(url: &str) -> Result<String, String> {
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?; let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
@@ -125,74 +43,19 @@ fn render_qr(url: &str) -> Result<String, String> {
.build()) .build())
} }
async fn accept_loop(listener: TcpListener, profile: Arc<String>, count: Arc<AtomicUsize>) {
loop {
let (mut stream, peer) = match listener.accept().await {
Ok(c) => c,
Err(_) => continue,
};
let profile = Arc::clone(&profile);
let count = Arc::clone(&count);
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = match tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf)).await
{
Ok(Ok(n)) => n,
_ => return,
};
let request = String::from_utf8_lossy(&buf);
if request.starts_with("GET /setup") || request.starts_with("GET / ") {
let body = profile.as_bytes();
let mut response =
format!("{}{}\r\n\r\n", RUST_OK_HEADERS, body.len()).into_bytes();
response.extend_from_slice(body);
let _ = stream.write_all(&response).await;
let _ = stream.flush().await;
let n = count.fetch_add(1, Ordering::Relaxed) + 1;
eprintln!(
" \x1b[32m✓\x1b[0m Profile downloaded by {} ({} total)",
peer.ip(),
n
);
} else {
let _ = stream.write_all(RUST_NOT_FOUND.as_bytes()).await;
}
});
}
}
/// Run the `numa setup-phone` flow. /// Run the `numa setup-phone` flow.
pub async fn run() -> Result<(), String> { pub async fn run() -> Result<(), String> {
let lan_ip = crate::lan::detect_lan_ip() let lan_ip = crate::lan::detect_lan_ip()
.ok_or("could not detect LAN IP — are you connected to a network?")?; .ok_or("could not detect LAN IP — are you connected to a network?")?;
let ca_path: PathBuf = crate::data_dir().join("ca.pem"); let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
let ca_pem = std::fs::read_to_string(&ca_path).map_err(|e| {
format!(
"could not read CA at {}: {} — is Numa installed and has the service started at least once?",
ca_path.display(),
e
)
})?;
let profile = build_mobileconfig(lan_ip, &ca_pem);
let url = format!("http://{}:{}/setup", lan_ip, SETUP_PORT);
let qr = render_qr(&url)?; let 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!();
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m\n"); eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
eprintln!(" Serving setup profile at: \x1b[36m{}\x1b[0m\n", url); eprintln!();
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
eprintln!();
for line in qr.lines() { for line in qr.lines() {
eprintln!(" {}", line); 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", " \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
lan_ip lan_ip
); );
eprintln!(" laptop changes networks, re-run this command — iOS will replace the"); eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
eprintln!(" existing profile automatically."); eprintln!(" existing profile automatically (fixed UUID).");
eprintln!(); 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!(); 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(()) Ok(())
} }
@@ -239,27 +91,9 @@ pub async fn run() -> Result<(), String> {
mod tests { mod tests {
use super::*; 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] #[test]
fn render_qr_produces_unicode() { 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()); assert!(!qr.is_empty());
// Dense1x2 uses these block characters // Dense1x2 uses these block characters
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' '))); assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));