feat: mobile setup — QR onboarding, Wi-Fi scoped mobileconfig (#73)
* fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules Without OnDemandRules, iOS applies the DoT profile globally — cellular DNS breaks when the phone leaves the LAN. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: phone setup QR code in dashboard header - Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature) - Header popover: QR on desktop, direct download link on mobile viewports - Only visible when [mobile] enabled = true in config - Expose mobile.enabled and mobile.port in /stats response - Lazy-load QR on first click, dismiss on outside click Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add Cache-Control to /qr, re-fetch QR on each popover open Cache-Control: no-store prevents stale QR after LAN IP change. Remove qrLoaded flag so the QR always reflects the current IP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: rustfmt serve_qr response tuple Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add iOS install steps to phone setup popover iOS shows "Profile Downloaded" with no guidance. The popover now includes the 3-step install flow including the buried Certificate Trust Settings toggle. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #73.
This commit is contained in:
36
src/api.rs
36
src/api.rs
@@ -57,6 +57,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||
.route("/services/{name}/routes", post(add_route))
|
||||
.route("/services/{name}/routes", delete(remove_route))
|
||||
.route("/ca.pem", get(serve_ca))
|
||||
.route("/qr", get(serve_qr))
|
||||
.route("/fonts/fonts.css", get(serve_fonts_css))
|
||||
.route(
|
||||
"/fonts/dm-sans-latin.woff2",
|
||||
@@ -170,9 +171,16 @@ struct StatsResponse {
|
||||
overrides: OverrideStats,
|
||||
blocking: BlockingStatsResponse,
|
||||
lan: LanStatsResponse,
|
||||
mobile: MobileStatsResponse,
|
||||
memory: MemoryStats,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MobileStatsResponse {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LanStatsResponse {
|
||||
enabled: bool,
|
||||
@@ -551,6 +559,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
enabled: ctx.lan_enabled,
|
||||
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
||||
},
|
||||
mobile: MobileStatsResponse {
|
||||
enabled: ctx.mobile_enabled,
|
||||
port: ctx.mobile_port,
|
||||
},
|
||||
memory: MemoryStats {
|
||||
cache_bytes,
|
||||
blocklist_bytes,
|
||||
@@ -931,6 +943,28 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
if !ctx.mobile_enabled {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let lan_ip = *ctx.lan_ip.lock().unwrap();
|
||||
let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
|
||||
let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(180, 180)
|
||||
.dark_color(qrcode::render::svg::Color("#2c2418"))
|
||||
.light_color(qrcode::render::svg::Color("#faf7f2"))
|
||||
.build();
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
],
|
||||
svg,
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_fonts_css() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
@@ -1005,6 +1039,8 @@ mod tests {
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct ServerCtx {
|
||||
/// 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>,
|
||||
pub mobile_enabled: bool,
|
||||
pub mobile_port: u16,
|
||||
}
|
||||
|
||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||
|
||||
@@ -383,6 +383,8 @@ mod tests {
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
@@ -319,6 +319,8 @@ async fn main() -> numa::Result<()> {
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
health_meta,
|
||||
ca_pem,
|
||||
mobile_enabled: config.mobile.enabled,
|
||||
mobile_port: config.mobile.port,
|
||||
});
|
||||
|
||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||
|
||||
@@ -144,8 +144,6 @@ fn build_ca_payload(ca_pem: &str) -> String {
|
||||
}
|
||||
|
||||
/// 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>
|
||||
@@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
||||
<key>ServerName</key>
|
||||
<string>numa.numa</string>
|
||||
</dict>
|
||||
<key>OnDemandRules</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>Action</key>
|
||||
<string>Connect</string>
|
||||
<key>InterfaceTypeMatch</key>
|
||||
<string>WiFi</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Action</key>
|
||||
<string>Disconnect</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
|
||||
<string>Routes DNS queries through Numa over DoT when on Wi-Fi</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Numa DNS-over-TLS</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
|
||||
Reference in New Issue
Block a user