diff --git a/Cargo.toml b/Cargo.toml index 4b881c5..95e094b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" rustls-pemfile = "2.2.0" -qrcode = { version = "0.14", default-features = false } +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/site/dashboard.html b/site/dashboard.html index c78c48f..f68fc27 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -554,6 +554,15 @@ body {
DNS that governs itself
+
@@ -788,6 +797,36 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } +let qrLoaded = false; +let mobilePort = 8765; +function togglePhoneSetup() { + const pop = document.getElementById('phoneSetupPopover'); + const isOpen = pop.style.display !== 'none'; + pop.style.display = isOpen ? 'none' : 'block'; + if (!isOpen && !qrLoaded) { + if (window.innerWidth <= 700) { + document.getElementById('qrContainer').style.display = 'none'; + const linkEl = document.getElementById('phoneSetupLink'); + const host = window.location.hostname; + linkEl.style.display = 'block'; + linkEl.innerHTML = `Install Profile`; + } else { + fetch(API + '/qr').then(r => r.text()).then(svg => { + document.getElementById('qrContainer').innerHTML = svg; + qrLoaded = true; + }).catch(() => { + document.getElementById('qrContainer').innerHTML = '
Could not load QR
'; + }); + } + } +} +document.addEventListener('click', (e) => { + const setup = document.getElementById('phoneSetup'); + if (setup && !setup.contains(e.target)) { + document.getElementById('phoneSetupPopover').style.display = 'none'; + } +}); + function shortSrc(addr) { if (!addr) return ''; const ip = addr.replace(/:\d+$/, ''); @@ -1058,6 +1097,14 @@ async function refresh() { } } + const phoneSetupEl = document.getElementById('phoneSetup'); + if (stats.mobile && stats.mobile.enabled) { + phoneSetupEl.style.display = ''; + mobilePort = stats.mobile.port; + } else { + phoneSetupEl.style.display = 'none'; + } + document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('blockedCount').textContent = formatNumber(q.blocked); const bl = stats.blocking; diff --git a/src/api.rs b/src/api.rs index fed7d5b..56e5b75 100644 --- a/src/api.rs +++ b/src/api.rs @@ -57,6 +57,7 @@ pub fn router(ctx: Arc) -> 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>) -> Json { 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,22 @@ pub async fn serve_ca(State(ctx): State>) -> Result>) -> Result { + 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::() + .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")], svg)) +} + async fn serve_fonts_css() -> impl IntoResponse { ( [ @@ -1005,6 +1033,8 @@ mod tests { dnssec_strict: false, health_meta: crate::health::HealthMeta::test_fixture(), ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, }) } diff --git a/src/ctx.rs b/src/ctx.rs index cf3522d..6b774eb 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -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, + pub mobile_enabled: bool, + pub mobile_port: u16, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, diff --git a/src/dot.rs b/src/dot.rs index 32d32ba..3ed47ba 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -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(); diff --git a/src/main.rs b/src/main.rs index 70bc3f9..62acb69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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();