feat(kiosk): LAN-side local HTTP server (GET layout API + admin proxy)

Kiosk now exposes :18090 with two surfaces:

- GET /local/layout/:id?key=<kiosk_local_key>
  Bookmark-friendly layout switch on this kiosk. Auth = kiosk-generated
  local key (32 random bytes, hex, stored at <state_dir>/local.key).

- ANY /proxy/* — forwards to BF server with the request's Authorization
  header preserved. Lets LAN clients reach a cloud-hosted BF server via
  the kiosk's local socket; kiosk adds no auth of its own.

Heartbeat reports {local_key, local_port}; kiosks table grows
local_key/local_port/local_last_ip columns. Admin kiosk edit page now
shows the local URLs as a copy-paste block.

Override port: BF_KIOSK_LOCAL_PORT. Disable: BF_KIOSK_LOCAL_DISABLE=1.
This commit is contained in:
Mitchell R 2026-05-14 07:24:21 +02:00
parent e5009fdd14
commit 6a8f6d76af
11 changed files with 318 additions and 1 deletions

View file

@ -39,3 +39,9 @@ gpiod = "0.3"
sha2 = "0.10" sha2 = "0.10"
ed25519-dalek = { version = "2", features = ["pem"] } ed25519-dalek = { version = "2", features = ["pem"] }
base64 = "0.22" 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"

205
kiosk/src/local_server.rs Normal file
View file

@ -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=<kiosk_local_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 <admin_api_key>` 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<Option<_>> 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<Mutex<Option<StdSender<WorkerMsg>>>>,
}
#[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<LocalServerState>,
Query(auth): Query<LocalAuth>,
) -> 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<LocalServerState>,
Path(id): Path<u32>,
Query(auth): Query<LocalAuth>,
) -> 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<LocalServerState>,
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) {}

View file

@ -4,6 +4,7 @@ mod cec;
mod firmware; mod firmware;
mod gpio; mod gpio;
mod hwmon; mod hwmon;
mod local_server;
mod pipeline; mod pipeline;
mod ui; mod ui;
mod ws_client; mod ws_client;

View file

@ -17,6 +17,22 @@ fn state_dir() -> PathBuf {
fn key_file() -> PathBuf { state_dir().join("kiosk.key") } fn key_file() -> PathBuf { state_dir().join("kiosk.key") }
fn server_file() -> PathBuf { state_dir().join("server.url") } fn server_file() -> PathBuf { state_dir().join("server.url") }
fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } 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. /// Persist the latest bundle to disk for offline boot.
pub fn save_bundle(bundle: &KioskBundle) { 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))| { 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 }) serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h })
}).collect(); }).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 let _ = client
.post(format!("{server}/api/kiosk/heartbeat")) .post(format!("{server}/api/kiosk/heartbeat"))
.header("Authorization", format!("Bearer {key}")) .header("Authorization", format!("Bearer {key}"))
@ -240,6 +261,8 @@ pub fn heartbeat(
"cpu_temp_c": hw.cpu_temp_c, "cpu_temp_c": hw.cpu_temp_c,
"fan_rpm": hw.fan_rpm, "fan_rpm": hw.fan_rpm,
"fan_pwm": hw.fan_pwm, "fan_pwm": hw.fan_pwm,
"local_key": local_key,
"local_port": local_port,
})) }))
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.send(); .send();

View file

@ -13,6 +13,7 @@ use crate::cec;
use crate::gpio; use crate::gpio;
use crate::firmware; use crate::firmware;
use crate::hwmon; use crate::hwmon;
use crate::local_server;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
@ -168,6 +169,18 @@ fn activate(app: &Application) {
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone())); 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 // Spawn WS client in a separate thread for live updates
let server_ws = server.clone(); let server_ws = server.clone();
let key_ws = key.clone(); let key_ws = key.clone();

View file

@ -269,8 +269,16 @@ function registerKioskRoutes(
cpu_temp_c?: number | null; cpu_temp_c?: number | null;
fan_rpm?: number | null; fan_rpm?: number | null;
fan_pwm?: number | null; fan_pwm?: number | null;
local_key?: string | null;
local_port?: number | null;
}>(event); }>(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, { repo.touchKiosk(kiosk.id, {
bundle_version: body?.bundle_version ?? null, bundle_version: body?.bundle_version ?? null,
kiosk_app_version: body?.kiosk_app_version ?? null, kiosk_app_version: body?.kiosk_app_version ?? null,
@ -278,6 +286,9 @@ function registerKioskRoutes(
cpu_temp_c: body?.cpu_temp_c ?? null, cpu_temp_c: body?.cpu_temp_c ?? null,
fan_rpm: body?.fan_rpm ?? null, fan_rpm: body?.fan_rpm ?? null,
fan_pwm: body?.fan_pwm ?? 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 // Sync displays reported by the kiosk

View file

@ -259,6 +259,9 @@ export function rowToKiosk(r: Row): Kiosk {
firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]),
firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]), firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]),
firmware_last_error: sn(r["firmware_last_error"]), 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"]), created_at: s(r["created_at"]),
}; };
} }

View file

@ -753,4 +753,11 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT");
addColumnIfNotExists(db, "kiosks", "firmware_last_error", "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");
},
]; ];

View file

@ -1037,6 +1037,9 @@ export class Repository {
cpu_temp_c?: number | null; cpu_temp_c?: number | null;
fan_rpm?: number | null; fan_rpm?: number | null;
fan_pwm?: number | null; fan_pwm?: number | null;
local_key?: string | null;
local_port?: number | null;
local_last_ip?: string | null;
}, },
): void { ): void {
this.prep( this.prep(
@ -1047,7 +1050,10 @@ export class Repository {
os_version = COALESCE(?, os_version), os_version = COALESCE(?, os_version),
cpu_temp_c = ?, cpu_temp_c = ?,
fan_rpm = ?, 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 = ?`, WHERE id = ?`,
).run( ).run(
isoNow(), isoNow(),
@ -1057,6 +1063,9 @@ export class Repository {
patch.cpu_temp_c ?? null, patch.cpu_temp_c ?? null,
patch.fan_rpm ?? null, patch.fan_rpm ?? null,
patch.fan_pwm ?? null, patch.fan_pwm ?? null,
patch.local_key ?? null,
patch.local_port ?? null,
patch.local_last_ip ?? null,
id, id,
); );
} }

View file

@ -216,6 +216,9 @@ export interface Kiosk {
firmware_last_attempt_at: string | null; firmware_last_attempt_at: string | null;
firmware_last_attempt_version: string | null; firmware_last_attempt_version: string | null;
firmware_last_error: string | null; firmware_last_error: string | null;
local_key: string | null;
local_port: number | null;
local_last_ip: string | null;
created_at: string; created_at: string;
} }

View file

@ -1526,6 +1526,8 @@ export function KioskEditPage(props: KioskEditProps) {
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
)} )}
{(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })}
{/* GPIO bindings */} {/* GPIO bindings */}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2> <h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
@ -2840,3 +2842,37 @@ export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
</div> </div>
); );
} }
// ---- 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 || "<kiosk-ip>";
const base = `http://${ip}:${String(k.local_port)}`;
const sample = `${base}/local/layout/<layout_id>?key=${k.local_key}`;
const proxy = `${base}/proxy/admin/...`;
return (
<div class="card" style="margin-bottom:1.5rem">
<h3 style="margin:0 0 0.5rem; font-size:1rem">Local LAN endpoints</h3>
<p style="font-size:0.8rem; color:#666; margin:0 0 0.75rem">
Kiosk runs an HTTP listener on its own LAN address. Bookmark-friendly
GET URLs trigger layout switches without needing an admin session.
</p>
<div style="font-size:0.8rem; margin-bottom:0.5rem">
<strong>Layout switch (GET):</strong>
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap; word-break:break-all">{sample}</pre>
</div>
<div style="font-size:0.8rem; margin-bottom:0.5rem">
<strong>Admin proxy (forwards your Bearer to server):</strong>
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap">{proxy}</pre>
</div>
<div style="font-size:0.75rem; color:#999">
Last seen from IP: <code>{k.local_last_ip ?? "—"}</code>. Local key:
<code style="margin-left:0.25rem">{k.local_key.slice(0, 8)}{k.local_key.slice(-4)}</code>
</div>
</div>
);
}