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

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

Adds a CLI subcommand that generates a one-time mobileconfig profile
containing both the Numa local CA (as a com.apple.security.root payload)
and the DoT DNS settings, then serves it via a temporary HTTP server
and prints a scannable QR code in the terminal.

Flow:
  1. User runs `numa setup-phone` (no sudo needed)
  2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem
  3. Builds combined mobileconfig (CA trust + DoT)
  4. Renders QR code with qrcode crate (Unicode block characters)
  5. Serves the profile on port 8765, stays open until Ctrl+C
  6. Counts successful downloads (multi-device households)

Important caveat documented in instructions: even with the CA bundled
in the profile, iOS still requires the user to manually enable trust
in Settings → General → About → Certificate Trust Settings. Verified
on a real iPhone.

Stable PayloadIdentifiers/UUIDs ensure re-running replaces the
existing profile on iOS rather than accumulating duplicates.

- New module: src/setup_phone.rs (~270 lines)
- New CLI subcommand: `numa setup-phone`
- New dependency: qrcode = "0.14" (default-features = false)
- tokio "signal" feature added for Ctrl+C handling
- 3 unit tests: PEM stripping, mobileconfig generation, QR rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: mobile API, enriched /health, mobileconfig module

Adds a persistent read-only HTTP listener (default port 8765, LAN-bound)
serving a dedicated subset of Numa's API for iOS/Android companion apps
and as a replacement for the one-shot server setup_phone used to spin up:

  GET /health           — enriched JSON with version, hostname, LAN IP,
                          SNI, DoT config, mobile API port, CA
                          fingerprint, features (shared handler with
                          the main API on port 5380)
  GET /ca.pem           — public CA certificate (shared handler)
  GET /mobileconfig     — full iOS profile (CA trust + DNS settings
                          pinned to current LAN IP)
  GET /ca.mobileconfig  — CA-only iOS profile (trust anchor without
                          DNS override — for the iOS companion app's
                          programmatic DNS flow via NEDNSSettingsManager)

All routes are idempotent GETs. The mobile API never serves the
state-mutating routes that live on the main API (overrides, blocking
toggle, service CRUD, cache flush), so it is safe to expose on the LAN
regardless of the main API's bind address. The CA private key is never
served by any route.

Opt-in via `[mobile] enabled = true`. Default is false so new installs
do not silently expose a LAN listener after upgrading; our committed
numa.toml template enables it explicitly for spike testing.

New modules:

- src/mobileconfig.rs — ProfileMode::{Full, CaOnly} enum with plist
  builder lifted from setup_phone.rs. Full and CaOnly share the CA
  payload UUID (same trust anchor) but have distinct top-level UUIDs
  so they coexist as separate installable profiles on iOS.

- src/health.rs — HealthMeta cached metadata built once at startup
  from config + CA fingerprint (SHA-256 of the PEM via ring), and the
  HealthResponse JSON shape shared between the main and mobile APIs.

- src/mobile_api.rs — axum Router for the persistent listener. Reuses
  api::health and api::serve_ca from the main API; owns the two
  mobileconfig handlers.

Modified:

- src/api.rs — health() returns the enriched HealthResponse, now pub.
  serve_ca is now pub so mobile_api can reuse it.
- src/config.rs — MobileConfig section (enabled, port, bind_addr).
- src/ctx.rs — health_meta: HealthMeta field on ServerCtx.
- src/main.rs — builds HealthMeta at startup, spawns mobile API
  listener if enabled.
- src/lan.rs — build_announcement takes &HealthMeta and writes
  enriched TXT records (version, api_port, proto, dot_port, ca_fp).
  SRV port now reports the mobile API port; peer discovery still
  reads TXT `services=` so this is backwards compatible. Always
  announces even when no .numa services are registered, so the iOS
  companion app can discover Numa via mDNS regardless of service
  state.
- src/setup_phone.rs — reduced from 267 to 100 lines. The CLI is now
  a thin QR wrapper over the persistent /mobileconfig endpoint; the
  hand-rolled one-shot HTTP server (accept_loop, RUST_OK_HEADERS,
  RUST_NOT_FOUND, download counter) is gone.
- src/dot.rs — test fixture updated with HealthMeta::test_fixture().
- numa.toml — commented [mobile] section, enabled = true for spike.

Tests: 136 unit tests passing (5 new in mobileconfig, 3 new in health).
cargo clippy clean. Integration sanity check: curl'd /health, /ca.pem,
/mobileconfig, /ca.mobileconfig against a running numa — all return
200 with correct content types and valid response bodies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: setup-phone probe, unknown command error, query source in dashboard

- setup-phone now probes the mobile API before printing the QR code
  and shows an actionable error if [mobile] is not enabled
- Unknown CLI subcommands print an error instead of silently
  attempting to start a full server
- Dashboard query log shows source IP under timestamp (localhost
  for loopback, full IP for LAN devices) with full addr on hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #38.
This commit is contained in:
Razvan Dimescu
2026-04-10 19:08:56 +03:00
committed by GitHub
parent 6f961c5ec2
commit de15b32325
15 changed files with 1065 additions and 41 deletions

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());
}
}