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>
This commit is contained in:
19
numa.toml
19
numa.toml
@@ -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
|
||||||
|
|||||||
27
src/api.rs
27
src/api.rs
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
10
src/ctx.rs
10
src/ctx.rs
@@ -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,
|
||||||
|
|||||||
@@ -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
254
src/health.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/lan.rs
84
src/lan.rs
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/lib.rs
17
src/lib.rs
@@ -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
|
||||||
|
|||||||
30
src/main.rs
30
src/main.rs
@@ -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
107
src/mobile_api.rs
Normal 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
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '█' | '▀' | '▄' | ' ')));
|
||||||
|
|||||||
Reference in New Issue
Block a user