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
razvandimescu commented 2026-04-07 20:56:30 +08:00 (Migrated from github.com)

Summary

Adds a numa setup-phone CLI subcommand that onboards a phone (iPhone primarily) to use Numa as its DNS-over-TLS server, in a single command.

Stacked on top of #25 (DoT listener) — this PR depends on the DoT feature. Will be rebased onto main after #25 merges.

What it does

  1. User runs numa setup-phone (no sudo needed)
  2. Detects current LAN IP via lan::detect_lan_ip()
  3. Reads CA cert from /usr/local/var/numa/ca.pem
  4. Builds a combined .mobileconfig containing:
    • com.apple.security.root payload (CA installation)
    • com.apple.dnsSettings.managed payload (DoT pointing to laptop's IP)
  5. Renders a QR code in the terminal (Unicode block characters via the qrcode crate)
  6. Serves the profile on port 8765
  7. Stays open until Ctrl+C, counts successful downloads

Terminal output

  Numa Phone Setup

  Serving setup profile at: http://192.168.1.209:8765/setup

  [QR code rendered with Unicode blocks]

  On your iPhone:
    1. Open Camera, point at the QR code, tap the yellow banner
    2. Allow the download when Safari asks
    3. Open Settings — tap "Profile Downloaded" near the top
       (or: Settings → General → VPN & Device Management → Numa DNS)
    4. Tap Install (top right), enter passcode, Install again
    5. Settings → General → About → Certificate Trust Settings
       Toggle ON "Numa Local CA" — required for DoT to work

  Note: profile uses your laptop's current IP (192.168.1.209). If your
  laptop changes networks, re-run this command — iOS will replace the
  existing profile automatically.

  Waiting for download (Ctrl+C to exit)...

Validated quirks

  • CA trust still requires manual toggle — even with the CA bundled in the same profile as the DNS settings, iOS does NOT auto-enable it in Certificate Trust Settings. Verified on a real iPhone. The instructions explicitly call out this step.
  • Stable PayloadIdentifiers/UUIDs — re-running setup-phone after an IP change replaces the existing profile rather than accumulating duplicates in iOS Settings.
  • Stay-open-until-Ctrl+C — better for multi-device households (phone + tablet + partner's phone) and lets users retry failed installs without re-running the command.

Known limitations (documented in copy)

  • IP changes break the profile. Apple's mobileconfig spec requires literal IPs in ServerAddresses — no hostnames allowed. Mitigation: re-run setup-phone, iOS auto-replaces the profile via stable identifiers. Long-term fix would require ACME + a real domain.
  • Laptop asleep = phone has no DNS. The profile sets Numa as the only resolver. When the laptop is unreachable, the phone can't resolve anything until it switches networks. Future improvement: --ssid flag for OnDemand rules to scope Numa to a specific Wi-Fi.

Files

File Change
src/setup_phone.rs New module (~270 lines)
src/lib.rs pub mod setup_phone
src/main.rs New setup-phone CLI subcommand + help text
Cargo.toml qrcode = "0.14" (default-features = false), tokio signal feature

Tests

3 unit tests in setup_phone::tests:

  • pem_to_base64_strips_headers — PEM parsing
  • mobileconfig_contains_ip_and_ca — profile generation
  • render_qr_produces_unicode — QR rendering

Total: 129 tests passing locally (126 DoT-branch + 3 new).

Test plan

  • cargo test passes locally (macOS)
  • cargo clippy -- -D warnings clean
  • cargo fmt --check clean
  • Manual run on macOS: QR renders, server binds, profile downloads
  • Validated on a real iPhone: install + Certificate Trust Settings toggle + DoT working
  • CI passes on Linux + macOS + Windows after rebase

Future iterations (out of scope)

  • --ssid "MyHomeWiFi" for OnDemand rules (laptop-asleep mitigation)
  • --ssid auto to detect current Wi-Fi via networksetup/iwgetid
  • --print to write profile to stdout for scripted use
  • Verification page at verify.numa.numa showing "your phone is connected"
  • Terminal width check, fall back to URL-only output if QR won't fit

🤖 Generated with Claude Code

## Summary Adds a `numa setup-phone` CLI subcommand that onboards a phone (iPhone primarily) to use Numa as its DNS-over-TLS server, in a single command. **Stacked on top of #25 (DoT listener)** — this PR depends on the DoT feature. Will be rebased onto `main` after #25 merges. ## What it does 1. User runs `numa setup-phone` (no sudo needed) 2. Detects current LAN IP via `lan::detect_lan_ip()` 3. Reads CA cert from `/usr/local/var/numa/ca.pem` 4. Builds a combined `.mobileconfig` containing: - `com.apple.security.root` payload (CA installation) - `com.apple.dnsSettings.managed` payload (DoT pointing to laptop's IP) 5. Renders a QR code in the terminal (Unicode block characters via the `qrcode` crate) 6. Serves the profile on port 8765 7. Stays open until Ctrl+C, counts successful downloads ## Terminal output ``` Numa Phone Setup Serving setup profile at: http://192.168.1.209:8765/setup [QR code rendered with Unicode blocks] On your iPhone: 1. Open Camera, point at the QR code, tap the yellow banner 2. Allow the download when Safari asks 3. Open Settings — tap "Profile Downloaded" near the top (or: Settings → General → VPN & Device Management → Numa DNS) 4. Tap Install (top right), enter passcode, Install again 5. Settings → General → About → Certificate Trust Settings Toggle ON "Numa Local CA" — required for DoT to work Note: profile uses your laptop's current IP (192.168.1.209). If your laptop changes networks, re-run this command — iOS will replace the existing profile automatically. Waiting for download (Ctrl+C to exit)... ``` ## Validated quirks - **CA trust still requires manual toggle** — even with the CA bundled in the same profile as the DNS settings, iOS does NOT auto-enable it in Certificate Trust Settings. Verified on a real iPhone. The instructions explicitly call out this step. - **Stable PayloadIdentifiers/UUIDs** — re-running `setup-phone` after an IP change replaces the existing profile rather than accumulating duplicates in iOS Settings. - **Stay-open-until-Ctrl+C** — better for multi-device households (phone + tablet + partner's phone) and lets users retry failed installs without re-running the command. ## Known limitations (documented in copy) - **IP changes break the profile.** Apple's mobileconfig spec requires literal IPs in `ServerAddresses` — no hostnames allowed. Mitigation: re-run `setup-phone`, iOS auto-replaces the profile via stable identifiers. Long-term fix would require ACME + a real domain. - **Laptop asleep = phone has no DNS.** The profile sets Numa as the only resolver. When the laptop is unreachable, the phone can't resolve anything until it switches networks. Future improvement: `--ssid` flag for OnDemand rules to scope Numa to a specific Wi-Fi. ## Files | File | Change | |---|---| | `src/setup_phone.rs` | New module (~270 lines) | | `src/lib.rs` | `pub mod setup_phone` | | `src/main.rs` | New `setup-phone` CLI subcommand + help text | | `Cargo.toml` | `qrcode = "0.14"` (default-features = false), tokio `signal` feature | ## Tests 3 unit tests in `setup_phone::tests`: - `pem_to_base64_strips_headers` — PEM parsing - `mobileconfig_contains_ip_and_ca` — profile generation - `render_qr_produces_unicode` — QR rendering Total: 129 tests passing locally (126 DoT-branch + 3 new). ## Test plan - [x] `cargo test` passes locally (macOS) - [x] `cargo clippy -- -D warnings` clean - [x] `cargo fmt --check` clean - [x] Manual run on macOS: QR renders, server binds, profile downloads - [x] Validated on a real iPhone: install + Certificate Trust Settings toggle + DoT working - [x] CI passes on Linux + macOS + Windows after rebase ## Future iterations (out of scope) - `--ssid "MyHomeWiFi"` for OnDemand rules (laptop-asleep mitigation) - `--ssid auto` to detect current Wi-Fi via `networksetup`/`iwgetid` - `--print` to write profile to stdout for scripted use - Verification page at `verify.numa.numa` showing "your phone is connected" - Terminal width check, fall back to URL-only output if QR won't fit 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.