Files
numa/src/mobileconfig.rs
Razvan Dimescu 2c20c56421 feat: mobile setup — QR onboarding, Wi-Fi scoped mobileconfig (#73)
* fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules

Without OnDemandRules, iOS applies the DoT profile globally —
cellular DNS breaks when the phone leaves the LAN.

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

* feat: phone setup QR code in dashboard header

- Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature)
- Header popover: QR on desktop, direct download link on mobile viewports
- Only visible when [mobile] enabled = true in config
- Expose mobile.enabled and mobile.port in /stats response
- Lazy-load QR on first click, dismiss on outside click

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

* fix: add Cache-Control to /qr, re-fetch QR on each popover open

Cache-Control: no-store prevents stale QR after LAN IP change.
Remove qrLoaded flag so the QR always reflects the current IP.

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

* style: rustfmt serve_qr response tuple

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

* fix: add iOS install steps to phone setup popover

iOS shows "Profile Downloaded" with no guidance. The popover
now includes the 3-step install flow including the buried
Certificate Trust Settings toggle.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:21:51 +03:00

306 lines
10 KiB
Rust

//! 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.
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>OnDemandRules</key>
<array>
<dict>
<key>Action</key>
<string>Connect</string>
<key>InterfaceTypeMatch</key>
<string>WiFi</string>
</dict>
<dict>
<key>Action</key>
<string>Disconnect</string>
</dict>
</array>
<key>PayloadDescription</key>
<string>Routes DNS queries through Numa over DoT when on Wi-Fi</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));
}
}