mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
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:
parent
e5009fdd14
commit
6a8f6d76af
11 changed files with 318 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
205
kiosk/src/local_server.rs
Normal file
205
kiosk/src/local_server.rs
Normal 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) {}
|
||||
|
|
@ -4,6 +4,7 @@ mod cec;
|
|||
mod firmware;
|
||||
mod gpio;
|
||||
mod hwmon;
|
||||
mod local_server;
|
||||
mod pipeline;
|
||||
mod ui;
|
||||
mod ws_client;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
|
||||
|
|
@ -2840,3 +2842,37 @@ export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue