mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 22:26:33 +00:00
423 lines
14 KiB
Rust
423 lines
14 KiB
Rust
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use tracing::info;
|
|
|
|
use crate::bundle::KioskBundle;
|
|
|
|
pub struct DisplayReport {
|
|
pub index: usize,
|
|
pub name: String,
|
|
pub width_px: u32,
|
|
pub height_px: u32,
|
|
pub power_state: String,
|
|
}
|
|
|
|
fn kiosk_app_version() -> &'static str {
|
|
option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
|
}
|
|
|
|
fn reported_hostname() -> Option<String> {
|
|
hostname::get()
|
|
.ok()
|
|
.map(|h| h.to_string_lossy().trim().to_string())
|
|
.filter(|h| !h.is_empty())
|
|
}
|
|
|
|
fn read_network_interfaces() -> Vec<Value> {
|
|
let out = match Command::new("ip").args(["-j", "addr", "show"]).output() {
|
|
Ok(out) if out.status.success() => out,
|
|
Ok(out) => {
|
|
tracing::warn!("ip -j addr show exited with {}", out.status);
|
|
return Vec::new();
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!("ip -j addr show failed: {err}");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
let parsed: Value = match serde_json::from_slice(&out.stdout) {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
tracing::warn!("ip -j addr show parse failed: {err}");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
let Some(items) = parsed.as_array() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
items
|
|
.iter()
|
|
.filter_map(|item| {
|
|
let name = item.get("ifname")?.as_str()?;
|
|
let addr_info = item.get("addr_info")?.as_array()?;
|
|
let ips: Vec<Value> = addr_info
|
|
.iter()
|
|
.filter_map(|addr| {
|
|
let family = addr.get("family")?.as_str()?;
|
|
if family != "inet" && family != "inet6" {
|
|
return None;
|
|
}
|
|
let local = addr.get("local")?.as_str()?;
|
|
let prefix = addr.get("prefixlen").and_then(|v| v.as_u64());
|
|
Some(match prefix {
|
|
Some(prefix) => Value::String(format!("{local}/{prefix}")),
|
|
None => Value::String(local.to_string()),
|
|
})
|
|
})
|
|
.collect();
|
|
if ips.is_empty() {
|
|
return None;
|
|
}
|
|
Some(serde_json::json!({
|
|
"name": name,
|
|
"mac": item.get("address").and_then(|v| v.as_str()),
|
|
"operstate": item.get("operstate").and_then(|v| v.as_str()),
|
|
"ips": ips,
|
|
}))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn state_dir() -> PathBuf {
|
|
let home = dirs::home_dir().expect("no home directory");
|
|
let dir = home.join(".betterframe-kiosk");
|
|
fs::create_dir_all(&dir).ok();
|
|
dir
|
|
}
|
|
|
|
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. Stored
|
|
/// encrypted-at-rest (hardware-bound) so pulling the SD card doesn't yield
|
|
/// the key plaintext.
|
|
pub fn load_or_create_local_key() -> String {
|
|
let path = local_key_file();
|
|
if let Ok(raw) = fs::read(&path) {
|
|
let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
|
|
if let Some(trimmed) = crate::at_rest::read_text_maybe_encrypted(&path) {
|
|
if trimmed.len() >= 16 {
|
|
if !was_encrypted {
|
|
let _ = crate::at_rest::write_encrypted(&path, trimmed.as_bytes());
|
|
}
|
|
return trimmed;
|
|
}
|
|
}
|
|
}
|
|
use rand::RngCore;
|
|
let mut buf = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut buf);
|
|
let hex_key = hex::encode(buf);
|
|
let _ = crate::at_rest::write_encrypted(&path, hex_key.as_bytes());
|
|
hex_key
|
|
}
|
|
|
|
/// Persist the latest bundle to disk for offline boot. Encrypted at rest
|
|
/// because the bundle contains camera RTSP URIs with credentials in URL
|
|
/// form (rtsp://user:pass@host/...).
|
|
pub fn save_bundle(bundle: &KioskBundle) {
|
|
match serde_json::to_vec(bundle) {
|
|
Ok(bytes) => {
|
|
if let Err(e) = crate::at_rest::write_encrypted(&bundle_cache_path(), &bytes) {
|
|
tracing::warn!("failed to save bundle cache: {e}");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!("failed to serialize bundle: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Load a cached bundle from disk. Returns None if file missing or invalid.
|
|
/// Tolerates legacy plaintext (kiosks upgraded from a pre-at_rest build)
|
|
/// so pairing survives the rollout.
|
|
pub fn load_cached_bundle() -> Option<KioskBundle> {
|
|
let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?;
|
|
match serde_json::from_slice::<KioskBundle>(&bytes) {
|
|
Ok(b) => Some(b),
|
|
Err(e) => {
|
|
tracing::warn!("cached bundle invalid: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Discover the BetterFrame server.
|
|
pub fn discover_server(override_url: Option<&str>) -> String {
|
|
if let Some(url) = override_url {
|
|
return url.to_string();
|
|
}
|
|
|
|
// Check saved
|
|
if let Ok(saved) = fs::read_to_string(server_file()) {
|
|
let saved = saved.trim().to_string();
|
|
if check_health(&saved) {
|
|
return saved;
|
|
}
|
|
}
|
|
|
|
// Probe order: on-device → LAN mDNS → BetterCorp managed cloud.
|
|
// Single image works for aio (server beside kiosk on same Pi), on-prem
|
|
// (server on the LAN, discoverable by mDNS), and client-only (no local
|
|
// server — falls through to the cloud).
|
|
let candidates = [
|
|
"http://localhost",
|
|
"http://betterframe.local",
|
|
"https://frame-eu.betterportal.net",
|
|
];
|
|
|
|
for url in candidates {
|
|
info!("trying {url}...");
|
|
if check_health(url) {
|
|
fs::write(server_file(), url).ok();
|
|
return url.to_string();
|
|
}
|
|
}
|
|
|
|
panic!("Could not find BetterFrame server");
|
|
}
|
|
|
|
fn check_health(url: &str) -> bool {
|
|
reqwest::blocking::Client::new()
|
|
.get(format!("{url}/healthz"))
|
|
.timeout(Duration::from_secs(3))
|
|
.send()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Check if already paired (key file exists).
|
|
pub fn is_paired() -> bool {
|
|
key_file().exists()
|
|
}
|
|
|
|
/// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from
|
|
/// a pre-at_rest build) and re-stores it ciphertext in place so subsequent
|
|
/// SD-card extractions don't see the bearer token.
|
|
pub fn load_key() -> String {
|
|
let path = key_file();
|
|
let raw = fs::read(&path).expect("failed to read kiosk key");
|
|
let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
|
|
let key = crate::at_rest::read_text_maybe_encrypted(&path).expect("failed to decode kiosk key");
|
|
if !was_encrypted {
|
|
// Best-effort migrate. If write fails (e.g. RO mount during a
|
|
// recovery boot) we still hand back the key so the kiosk works.
|
|
let _ = crate::at_rest::write_encrypted(&path, key.as_bytes());
|
|
}
|
|
key
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct InitiateResp {
|
|
code: String,
|
|
expires_at: String,
|
|
}
|
|
|
|
/// Initiate pairing — returns (code, expires_at).
|
|
pub fn initiate_pairing(server: &str) -> (String, String) {
|
|
let hostname = hostname::get()
|
|
.map(|h| h.to_string_lossy().to_string())
|
|
.unwrap_or_else(|_| "kiosk".into());
|
|
|
|
let hw_model = fs::read_to_string("/proc/device-tree/model")
|
|
.unwrap_or_else(|_| "unknown".into())
|
|
.replace('\0', "");
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp: InitiateResp = client
|
|
.post(format!("{server}/api/pair/initiate"))
|
|
.json(&serde_json::json!({
|
|
"proposed_name": hostname,
|
|
"hardware_model": hw_model,
|
|
"capabilities": ["rtsp", "gstreamer", "gtk4"]
|
|
}))
|
|
.send()
|
|
.expect("pairing initiate failed")
|
|
.json()
|
|
.expect("bad initiate response");
|
|
|
|
(resp.code, resp.expires_at)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ClaimResp {
|
|
status: String,
|
|
kiosk_key: Option<String>,
|
|
kiosk_name: Option<String>,
|
|
}
|
|
|
|
/// Poll for pairing claim. Returns (name, key) when admin confirms.
|
|
pub fn poll_claim(server: &str, code: &str) -> (String, String) {
|
|
let client = reqwest::blocking::Client::new();
|
|
loop {
|
|
let resp = client
|
|
.post(format!("{server}/api/pair/claim"))
|
|
.json(&serde_json::json!({ "code": code }))
|
|
.send()
|
|
.expect("claim request failed");
|
|
|
|
if resp.status().as_u16() == 200 {
|
|
let claim: ClaimResp = resp.json().expect("bad claim response");
|
|
if claim.status == "claimed" {
|
|
let key = claim.kiosk_key.expect("missing kiosk_key");
|
|
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
|
|
crate::at_rest::write_encrypted(&key_file(), key.as_bytes())
|
|
.expect("failed to save kiosk key");
|
|
// Successful pairing resets all terminal lockout state.
|
|
crate::remote_debug::reset_all_lockouts();
|
|
return (name, key);
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
}
|
|
}
|
|
|
|
/// Fetch bundle from server. Returns None on network/HTTP/parse failure.
|
|
/// On success, also writes the bundle to the on-disk cache.
|
|
pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = match client
|
|
.get(format!("{server}/api/kiosk/bundle"))
|
|
.header("Authorization", format!("Bearer {key}"))
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!("bundle fetch failed: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
if !resp.status().is_success() {
|
|
tracing::warn!("bundle fetch returned {}", resp.status());
|
|
return None;
|
|
}
|
|
|
|
match resp.json::<KioskBundle>() {
|
|
Ok(b) => {
|
|
save_bundle(&b);
|
|
Some(b)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("bundle parse failed: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send heartbeat with display geometry + hwmon.
|
|
/// Report a kiosk-side layout switch to the server, which forwards to
|
|
/// node-red as a `layout.changed` event. Covers idle reverts and any other
|
|
/// switch the kiosk performs without an admin click (admin clicks already
|
|
/// emit server-side).
|
|
pub fn report_layout_change(
|
|
server: &str,
|
|
key: &str,
|
|
display_id: u32,
|
|
layout_id: u32,
|
|
layout_name: &str,
|
|
) {
|
|
let client = reqwest::blocking::Client::new();
|
|
let _ = client
|
|
.post(format!("{server}/api/kiosk/event"))
|
|
.header("Authorization", format!("Bearer {key}"))
|
|
.json(&serde_json::json!({
|
|
"topic": "layout.changed",
|
|
"source_type": "system",
|
|
"payload": {
|
|
"display_id": display_id,
|
|
"layout_id": layout_id,
|
|
"layout_name": layout_name,
|
|
},
|
|
}))
|
|
.timeout(Duration::from_secs(5))
|
|
.send();
|
|
}
|
|
|
|
pub fn report_kiosk_log(server: &str, key: &str, level: &str, message: &str, payload: Value) {
|
|
let client = reqwest::blocking::Client::new();
|
|
let _ = client
|
|
.post(format!("{server}/api/kiosk/event"))
|
|
.header("Authorization", format!("Bearer {key}"))
|
|
.json(&serde_json::json!({
|
|
"topic": "kiosk.log",
|
|
"source_type": "system",
|
|
"payload": {
|
|
"level": level,
|
|
"message": message,
|
|
"context": payload,
|
|
},
|
|
}))
|
|
.timeout(Duration::from_secs(5))
|
|
.send();
|
|
}
|
|
|
|
pub fn heartbeat(
|
|
server: &str,
|
|
key: &str,
|
|
displays: &[DisplayReport],
|
|
hw: &crate::hwmon::HwInfo,
|
|
) -> bool {
|
|
let client = reqwest::blocking::Client::new();
|
|
let display_info: Vec<_> = displays.iter().map(|d| {
|
|
serde_json::json!({
|
|
"index": d.index,
|
|
"name": &d.name,
|
|
"width_px": d.width_px,
|
|
"height_px": d.height_px,
|
|
"power_state": &d.power_state,
|
|
})
|
|
}).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 hostname = reported_hostname();
|
|
let network_interfaces = read_network_interfaces();
|
|
client
|
|
.post(format!("{server}/api/kiosk/heartbeat"))
|
|
.header("Authorization", format!("Bearer {key}"))
|
|
.json(&serde_json::json!({
|
|
"kiosk_app_version": kiosk_app_version(),
|
|
"os_version": crate::os_update::current_os_version_public(),
|
|
"displays": display_info,
|
|
"cpu_temp_c": hw.cpu_temp_c,
|
|
"cpu_load_percent": hw.cpu_load_percent,
|
|
"fan_rpm": hw.fan_rpm,
|
|
"fan_pwm": hw.fan_pwm,
|
|
"memory_total_mb": hw.memory_total_mb,
|
|
"memory_used_mb": hw.memory_used_mb,
|
|
"disk_total_mb": hw.disk_total_mb,
|
|
"disk_free_mb": hw.disk_free_mb,
|
|
"disk_used_percent": hw.disk_used_percent,
|
|
"local_key": local_key,
|
|
"local_port": local_port,
|
|
"reported_hostname": hostname,
|
|
"network_interfaces": network_interfaces,
|
|
}))
|
|
.timeout(Duration::from_secs(5))
|
|
.send()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|