diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml index 6798a13..919eb41 100644 --- a/kiosk/Cargo.toml +++ b/kiosk/Cargo.toml @@ -39,3 +39,9 @@ gpiod = "0.3" sha2 = "0.10" ed25519-dalek = { version = "2", features = ["pem"] } base64 = "0.22" + +# Local HTTP server on kiosk (LAN GET-only layout switch + admin proxy) +axum = "0.7" +tower = "0.5" +hex = "0.4" +rand = "0.8" diff --git a/kiosk/src/local_server.rs b/kiosk/src/local_server.rs new file mode 100644 index 0000000..1d928df --- /dev/null +++ b/kiosk/src/local_server.rs @@ -0,0 +1,205 @@ +//! Kiosk-local HTTP server (LAN-side, on the kiosk Pi itself). +//! +//! Two surfaces: +//! +//! 1. **GET-only layout API** — `/local/layout/:id?key=` +//! Lets anyone on the LAN with the kiosk's local key trigger a layout +//! switch on THIS kiosk via a plain browser URL. Bookmark-friendly. No +//! body, no admin credentials needed — auth is the local key generated +//! at boot and surfaced to admin via heartbeat. Only `GET` accepted. +//! +//! 2. **Admin proxy** — `/proxy/*` forwards to the BF server with the +//! request's `Authorization: Bearer ` header preserved. +//! Lets LAN-only clients reach a cloud-hosted BF server through the +//! kiosk's local socket. Kiosk adds no auth of its own — server-side +//! auth still enforces. +//! +//! Listens on `0.0.0.0:18090` by default. Override with env +//! `BF_KIOSK_LOCAL_PORT`. Disable with `BF_KIOSK_LOCAL_DISABLE=1`. + +use std::net::SocketAddr; +use std::sync::mpsc::Sender as StdSender; +use std::sync::{Arc, Mutex}; + +use axum::{ + body::{Body, Bytes}, + extract::{Path, Query, Request, State}, + http::{HeaderMap, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::{any, get}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::WorkerMsg; + +#[derive(Clone)] +pub struct LocalServerState { + pub local_key: String, + pub server_url: String, + pub kiosk_key: String, + /// Channel into the kiosk UI worker so layout-switch requests reach the + /// GTK main loop. Wrapped in Mutex> so the state struct stays + /// cheap to clone (Arc) without forcing every consumer to take a lock + /// just to read URL/key fields. + pub ui_tx: Arc>>>, +} + +#[derive(Deserialize)] +pub struct LocalAuth { key: String } + +#[derive(Serialize)] +pub struct LocalInfo { + kiosk_local_port: u16, + server_url: String, +} + +pub fn start(state: LocalServerState) { + if std::env::var("BF_KIOSK_LOCAL_DISABLE").ok().as_deref() == Some("1") { + info!("local-server: disabled by BF_KIOSK_LOCAL_DISABLE=1"); + return; + } + let port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(18090); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("local-server tokio runtime"); + rt.block_on(async move { + let app = Router::new() + .route("/local/info", get(local_info_handler)) + .route("/local/layout/:id", get(local_layout_handler)) + .route("/proxy/*path", any(proxy_handler)) + .with_state(state); + + let addr: SocketAddr = ([0, 0, 0, 0], port).into(); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!("local-server: bind {addr} failed: {e}"); + return; + } + }; + info!("local-server: listening on {addr} (GET-only layout API + /proxy/*)"); + if let Err(e) = axum::serve(listener, app).await { + warn!("local-server: serve error: {e}"); + } + }); + }); +} + +async fn local_info_handler( + State(state): State, + Query(auth): Query, +) -> Response { + if !constant_time_eq(&auth.key, &state.local_key) { + return (StatusCode::UNAUTHORIZED, "bad key").into_response(); + } + Json(LocalInfo { + kiosk_local_port: 18090, + server_url: state.server_url.clone(), + }) + .into_response() +} + +async fn local_layout_handler( + State(state): State, + Path(id): Path, + Query(auth): Query, +) -> Response { + if !constant_time_eq(&auth.key, &state.local_key) { + return (StatusCode::UNAUTHORIZED, "bad key").into_response(); + } + let tx = state.ui_tx.lock().ok().and_then(|g| g.clone()); + let Some(tx) = tx else { + return (StatusCode::SERVICE_UNAVAILABLE, "ui not ready").into_response(); + }; + if let Err(e) = tx.send(WorkerMsg::SwitchLayout(id)) { + warn!("local-server: send SwitchLayout failed: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response(); + } + info!("local-server: switched to layout {id}"); + (StatusCode::NO_CONTENT, "").into_response() +} + +/// Forward any request under /proxy/* to the BF server. Method, query +/// string, body, and Authorization header are preserved. Kiosk adds NO auth +/// — caller must supply their own admin API key (Bearer) which server-side +/// auth verifies. +async fn proxy_handler( + State(state): State, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let raw_path = uri.path(); + let path = raw_path.strip_prefix("/proxy").unwrap_or(raw_path); + let q = uri.query().map(|q| format!("?{q}")).unwrap_or_default(); + let target = format!("{}{}{}", state.server_url.trim_end_matches('/'), path, q); + + let client = reqwest::Client::new(); + let mut req = client.request(reqwest_method(&method), &target); + for (k, v) in headers.iter() { + let name = k.as_str(); + // Skip hop-by-hop + host headers — let reqwest set its own. + if matches!( + name, + "host" | "content-length" | "connection" | "keep-alive" | "transfer-encoding" + ) { + continue; + } + if let Ok(val) = v.to_str() { + req = req.header(name, val); + } + } + if !body.is_empty() { + req = req.body(body.to_vec()); + } + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + warn!("local-server: proxy → {target} failed: {e}"); + return (StatusCode::BAD_GATEWAY, "proxy upstream error").into_response(); + } + }; + let status_code = resp.status().as_u16(); + let mut builder = Response::builder().status(status_code); + for (k, v) in resp.headers().iter() { + let name = k.as_str(); + if matches!(name, "connection" | "keep-alive" | "transfer-encoding") { + continue; + } + builder = builder.header(name, v); + } + let bytes = match resp.bytes().await { + Ok(b) => b, + Err(e) => { + warn!("local-server: proxy body read failed: {e}"); + return (StatusCode::BAD_GATEWAY, "proxy upstream body error").into_response(); + } + }; + builder + .body(Body::from(bytes)) + .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bad proxy response").into_response()) +} + +fn reqwest_method(m: &Method) -> reqwest::Method { + reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET) +} + +fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { return false; } + let mut diff = 0u8; + for (x, y) in a.bytes().zip(b.bytes()) { diff |= x ^ y; } + diff == 0 +} + +/// Drop in Request unused-import suppression on non-feature builds. +#[allow(dead_code)] +fn _request_marker(_: Request) {} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 0106dd9..bc60006 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -4,6 +4,7 @@ mod cec; mod firmware; mod gpio; mod hwmon; +mod local_server; mod pipeline; mod ui; mod ws_client; diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 1333cc0..b888d93 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -17,6 +17,22 @@ fn state_dir() -> PathBuf { fn key_file() -> PathBuf { state_dir().join("kiosk.key") } fn server_file() -> PathBuf { state_dir().join("server.url") } fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } +fn local_key_file() -> PathBuf { state_dir().join("local.key") } + +/// Load (or generate) the kiosk-local API key used by the LAN-side GET +/// layout-switch endpoint. Persisted hex, 32 bytes random. +pub fn load_or_create_local_key() -> String { + if let Ok(s) = fs::read_to_string(local_key_file()) { + let trimmed = s.trim().to_string(); + if trimmed.len() >= 16 { return trimmed; } + } + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let hex_key = hex::encode(buf); + let _ = fs::write(local_key_file(), &hex_key); + hex_key +} /// Persist the latest bundle to disk for offline boot. pub fn save_bundle(bundle: &KioskBundle) { @@ -231,6 +247,11 @@ pub fn heartbeat( let display_info: Vec<_> = displays.iter().enumerate().map(|(index, (name, w, h))| { serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h }) }).collect(); + // Surface the LAN-side local key + port to admin so the UI can show a + // copy-paste URL for bookmark-style layout switches. + let local_key = load_or_create_local_key(); + let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") + .ok().and_then(|s| s.parse().ok()).unwrap_or(18090); let _ = client .post(format!("{server}/api/kiosk/heartbeat")) .header("Authorization", format!("Bearer {key}")) @@ -240,6 +261,8 @@ pub fn heartbeat( "cpu_temp_c": hw.cpu_temp_c, "fan_rpm": hw.fan_rpm, "fan_pwm": hw.fan_pwm, + "local_key": local_key, + "local_port": local_port, })) .timeout(Duration::from_secs(5)) .send(); diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 57db6fe..80a938a 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -13,6 +13,7 @@ use crate::cec; use crate::gpio; use crate::firmware; use crate::hwmon; +use crate::local_server; use crate::pipeline; use crate::server; use crate::ws_client; @@ -168,6 +169,18 @@ fn activate(app: &Application) { let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone())); } + // Start the LAN-side local server now that we have server URL + kiosk + // key. Reports the local key to the server on next heartbeat so admin + // can see it. + let local_key = server::load_or_create_local_key(); + info!("local-server: kiosk_local_key prefix={}…", &local_key[..8]); + local_server::start(local_server::LocalServerState { + local_key, + server_url: server.clone(), + kiosk_key: key.clone(), + ui_tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx.clone()))), + }); + // Spawn WS client in a separate thread for live updates let server_ws = server.clone(); let key_ws = key.clone(); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index b7b91a4..a078219 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -269,8 +269,16 @@ function registerKioskRoutes( cpu_temp_c?: number | null; fan_rpm?: number | null; fan_pwm?: number | null; + local_key?: string | null; + local_port?: number | null; }>(event); + // Capture the kiosk's LAN-side IP from the heartbeat connection so admin + // can render a copy-paste URL even when the kiosk has no DNS name. + const remoteIp = getRequestHeader(event, "x-real-ip") + ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() + ?? null; + repo.touchKiosk(kiosk.id, { bundle_version: body?.bundle_version ?? null, kiosk_app_version: body?.kiosk_app_version ?? null, @@ -278,6 +286,9 @@ function registerKioskRoutes( cpu_temp_c: body?.cpu_temp_c ?? null, fan_rpm: body?.fan_rpm ?? null, fan_pwm: body?.fan_pwm ?? null, + local_key: body?.local_key ?? null, + local_port: body?.local_port ?? null, + local_last_ip: remoteIp, }); // Sync displays reported by the kiosk diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index a0a166e..61f4639 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -259,6 +259,9 @@ export function rowToKiosk(r: Row): Kiosk { firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]), firmware_last_error: sn(r["firmware_last_error"]), + local_key: sn(r["local_key"]), + local_port: nn(r["local_port"]), + local_last_ip: sn(r["local_last_ip"]), created_at: s(r["created_at"]), }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 7c080d3..e87ecab 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -753,4 +753,11 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); addColumnIfNotExists(db, "kiosks", "firmware_last_error", "TEXT"); }, + + // ---- Kiosk LAN-side local server: reported via heartbeat ------------------ + (db: DatabaseSync) => { + addColumnIfNotExists(db, "kiosks", "local_key", "TEXT"); + addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 9eef9a8..615ebf0 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -1037,6 +1037,9 @@ export class Repository { cpu_temp_c?: number | null; fan_rpm?: number | null; fan_pwm?: number | null; + local_key?: string | null; + local_port?: number | null; + local_last_ip?: string | null; }, ): void { this.prep( @@ -1047,7 +1050,10 @@ export class Repository { os_version = COALESCE(?, os_version), cpu_temp_c = ?, fan_rpm = ?, - fan_pwm = ? + fan_pwm = ?, + local_key = COALESCE(?, local_key), + local_port = COALESCE(?, local_port), + local_last_ip = COALESCE(?, local_last_ip) WHERE id = ?`, ).run( isoNow(), @@ -1057,6 +1063,9 @@ export class Repository { patch.cpu_temp_c ?? null, patch.fan_rpm ?? null, patch.fan_pwm ?? null, + patch.local_key ?? null, + patch.local_port ?? null, + patch.local_last_ip ?? null, id, ); } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 3235bf3..6094c7d 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -216,6 +216,9 @@ export interface Kiosk { firmware_last_attempt_at: string | null; firmware_last_attempt_version: string | null; firmware_last_error: string | null; + local_key: string | null; + local_port: number | null; + local_last_ip: string | null; created_at: string; } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index c4862c4..c1077e1 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1526,6 +1526,8 @@ export function KioskEditPage(props: KioskEditProps) { KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) )} + {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} + {/* GPIO bindings */}

GPIO Bindings

@@ -2840,3 +2842,37 @@ export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
); } + +// ---- Kiosk local-server panel (LAN GET API + admin proxy) ------------------ + +interface KioskLocalPanelProps { kiosk: Kiosk } + +export function KioskLocalPanel(props: KioskLocalPanelProps) { + const k = props.kiosk; + if (!k.local_key || !k.local_port) return ""; + const ip = k.local_last_ip || ""; + const base = `http://${ip}:${String(k.local_port)}`; + const sample = `${base}/local/layout/?key=${k.local_key}`; + const proxy = `${base}/proxy/admin/...`; + return ( +
+

Local LAN endpoints

+

+ Kiosk runs an HTTP listener on its own LAN address. Bookmark-friendly + GET URLs trigger layout switches without needing an admin session. +

+
+ Layout switch (GET): +
{sample}
+
+
+ Admin proxy (forwards your Bearer to server): +
{proxy}
+
+
+ Last seen from IP: {k.local_last_ip ?? "—"}. Local key: + {k.local_key.slice(0, 8)}…{k.local_key.slice(-4)} +
+
+ ); +}