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:
294
src/mobileconfig.rs
Normal file
294
src/mobileconfig.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user