feat: numa setup-phone — QR-based mobile DoT onboarding #38
51
Cargo.lock
generated
51
Cargo.lock
generated
@@ -84,9 +84,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
@@ -522,6 +522,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -757,9 +767,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -772,6 +782,7 @@ dependencies = [
|
|||||||
"httpdate",
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"want",
|
"want",
|
||||||
@@ -1145,6 +1156,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
|
"qrcode",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -1219,6 +1231,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plotters"
|
name = "plotters"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -1295,6 +1313,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -1674,6 +1698,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -1833,14 +1867,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.51.1"
|
version = "1.50.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -1848,9 +1883,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.7.0"
|
version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
|
|||||||
categories = ["network-programming", "development-tools"]
|
categories = ["network-programming", "development-tools"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -30,6 +30,7 @@ tokio-rustls = "0.26"
|
|||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
rustls-pemfile = "2.2.0"
|
rustls-pemfile = "2.2.0"
|
||||||
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.8", features = ["html_reports"] }
|
criterion = { version = "0.8", features = ["html_reports"] }
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ body {
|
|||||||
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
||||||
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
||||||
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
||||||
|
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
|
||||||
|
|
||||||
/* Sidebar panels */
|
/* Sidebar panels */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -787,6 +788,13 @@ function formatTime(epoch) {
|
|||||||
return d.toLocaleTimeString([], { hour12: false });
|
return d.toLocaleTimeString([], { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortSrc(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
const ip = addr.replace(/:\d+$/, '');
|
||||||
|
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRemaining(secs) {
|
function formatRemaining(secs) {
|
||||||
if (secs == null) return 'permanent';
|
if (secs == null) return 'permanent';
|
||||||
if (secs < 60) return `${secs}s left`;
|
if (secs < 60) return `${secs}s left`;
|
||||||
@@ -912,8 +920,8 @@ function applyLogFilter() {
|
|||||||
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
||||||
: '';
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr title="Source: ${e.src || 'unknown'}">
|
||||||
<td>${formatTime(e.timestamp_epoch)}</td>
|
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td>
|
||||||
<td>${e.query_type}</td>
|
<td>${e.query_type}</td>
|
||||||
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
|
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
|
||||||
<td><span class="path-tag ${e.path}">${e.path}</span></td>
|
<td><span class="path-tag ${e.path}">${e.path}</span></td>
|
||||||
|
|||||||
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/lib.rs
18
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;
|
||||||
@@ -17,6 +20,7 @@ pub mod question;
|
|||||||
pub mod record;
|
pub mod record;
|
||||||
pub mod recursive;
|
pub mod recursive;
|
||||||
pub mod service_store;
|
pub mod service_store;
|
||||||
|
pub mod setup_phone;
|
||||||
pub mod srtt;
|
pub mod srtt;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod system_dns;
|
pub mod system_dns;
|
||||||
@@ -25,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
|
||||||
|
|||||||
50
src/main.rs
50
src/main.rs
@@ -54,6 +54,9 @@ async fn main() -> numa::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
"setup-phone" => {
|
||||||
|
return numa::setup_phone::run().await.map_err(|e| e.into());
|
||||||
|
}
|
||||||
"lan" => {
|
"lan" => {
|
||||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
@@ -85,12 +88,27 @@ async fn main() -> numa::Result<()> {
|
|||||||
eprintln!(" service status Check if the service is running");
|
eprintln!(" service status Check if the service is running");
|
||||||
eprintln!(" lan on Enable LAN service discovery (mDNS)");
|
eprintln!(" lan on Enable LAN service discovery (mDNS)");
|
||||||
eprintln!(" lan off Disable LAN service discovery");
|
eprintln!(" lan off Disable LAN service discovery");
|
||||||
|
eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone");
|
||||||
eprintln!(" help Show this help");
|
eprintln!(" help Show this help");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Config path defaults to numa.toml");
|
eprintln!("Config path defaults to numa.toml");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
if !arg1.is_empty()
|
||||||
|
&& arg1 != "run"
|
||||||
|
&& !arg1.contains('/')
|
||||||
|
&& !arg1.contains('\\')
|
||||||
|
&& !arg1.ends_with(".toml")
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
|
||||||
|
arg1
|
||||||
|
);
|
||||||
|
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let config_path = if arg1.is_empty() || arg1 == "run" {
|
let config_path = if arg1.is_empty() || arg1 == "run" {
|
||||||
@@ -235,6 +253,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) => {
|
||||||
@@ -286,6 +317,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();
|
||||||
@@ -469,6 +502,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/setup_phone.rs
Normal file
126
src/setup_phone.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
|
||||||
|
//!
|
||||||
|
//! Before the mobile API existed, this command spawned its own one-shot
|
||||||
|
//! HTTP server on port 8765 to serve a freshly-generated mobileconfig
|
||||||
|
//! 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::QrCode;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
fn render_qr(url: &str) -> Result<String, String> {
|
||||||
|
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
|
||||||
|
Ok(code
|
||||||
|
.render::<unicode::Dense1x2>()
|
||||||
|
.dark_color(unicode::Dense1x2::Light)
|
||||||
|
.light_color(unicode::Dense1x2::Dark)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the `numa setup-phone` flow.
|
||||||
|
pub async fn run() -> Result<(), String> {
|
||||||
|
let lan_ip = crate::lan::detect_lan_ip()
|
||||||
|
.ok_or("could not detect LAN IP — are you connected to a network?")?;
|
||||||
|
|
||||||
|
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT));
|
||||||
|
let api_reachable = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
tokio::net::TcpStream::connect(addr),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|r| r.is_ok())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !api_reachable {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
|
||||||
|
SETUP_PORT
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" The phone won't be able to download the profile until the mobile");
|
||||||
|
eprintln!(" API is running. Add this to your numa.toml and restart Numa:");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" [mobile]");
|
||||||
|
eprintln!(" enabled = true");
|
||||||
|
eprintln!();
|
||||||
|
return Err("mobile API not running".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
|
||||||
|
let qr = render_qr(&url)?;
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
|
||||||
|
eprintln!();
|
||||||
|
for line in qr.lines() {
|
||||||
|
eprintln!(" {}", line);
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" \x1b[1mOn your iPhone:\x1b[0m");
|
||||||
|
eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner");
|
||||||
|
eprintln!(" 2. Allow the download when Safari asks");
|
||||||
|
eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top");
|
||||||
|
eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)");
|
||||||
|
eprintln!(" 4. Tap Install (top right), enter passcode, Install again");
|
||||||
|
eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m");
|
||||||
|
eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
|
||||||
|
lan_ip
|
||||||
|
);
|
||||||
|
eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
|
||||||
|
eprintln!(" existing profile automatically (fixed UUID).");
|
||||||
|
eprintln!();
|
||||||
|
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!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_qr_produces_unicode() {
|
||||||
|
let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap();
|
||||||
|
assert!(!qr.is_empty());
|
||||||
|
// Dense1x2 uses these block characters
|
||||||
|
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user