diff --git a/.gitignore b/.gitignore index 8df4587..822507d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ npm-debug.log* *.tsbuildinfo # RAUC signing keys (generated locally, secrets set in GitHub Actions) rauc-signing/ +old-python/ diff --git a/deploy/angie/betterframe.docker.conf b/deploy/angie/betterframe.docker.conf index d0d8d2d..2b0d1c6 100644 --- a/deploy/angie/betterframe.docker.conf +++ b/deploy/angie/betterframe.docker.conf @@ -63,6 +63,18 @@ server { proxy_send_timeout 86400s; } + # Admin debug WS (journal + terminal) — authenticated via API key in query. + location /ws/admin/debug/ { + proxy_pass http://betterframe_ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + location /nrdp/ { auth_request /api/admin/_check; proxy_pass http://betterframe_nodered; diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index db73353..818be9d 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -8,6 +8,7 @@ mod local_server; mod onvif_events; mod os_update; mod pipeline; +mod remote_debug; mod server; mod ui; mod ws_client; @@ -27,6 +28,10 @@ pub enum ServerMsg { }, /// Server-pushed "go check for a firmware update now". FirmwareCheck, + /// Show terminal auth code on screen (overlay). + ShowTerminalCode(String), + /// Dismiss the terminal code overlay. + DismissTerminalCode, } use gstreamer::prelude::PluginFeatureExtManual; diff --git a/kiosk/src/remote_debug.rs b/kiosk/src/remote_debug.rs new file mode 100644 index 0000000..c6ab3e8 --- /dev/null +++ b/kiosk/src/remote_debug.rs @@ -0,0 +1,256 @@ +//! Remote debug access: on-demand journal streaming + gated terminal. +//! +//! Journal: server sends "journal-start" → kiosk spawns `journalctl -f`, +//! pipes lines back as "journal-line" messages. "journal-stop" kills it. +//! One-way, no auth ceremony beyond the existing kiosk WS connection. +//! +//! Terminal: dev-channel only + on-screen code auth + lockout. +//! - Server sends "terminal-request" +//! - Kiosk checks lockout state + firmware_channel == "dev" +//! - Shows 8-char code on screen (NOT logged) +//! - Server relays admin's code via "terminal-auth" +//! - Kiosk validates locally +//! - On success: spawns bash, relays I/O as "terminal-data" (base64) +//! - On failure: increments attempt counter +//! +//! Lockout: 3 failed attempts per boot → lockout_count++. 3 lockouts +//! (9 total failures across reboots) → permanent lockout (reflash only). +//! Successful kiosk pairing resets all lockout state. + +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; + +use base64::Engine; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +const LOCKOUT_PATH: &str = "/var/lib/betterframe/kiosk/terminal-lockout.json"; +const MAX_ATTEMPTS_PER_BOOT: u32 = 3; +const MAX_LOCKOUTS: u32 = 3; +const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const CODE_LEN: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LockoutState { + attempts_this_boot: u32, + lockout_count: u32, + permanent: bool, +} + +impl Default for LockoutState { + fn default() -> Self { + Self { attempts_this_boot: 0, lockout_count: 0, permanent: false } + } +} + +fn lockout_path() -> PathBuf { PathBuf::from(LOCKOUT_PATH) } + +fn load_lockout() -> LockoutState { + fs::read_to_string(lockout_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_lockout(state: &LockoutState) { + if let Some(parent) = lockout_path().parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(lockout_path(), serde_json::to_string(state).unwrap_or_default()); +} + +/// Reset attempts_this_boot on kiosk start (but keep lockout_count). +pub fn reset_boot_attempts() { + let mut state = load_lockout(); + state.attempts_this_boot = 0; + save_lockout(&state); +} + +/// Called after successful pairing — clears ALL lockout state. +pub fn reset_all_lockouts() { + let _ = fs::remove_file(lockout_path()); +} + +fn is_locked() -> bool { + let state = load_lockout(); + state.permanent || state.lockout_count >= MAX_LOCKOUTS +} + +pub fn is_locked_public() -> bool { is_locked() } + +fn record_failed_attempt() -> bool { + let mut state = load_lockout(); + state.attempts_this_boot += 1; + if state.attempts_this_boot >= MAX_ATTEMPTS_PER_BOOT { + state.lockout_count += 1; + state.attempts_this_boot = 0; + if state.lockout_count >= MAX_LOCKOUTS { + state.permanent = true; + } + } + save_lockout(&state); + state.permanent || state.lockout_count >= MAX_LOCKOUTS +} + +fn generate_code() -> String { + let mut rng = rand::thread_rng(); + let mut code = String::with_capacity(CODE_LEN); + for _ in 0..CODE_LEN { + let mut byte = [0u8; 1]; + rng.fill_bytes(&mut byte); + code.push(CODE_ALPHABET[(byte[0] as usize) % CODE_ALPHABET.len()] as char); + } + code +} + +// ---- Journal streaming ------------------------------------------------------ + +pub struct JournalStream { + kill: Arc>, +} + +impl JournalStream { + /// Spawn journalctl -f and call `on_line` for each line. Blocks until + /// stopped via `stop()` or the process exits. + pub fn start(on_line: F) -> Self { + let kill = Arc::new(Mutex::new(false)); + let kill_clone = kill.clone(); + + std::thread::spawn(move || { + let mut child = match Command::new("journalctl") + .args(["-u", "betterframe-kiosk", "-f", "--no-pager", "-o", "short-iso", "-n", "50"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + warn!("remote-debug: journalctl spawn failed: {e}"); + return; + } + }; + + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + for line in reader.lines() { + if *kill_clone.lock().unwrap() { + break; + } + match line { + Ok(line) => on_line(&line), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + }); + + Self { kill } + } + + pub fn stop(&self) { + *self.kill.lock().unwrap() = true; + } +} + +// ---- Terminal access -------------------------------------------------------- + +/// Check if terminal access is allowed. Returns error message if not. +pub fn check_terminal_access() -> Result<(), String> { + if is_locked() { + return Err("locked".to_string()); + } + // Check firmware channel — only dev allowed. + let channel = std::env::var("BF_FIRMWARE_CHANNEL").unwrap_or_else(|_| "stable".to_string()); + if channel != "dev" { + return Err("terminal access requires dev channel".to_string()); + } + Ok(()) +} + +/// Generate a code and return it. Caller is responsible for displaying it +/// on screen and NOT logging it. +pub fn create_terminal_challenge() -> Result { + check_terminal_access()?; + Ok(generate_code()) +} + +/// Validate the code. Returns true on match. On failure, records attempt +/// and returns false. Caller should check `is_locked()` after false. +pub fn validate_terminal_code(expected: &str, provided: &str) -> bool { + if expected.len() != provided.len() { + record_failed_attempt(); + return false; + } + // Constant-time compare + let mut diff = 0u8; + for (a, b) in expected.bytes().zip(provided.bytes()) { + diff |= a ^ b; + } + if diff != 0 { + record_failed_attempt(); + return false; + } + true +} + +/// Spawn a bash shell with piped stdin/stdout/stderr. Returns handles for +/// reading output and writing input. Caller is responsible for I/O relay. +pub struct TerminalSession { + child: std::process::Child, + stdin: Option, +} + +impl TerminalSession { + pub fn spawn() -> Result<(Self, std::process::ChildStdout, std::process::ChildStderr), String> { + let mut child = Command::new("bash") + .args(["--login"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("TERM", "xterm-256color") + .spawn() + .map_err(|e| format!("bash spawn: {e}"))?; + + let stdout = child.stdout.take().ok_or("no stdout")?; + let stderr = child.stderr.take().ok_or("no stderr")?; + let stdin = child.stdin.take(); + + Ok((Self { child, stdin }, stdout, stderr)) + } + + pub fn write_input(&mut self, data: &[u8]) -> Result<(), String> { + if let Some(ref mut stdin) = self.stdin { + stdin.write_all(data).map_err(|e| format!("stdin write: {e}"))?; + stdin.flush().map_err(|e| format!("stdin flush: {e}"))?; + } + Ok(()) + } + + pub fn kill(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + self.kill(); + } +} + +/// Encode bytes as base64 for WS transport. +pub fn b64_encode(data: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(data) +} + +/// Decode base64 from WS transport. +pub fn b64_decode(data: &str) -> Result, String> { + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("base64 decode: {e}")) +} diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index d784000..a90eac0 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -280,6 +280,8 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) { 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); } } diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 4bfdff9..89b084f 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -20,6 +20,7 @@ use crate::hwmon; use crate::local_server; use crate::onvif_events; use crate::os_update; +use crate::remote_debug; use crate::pipeline; use crate::server; use crate::ws_client; @@ -264,6 +265,19 @@ fn activate(app: &Application) { ServerMsg::FirmwareCheck => { maybe_apply_firmware_update(&server_for_reload, &key_for_reload); } + ServerMsg::ShowTerminalCode(code) => { + // Overlay on all windows: big centered code text. + // NOT logged — security requirement. + let code_clone = code.clone(); + gtk::glib::idle_add_local_once(move || { + show_terminal_code_overlay(&code_clone); + }); + } + ServerMsg::DismissTerminalCode => { + gtk::glib::idle_add_local_once(|| { + dismiss_terminal_code_overlay(); + }); + } } } }); @@ -271,6 +285,9 @@ fn activate(app: &Application) { // Heartbeat loop — reports display geometry + hwmon, also checks for // firmware + OS bundle updates so kiosks pick up new builds without // admin push. + // Reset terminal auth boot-attempt counter (lockout_count persists). + remote_debug::reset_boot_attempts(); + let mut first_iter = true; loop { let heartbeat_ok = send_heartbeat_now(&server, &key); @@ -1897,3 +1914,51 @@ fn add_css(widget: &impl IsA, css: &str) { gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, ); } + +// ---- Terminal code overlay -------------------------------------------------- +// Shown when admin requests terminal access. Big centered code on a dark +// semi-transparent backdrop over all kiosk windows. The code is NOT logged +// anywhere (security requirement — physical presence only). + +thread_local! { + static TERMINAL_CODE_OVERLAY: RefCell> = const { RefCell::new(None) }; +} + +fn show_terminal_code_overlay(code: &str) { + dismiss_terminal_code_overlay(); + + let win = gtk::Window::builder() + .title("Terminal Auth") + .decorated(false) + .modal(true) + .build(); + + let label = Label::new(Some(code)); + add_css(&label, "label { font-size: 72px; font-weight: 800; font-family: monospace; color: #fff; letter-spacing: 12px; }"); + + let hint = Label::new(Some("Enter this code in the admin UI to authorize terminal access")); + add_css(&hint, "label { font-size: 16px; color: #aaa; margin-top: 24px; }"); + + let vbox = GtkBox::new(Orientation::Vertical, 16); + vbox.set_valign(gtk::Align::Center); + vbox.set_halign(gtk::Align::Center); + vbox.set_vexpand(true); + vbox.set_hexpand(true); + vbox.append(&label); + vbox.append(&hint); + + add_css(&vbox, "box { background: rgba(0,0,0,0.85); }"); + win.set_child(Some(&vbox)); + win.set_fullscreened(true); + win.present(); + + TERMINAL_CODE_OVERLAY.with(|o| *o.borrow_mut() = Some(win)); +} + +fn dismiss_terminal_code_overlay() { + TERMINAL_CODE_OVERLAY.with(|o| { + if let Some(win) = o.borrow_mut().take() { + win.close(); + } + }); +} diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index e93264e..ca42a0a 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -1,4 +1,6 @@ +use std::io::Read; use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; @@ -7,6 +9,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use tracing::{info, warn}; use crate::ServerMsg; +use crate::remote_debug; #[derive(Deserialize)] struct OnvifSoapRequest { @@ -38,104 +41,64 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { let mut backoff = 1u64; loop { match connect_async(&ws_url).await { - Ok((mut ws, _resp)) => { + Ok((ws_stream, _resp)) => { info!("ws: connected"); backoff = 1; - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(text)) => { - if text.contains("\"type\":\"ping\"") { - let _ = ws - .send(Message::Text(r#"{"type":"pong"}"#.to_string())) - .await; - } else if text.contains("\"type\":\"onvif-soap-request\"") { - let Ok(msg) = serde_json::from_str::(&text) - else { - warn!("ws: onvif request was not valid JSON"); - continue; - }; - let Ok(req) = serde_json::from_value::(msg) - else { - warn!("ws: onvif request missing fields"); - continue; - }; - let response = perform_onvif_soap(req).await; - let _ = ws.send(Message::Text(response)).await; - } else if text.contains("\"type\":\"reload-bundle\"") { - info!("ws: reload-bundle received"); - let _ = tx.send(ServerMsg::ReloadBundle); - } else if text.contains("\"type\":\"standby\"") { - info!("ws: standby received"); - let display_id = serde_json::from_str::(&text) - .ok() - .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); - let _ = tx.send(ServerMsg::Standby(display_id)); - } else if text.contains("\"type\":\"wake\"") { - info!("ws: wake received"); - let display_id = serde_json::from_str::(&text) - .ok() - .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); - let _ = tx.send(ServerMsg::Wake(display_id)); - } else if text.contains("\"type\":\"layout-switch\"") { - info!("ws: layout-switch received: {text}"); - let msg = serde_json::from_str::(&text).ok(); - let layout_id = msg - .as_ref() - .and_then(|m| m.get("layout_id")) - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - let display_id = msg - .as_ref() - .and_then(|m| m.get("display_id")) - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - if let Some(layout_id) = layout_id { - let _ = tx.send(ServerMsg::SwitchLayout { - display_id, - layout_id, - }); - } else { - warn!("ws: layout-switch missing layout_id"); + let (mut writer, mut reader) = ws_stream.split(); + + // Channel for sync threads (journal, terminal) to send WS messages. + let (outbound_tx, mut outbound_rx) = tokio::sync::mpsc::unbounded_channel::(); + + // State for journal streaming + terminal session. + let journal_stream: Arc>> = + Arc::new(Mutex::new(None)); + let terminal_session: Arc>> = + Arc::new(Mutex::new(None)); + let pending_code: Arc>> = Arc::new(Mutex::new(None)); + + loop { + tokio::select! { + ws_msg = reader.next() => { + let Some(ws_msg) = ws_msg else { break }; + match ws_msg { + Ok(Message::Text(text)) => { + handle_message( + &text, + &mut writer, + &tx, + &outbound_tx, + &journal_stream, + &terminal_session, + &pending_code, + ).await; } - } else if text.contains("\"type\":\"firmware_check\"") { - info!("ws: firmware_check received"); - let _ = tx.send(ServerMsg::FirmwareCheck); - } else if text.contains("\"type\":\"fan\"") { - info!("ws: fan received: {text}"); - let Ok(msg) = serde_json::from_str::(&text) - else { - warn!("ws: fan command was not valid JSON"); - continue; - }; - let pwm: Option = - if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") - { - None - } else if let Some(value) = - msg.get("pwm").and_then(|v| v.as_u64()) - { - Some(value.min(255) as u32) - } else { - warn!("ws: fan command missing mode=auto or pwm"); - continue; - }; - let _ = tx.send(ServerMsg::Fan(pwm)); - } else { - info!("ws: msg: {text}"); + Ok(Message::Close(_)) => { + info!("ws: server closed connection"); + break; + } + Err(e) => { + warn!("ws: error: {e}"); + break; + } + _ => {} } } - Ok(Message::Close(_)) => { - info!("ws: server closed connection"); - break; + Some(out_msg) = outbound_rx.recv() => { + if writer.send(Message::Text(out_msg)).await.is_err() { + break; + } } - Err(e) => { - warn!("ws: error: {e}"); - break; - } - _ => {} } } + + // Cleanup on disconnect. + if let Some(stream) = journal_stream.lock().unwrap().take() { + stream.stop(); + } + if let Some(mut session) = terminal_session.lock().unwrap().take() { + session.kill(); + } } Err(e) => { warn!("ws: connect failed: {e}"); @@ -149,6 +112,181 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { }); } +type WsWriter = futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, +>; + +async fn ws_send(writer: &mut WsWriter, msg: serde_json::Value) { + let _ = writer.send(Message::Text(msg.to_string())).await; +} + +async fn handle_message( + text: &str, + writer: &mut WsWriter, + tx: &Sender, + outbound_tx: &tokio::sync::mpsc::UnboundedSender, + journal_stream: &Arc>>, + terminal_session: &Arc>>, + pending_code: &Arc>>, +) { + if text.contains("\"type\":\"ping\"") { + let _ = writer.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await; + } else if text.contains("\"type\":\"onvif-soap-request\"") { + let Ok(msg) = serde_json::from_str::(text) else { + warn!("ws: onvif request was not valid JSON"); + return; + }; + let Ok(req) = serde_json::from_value::(msg) else { + warn!("ws: onvif request missing fields"); + return; + }; + let response = perform_onvif_soap(req).await; + let _ = writer.send(Message::Text(response)).await; + } else if text.contains("\"type\":\"reload-bundle\"") { + info!("ws: reload-bundle received"); + let _ = tx.send(ServerMsg::ReloadBundle); + } else if text.contains("\"type\":\"standby\"") { + let display_id = serde_json::from_str::(text) + .ok() + .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + let _ = tx.send(ServerMsg::Standby(display_id)); + } else if text.contains("\"type\":\"wake\"") { + let display_id = serde_json::from_str::(text) + .ok() + .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + let _ = tx.send(ServerMsg::Wake(display_id)); + } else if text.contains("\"type\":\"layout-switch\"") { + let msg = serde_json::from_str::(text).ok(); + let layout_id = msg.as_ref() + .and_then(|m| m.get("layout_id")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let display_id = msg.as_ref() + .and_then(|m| m.get("display_id")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + if let Some(layout_id) = layout_id { + let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id }); + } + } else if text.contains("\"type\":\"firmware_check\"") { + let _ = tx.send(ServerMsg::FirmwareCheck); + } else if text.contains("\"type\":\"fan\"") { + let Ok(msg) = serde_json::from_str::(text) else { return }; + let pwm = if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") { + None + } else if let Some(value) = msg.get("pwm").and_then(|v| v.as_u64()) { + Some(value.min(255) as u32) + } else { + return; + }; + let _ = tx.send(ServerMsg::Fan(pwm)); + + // ---- Journal streaming -------------------------------------------------- + } else if text.contains("\"type\":\"journal-start\"") { + info!("ws: journal-start"); + if let Some(old) = journal_stream.lock().unwrap().take() { + old.stop(); + } + let otx = outbound_tx.clone(); + let stream = remote_debug::JournalStream::start(move |line| { + let msg = serde_json::json!({ "type": "journal-line", "line": line }).to_string(); + let _ = otx.send(msg); + }); + *journal_stream.lock().unwrap() = Some(stream); + } else if text.contains("\"type\":\"journal-stop\"") { + info!("ws: journal-stop"); + if let Some(stream) = journal_stream.lock().unwrap().take() { + stream.stop(); + } + + // ---- Terminal ----------------------------------------------------------- + } else if text.contains("\"type\":\"terminal-request\"") { + info!("ws: terminal-request"); + if let Err(reason) = remote_debug::check_terminal_access() { + ws_send(writer, serde_json::json!({ "type": "terminal-denied", "reason": reason })).await; + } else { + match remote_debug::create_terminal_challenge() { + Ok(code) => { + *pending_code.lock().unwrap() = Some(code.clone()); + let _ = tx.send(ServerMsg::ShowTerminalCode(code)); + ws_send(writer, serde_json::json!({ "type": "terminal-challenge" })).await; + } + Err(e) => { + ws_send(writer, serde_json::json!({ "type": "terminal-denied", "reason": e })).await; + } + } + } + } else if text.contains("\"type\":\"terminal-auth\"") { + let msg: serde_json::Value = serde_json::from_str(text).unwrap_or_default(); + let provided = msg.get("code").and_then(|v| v.as_str()).unwrap_or(""); + let expected = pending_code.lock().unwrap().take(); + if let Some(expected) = expected { + if remote_debug::validate_terminal_code(&expected, provided) { + info!("ws: terminal auth OK"); + let _ = tx.send(ServerMsg::DismissTerminalCode); + match remote_debug::TerminalSession::spawn() { + Ok((session, stdout, stderr)) => { + *terminal_session.lock().unwrap() = Some(session); + // Pipe stdout + stderr → outbound WS channel. + let otx1 = outbound_tx.clone(); + std::thread::spawn(move || pipe_output(stdout, otx1)); + let otx2 = outbound_tx.clone(); + std::thread::spawn(move || pipe_output(stderr, otx2)); + ws_send(writer, serde_json::json!({ "type": "terminal-granted" })).await; + } + Err(e) => { + ws_send(writer, serde_json::json!({ + "type": "terminal-denied", "reason": format!("spawn: {e}") + })).await; + } + } + } else { + warn!("ws: terminal auth failed"); + let reason = if remote_debug::is_locked_public() { "locked" } else { "wrong code" }; + ws_send(writer, serde_json::json!({ "type": "terminal-denied", "reason": reason })).await; + } + } else { + ws_send(writer, serde_json::json!({ + "type": "terminal-denied", "reason": "no pending challenge" + })).await; + } + } else if text.contains("\"type\":\"terminal-data\"") { + let msg: serde_json::Value = serde_json::from_str(text).unwrap_or_default(); + if let Some(b64) = msg.get("data").and_then(|v| v.as_str()) { + if let Ok(bytes) = remote_debug::b64_decode(b64) { + if let Some(ref mut session) = *terminal_session.lock().unwrap() { + let _ = session.write_input(&bytes); + } + } + } + } else if text.contains("\"type\":\"terminal-close\"") { + info!("ws: terminal-close"); + if let Some(mut session) = terminal_session.lock().unwrap().take() { + session.kill(); + } + } else { + info!("ws: unknown msg: {text}"); + } +} + +fn pipe_output(mut reader: R, tx: tokio::sync::mpsc::UnboundedSender) { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let b64 = remote_debug::b64_encode(&buf[..n]); + let msg = serde_json::json!({ "type": "terminal-data", "data": b64 }).to_string(); + if tx.send(msg).is_err() { break; } + } + Err(_) => break, + } + } +} + async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { let timeout = Duration::from_millis(req.timeout_ms.unwrap_or(8000).clamp(1000, 30000)); let client = match reqwest::Client::builder().timeout(timeout).build() { @@ -158,8 +296,7 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("kiosk ONVIF client init failed: {err}"), - }) - .to_string(); + }).to_string(); } }; @@ -170,8 +307,7 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("invalid ONVIF URL: {err}"), - }) - .to_string(); + }).to_string(); } }; if parsed.scheme() != "http" && parsed.scheme() != "https" { @@ -179,20 +315,15 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": "ONVIF URL must use http or https", - }) - .to_string(); + }).to_string(); } let result = client .post(parsed) - .header( - "Content-Type", - format!( - "application/soap+xml; charset=utf-8; action=\"{}\"", - req.action - ), - ) - .header("SOAPAction", req.action) + .header("Content-Type", format!( + "application/soap+xml; charset=utf-8; action=\"{}\"", req.action + )) + .header("SOAPAction", &req.action) .body(req.body) .send() .await; @@ -206,28 +337,24 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "request_id": req.request_id, "status": status, "body": body, - }) - .to_string(), + }).to_string(), Err(err) => serde_json::json!({ "type": "onvif-soap-response", "request_id": req.request_id, "status": status, "error": format!("kiosk ONVIF response read failed: {err}"), - }) - .to_string(), + }).to_string(), } } Err(err) => serde_json::json!({ "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("kiosk ONVIF request failed: {err}"), - }) - .to_string(), + }).to_string(), } } fn build_ws_url(http_url: &str, token: &str) -> String { - // Replace http:// → ws://, https:// → wss://. Strip any trailing path. let base = if let Some(rest) = http_url.strip_prefix("https://") { format!("wss://{}", rest.split('/').next().unwrap_or(rest)) } else if let Some(rest) = http_url.strip_prefix("http://") { @@ -236,7 +363,6 @@ fn build_ws_url(http_url: &str, token: &str) -> String { format!("ws://{http_url}") }; - // Direct dev URLs may point at api-http; normal installs go through Angie. let base_port = base.rsplit(':').next().unwrap_or(""); let base = if base_port == "18081" { base.replace(":18081", ":18082") diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 460466b..dcc8d32 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1711,6 +1711,130 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); + // ---- Kiosk debug (journal + terminal) pages ---------------------------- + // These are simple HTML pages that connect to the admin debug WS at + // /ws/admin/debug/:kioskId and render output. The WS connection is + // authenticated via the admin's API key. + app.get("/admin/kiosks/:id/logs", (event) => { + const id = Number(getRouterParam(event, "id")); + const kiosk = deps.repo.getKioskById(id); + if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + const user = event.context.user!; + // Get or create an API key for the WS connection. + // WS auth: pass session cookie name so JS can read it for the WS query param. + // The coordinator WS endpoint also accepts session-based auth. + const wsToken = ""; + return htmlPage(`Logs: ${kiosk.name} + +
+ ← ${kiosk.name} + + + +
+

+      `);
+  });
+
+  app.get("/admin/kiosks/:id/terminal", (event) => {
+    const id = Number(getRouterParam(event, "id"));
+    const kiosk = deps.repo.getKioskById(id);
+    if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
+    // WS auth: pass session cookie name so JS can read it for the WS query param.
+    // The coordinator WS endpoint also accepts session-based auth.
+    const wsToken = "";
+    return htmlPage(`Terminal: ${kiosk.name}
+      
+      
+ ← ${kiosk.name} + + + + Disconnected +
+
+ + `); + }); + // ---- Layout switch ---------------------------------------------------- const emitLayoutChanged = (displayId: number | null, kioskId: number | null, layoutId: number) => { const layout = deps.repo.getLayoutById(layoutId); diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 8929c45..cdf6524 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -85,6 +85,40 @@ const pendingRequests = new Map; }>(); +// Admin debug subscribers: admin WS connections subscribed to a kiosk's +// journal/terminal output. Keyed by kiosk id → set of admin WebSockets. +const debugSubscribers = new Map>(); + +function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void { + let subs = debugSubscribers.get(kioskId); + if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); } + subs.add(adminWs); + adminWs.on("close", () => { + subs!.delete(adminWs); + if (subs!.size === 0) { + debugSubscribers.delete(kioskId); + sendToKiosk(kioskId, { type: "journal-stop" }); + sendToKiosk(kioskId, { type: "terminal-close" }); + } + }); +} + +function relayToDebugSubscribers(kioskId: number, message: string): void { + const subs = debugSubscribers.get(kioskId); + if (!subs) return; + for (const ws of subs) { + if (ws.readyState === WebSocket.OPEN) ws.send(message); + } +} + +function parseCookieValue(header: string, name: string): string | null { + for (const pair of header.split(";")) { + const [k, ...rest] = pair.trim().split("="); + if (k?.trim() === name) return rest.join("=").trim() || null; + } + return null; +} + function sendToKiosk(kioskId: number, message: object): boolean { const k = connectedKiosks.get(kioskId); if (!k || k.ws.readyState !== WebSocket.OPEN) return false; @@ -184,6 +218,57 @@ export class Plugin extends BSBService, typeof Event httpServer.on("upgrade", async (req: IncomingMessage, socket, head) => { const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + + // Admin debug WS: /ws/admin/debug/:kioskId?token= + // Subscribes to a kiosk's journal + terminal output stream. + if (url.pathname.startsWith("/ws/admin/debug/")) { + const kioskIdStr = url.pathname.split("/").pop() ?? ""; + const kioskId = Number(kioskIdStr); + if (!Number.isInteger(kioskId) || kioskId <= 0) { + socket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); + socket.destroy(); + return; + } + // Auth: try API key from query param, then session cookie. + const adminToken = url.searchParams.get("token"); + const cookieHeader = req.headers.cookie ?? ""; + try { + let authed = false; + if (adminToken) { + const key = await auth.verifyApiKey(adminToken, null); + if (key) authed = true; + } + if (!authed && cookieHeader) { + const cookieVal = parseCookieValue(cookieHeader, cookieName); + if (cookieVal) { + const result = auth.resolveSession(cookieVal); + if (result) authed = true; + } + } + if (!authed) throw new Error("unauthorized"); + } catch { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (adminWs) => { + addDebugSubscriber(kioskId, adminWs); + obs.log.info("admin debug WS connected for kiosk {id}", { id: kioskId }); + // Relay admin → kiosk messages (terminal-auth, terminal-data, terminal-close, journal-start/stop). + adminWs.on("message", (data) => { + try { + const msg = JSON.parse(data.toString()) as Record; + const relayTypes = ["journal-start", "journal-stop", "terminal-request", + "terminal-auth", "terminal-data", "terminal-close"]; + if (relayTypes.includes(msg["type"] as string)) { + sendToKiosk(kioskId, msg); + } + } catch { /* ignore */ } + }); + }); + return; + } + if (url.pathname !== "/ws/kiosk") { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); @@ -237,6 +322,13 @@ export class Plugin extends BSBService, typeof Event } return; } + // Relay debug messages (journal + terminal) to admin subscribers. + const debugTypes = ["journal-line", "terminal-challenge", "terminal-granted", + "terminal-denied", "terminal-data"]; + if (debugTypes.includes(msg["type"] as string)) { + relayToDebugSubscribers(kiosk.id, data.toString()); + return; + } if (msg["type"] === "status") { obs.log.info("kiosk status: {data}", { data: data.toString() }); const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 60b1899..858c321 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1771,6 +1771,14 @@ export function KioskEditPage(props: KioskEditProps) { {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} +
+

Remote Debug

+ +
+ {/* GPIO bindings */}

GPIO Bindings