feat(remote-debug): journal streaming + secure terminal via WebSocket

Kiosk side (remote_debug.rs + ws_client.rs refactor):
  - Journal streaming: server sends journal-start → kiosk spawns
    journalctl -f, pipes lines back as journal-line messages via WS.
    journal-stop kills the process. On-demand, not always-on.
  - Terminal: server sends terminal-request → kiosk checks lockout +
    firmware_channel == "dev" → generates 8-char code displayed on
    screen as fullscreen overlay (NOT logged) → server relays admin's
    code via terminal-auth → kiosk validates with constant-time compare
    → on success spawns bash, relays I/O as base64 terminal-data.
  - Lockout: 3 failed codes per boot → lockout_count++. 3 lockouts
    (9 total failures) → permanent (reflash only). Reboot resets
    attempt counter, not lockout counter. Successful pairing resets all.
  - ws_client.rs rewritten with split reader/writer + tokio::select!
    for multiplexing incoming WS messages with outbound journal/terminal
    data from sync threads.

Server side (coordinator-ws + routes-admin):
  - New admin debug WS endpoint: /ws/admin/debug/:kioskId. Authenticated
    via admin API key (query param) or session cookie. Relays messages
    bidirectionally between admin browser ↔ kiosk.
  - Admin pages: /admin/kiosks/:id/logs (journal viewer with start/
    stop/clear) and /admin/kiosks/:id/terminal (code entry + terminal
    area). Both open in new tabs from the kiosk detail page.
  - Angie proxy config updated with /ws/admin/debug/ location block.

Security:
  - Terminal only on dev channel
  - Code displayed physically on screen, never logged or stored server-side
  - Lockout: 3/boot, 3 lockouts = permanent, pairing resets
  - Kiosk responds "locked" without specifying which lockout triggered
This commit is contained in:
Mitchell R 2026-05-22 20:13:39 +02:00
parent e0b9955522
commit c5068615ee
No known key found for this signature in database
10 changed files with 802 additions and 111 deletions

1
.gitignore vendored
View file

@ -44,3 +44,4 @@ npm-debug.log*
*.tsbuildinfo *.tsbuildinfo
# RAUC signing keys (generated locally, secrets set in GitHub Actions) # RAUC signing keys (generated locally, secrets set in GitHub Actions)
rauc-signing/ rauc-signing/
old-python/

View file

@ -63,6 +63,18 @@ server {
proxy_send_timeout 86400s; 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/ { location /nrdp/ {
auth_request /api/admin/_check; auth_request /api/admin/_check;
proxy_pass http://betterframe_nodered; proxy_pass http://betterframe_nodered;

View file

@ -8,6 +8,7 @@ mod local_server;
mod onvif_events; mod onvif_events;
mod os_update; mod os_update;
mod pipeline; mod pipeline;
mod remote_debug;
mod server; mod server;
mod ui; mod ui;
mod ws_client; mod ws_client;
@ -27,6 +28,10 @@ pub enum ServerMsg {
}, },
/// Server-pushed "go check for a firmware update now". /// Server-pushed "go check for a firmware update now".
FirmwareCheck, FirmwareCheck,
/// Show terminal auth code on screen (overlay).
ShowTerminalCode(String),
/// Dismiss the terminal code overlay.
DismissTerminalCode,
} }
use gstreamer::prelude::PluginFeatureExtManual; use gstreamer::prelude::PluginFeatureExtManual;

256
kiosk/src/remote_debug.rs Normal file
View file

@ -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<Mutex<bool>>,
}
impl JournalStream {
/// Spawn journalctl -f and call `on_line` for each line. Blocks until
/// stopped via `stop()` or the process exits.
pub fn start<F: Fn(&str) + Send + 'static>(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<String, String> {
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<std::process::ChildStdin>,
}
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<Vec<u8>, String> {
base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| format!("base64 decode: {e}"))
}

View file

@ -280,6 +280,8 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) crate::at_rest::write_encrypted(&key_file(), key.as_bytes())
.expect("failed to save kiosk key"); .expect("failed to save kiosk key");
// Successful pairing resets all terminal lockout state.
crate::remote_debug::reset_all_lockouts();
return (name, key); return (name, key);
} }
} }

View file

@ -20,6 +20,7 @@ use crate::hwmon;
use crate::local_server; use crate::local_server;
use crate::onvif_events; use crate::onvif_events;
use crate::os_update; use crate::os_update;
use crate::remote_debug;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
@ -264,6 +265,19 @@ fn activate(app: &Application) {
ServerMsg::FirmwareCheck => { ServerMsg::FirmwareCheck => {
maybe_apply_firmware_update(&server_for_reload, &key_for_reload); 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 // Heartbeat loop — reports display geometry + hwmon, also checks for
// firmware + OS bundle updates so kiosks pick up new builds without // firmware + OS bundle updates so kiosks pick up new builds without
// admin push. // admin push.
// Reset terminal auth boot-attempt counter (lockout_count persists).
remote_debug::reset_boot_attempts();
let mut first_iter = true; let mut first_iter = true;
loop { loop {
let heartbeat_ok = send_heartbeat_now(&server, &key); let heartbeat_ok = send_heartbeat_now(&server, &key);
@ -1897,3 +1914,51 @@ fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, 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<Option<gtk::Window>> = 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();
}
});
}

View file

@ -1,4 +1,6 @@
use std::io::Read;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@ -7,6 +9,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::ServerMsg; use crate::ServerMsg;
use crate::remote_debug;
#[derive(Deserialize)] #[derive(Deserialize)]
struct OnvifSoapRequest { struct OnvifSoapRequest {
@ -38,92 +41,37 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
let mut backoff = 1u64; let mut backoff = 1u64;
loop { loop {
match connect_async(&ws_url).await { match connect_async(&ws_url).await {
Ok((mut ws, _resp)) => { Ok((ws_stream, _resp)) => {
info!("ws: connected"); info!("ws: connected");
backoff = 1; backoff = 1;
while let Some(msg) = ws.next().await { let (mut writer, mut reader) = ws_stream.split();
match msg {
// Channel for sync threads (journal, terminal) to send WS messages.
let (outbound_tx, mut outbound_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// State for journal streaming + terminal session.
let journal_stream: Arc<Mutex<Option<remote_debug::JournalStream>>> =
Arc::new(Mutex::new(None));
let terminal_session: Arc<Mutex<Option<remote_debug::TerminalSession>>> =
Arc::new(Mutex::new(None));
let pending_code: Arc<Mutex<Option<String>>> = 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)) => { Ok(Message::Text(text)) => {
if text.contains("\"type\":\"ping\"") { handle_message(
let _ = ws &text,
.send(Message::Text(r#"{"type":"pong"}"#.to_string())) &mut writer,
.await; &tx,
} else if text.contains("\"type\":\"onvif-soap-request\"") { &outbound_tx,
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) &journal_stream,
else { &terminal_session,
warn!("ws: onvif request was not valid JSON"); &pending_code,
continue; ).await;
};
let Ok(req) = serde_json::from_value::<OnvifSoapRequest>(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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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");
}
} 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::<serde_json::Value>(&text)
else {
warn!("ws: fan command was not valid JSON");
continue;
};
let pwm: Option<u32> =
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(_)) => { Ok(Message::Close(_)) => {
info!("ws: server closed connection"); info!("ws: server closed connection");
@ -136,6 +84,21 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
_ => {} _ => {}
} }
} }
Some(out_msg) = outbound_rx.recv() => {
if writer.send(Message::Text(out_msg)).await.is_err() {
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) => { Err(e) => {
warn!("ws: connect failed: {e}"); warn!("ws: connect failed: {e}");
@ -149,6 +112,181 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
}); });
} }
type WsWriter = futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
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<ServerMsg>,
outbound_tx: &tokio::sync::mpsc::UnboundedSender<String>,
journal_stream: &Arc<Mutex<Option<remote_debug::JournalStream>>>,
terminal_session: &Arc<Mutex<Option<remote_debug::TerminalSession>>>,
pending_code: &Arc<Mutex<Option<String>>>,
) {
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::<serde_json::Value>(text) else {
warn!("ws: onvif request was not valid JSON");
return;
};
let Ok(req) = serde_json::from_value::<OnvifSoapRequest>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<R: Read>(mut reader: R, tx: tokio::sync::mpsc::UnboundedSender<String>) {
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 { async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
let timeout = Duration::from_millis(req.timeout_ms.unwrap_or(8000).clamp(1000, 30000)); let timeout = Duration::from_millis(req.timeout_ms.unwrap_or(8000).clamp(1000, 30000));
let client = match reqwest::Client::builder().timeout(timeout).build() { 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", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("kiosk ONVIF client init failed: {err}"), "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", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("invalid ONVIF URL: {err}"), "error": format!("invalid ONVIF URL: {err}"),
}) }).to_string();
.to_string();
} }
}; };
if parsed.scheme() != "http" && parsed.scheme() != "https" { if parsed.scheme() != "http" && parsed.scheme() != "https" {
@ -179,20 +315,15 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": "ONVIF URL must use http or https", "error": "ONVIF URL must use http or https",
}) }).to_string();
.to_string();
} }
let result = client let result = client
.post(parsed) .post(parsed)
.header( .header("Content-Type", format!(
"Content-Type", "application/soap+xml; charset=utf-8; action=\"{}\"", req.action
format!( ))
"application/soap+xml; charset=utf-8; action=\"{}\"", .header("SOAPAction", &req.action)
req.action
),
)
.header("SOAPAction", req.action)
.body(req.body) .body(req.body)
.send() .send()
.await; .await;
@ -206,28 +337,24 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
"request_id": req.request_id, "request_id": req.request_id,
"status": status, "status": status,
"body": body, "body": body,
}) }).to_string(),
.to_string(),
Err(err) => serde_json::json!({ Err(err) => serde_json::json!({
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"status": status, "status": status,
"error": format!("kiosk ONVIF response read failed: {err}"), "error": format!("kiosk ONVIF response read failed: {err}"),
}) }).to_string(),
.to_string(),
} }
} }
Err(err) => serde_json::json!({ Err(err) => serde_json::json!({
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("kiosk ONVIF request failed: {err}"), "error": format!("kiosk ONVIF request failed: {err}"),
}) }).to_string(),
.to_string(),
} }
} }
fn build_ws_url(http_url: &str, token: &str) -> 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://") { let base = if let Some(rest) = http_url.strip_prefix("https://") {
format!("wss://{}", rest.split('/').next().unwrap_or(rest)) format!("wss://{}", rest.split('/').next().unwrap_or(rest))
} else if let Some(rest) = http_url.strip_prefix("http://") { } 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}") 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_port = base.rsplit(':').next().unwrap_or("");
let base = if base_port == "18081" { let base = if base_port == "18081" {
base.replace(":18081", ":18082") base.replace(":18081", ":18082")

View file

@ -1711,6 +1711,130 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); 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(`<html><head><title>Logs: ${kiosk.name}</title>
<style>body{margin:0;background:#111;color:#0f0;font-family:monospace;font-size:13px;padding:1rem}
pre{white-space:pre-wrap;word-break:break-all}
.controls{margin-bottom:1rem}
button{background:#333;color:#fff;border:1px solid #555;padding:4px 12px;cursor:pointer;margin-right:8px}
</style></head><body>
<div class="controls">
<a href="/admin/kiosks/${id}" style="color:#0f0"> ${kiosk.name}</a>
<button id="btn-start">Start streaming</button>
<button id="btn-stop">Stop</button>
<button id="btn-clear">Clear</button>
</div>
<pre id="log"></pre>
<script>
(function(){
var log=document.getElementById('log');
var ws;
function connect(){
// WS to coordinator — proxied through Angie at /ws/admin/debug/:id
var proto=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(proto+'//'+location.host+'/ws/admin/debug/${id}?token=${wsToken}');
ws.onmessage=function(e){
try{var m=JSON.parse(e.data);
if(m.type==='journal-line'){log.textContent+=m.line+'\\n';log.scrollTop=log.scrollHeight;}
}catch{}
};
ws.onclose=function(){setTimeout(connect,3000)};
}
document.getElementById('btn-start').onclick=function(){
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'journal-start'}));
};
document.getElementById('btn-stop').onclick=function(){
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'journal-stop'}));
};
document.getElementById('btn-clear').onclick=function(){log.textContent='';};
connect();
})();
</script></body></html>`);
});
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(`<html><head><title>Terminal: ${kiosk.name}</title>
<style>body{margin:0;background:#000;color:#fff;font-family:monospace;font-size:14px;padding:1rem}
#term{white-space:pre-wrap;word-break:break-all;height:calc(100vh - 120px);overflow-y:auto;background:#111;padding:8px;border:1px solid #333}
.controls{margin-bottom:1rem;display:flex;gap:8px;align-items:center}
button{background:#333;color:#fff;border:1px solid #555;padding:4px 12px;cursor:pointer}
input{background:#222;color:#fff;border:1px solid #555;padding:4px 8px;font-family:monospace;width:200px}
.status{color:#888;font-size:12px;margin-left:12px}
</style></head><body>
<div class="controls">
<a href="/admin/kiosks/${id}" style="color:#0af"> ${kiosk.name}</a>
<button id="btn-request">Request Terminal</button>
<input id="code-input" placeholder="Enter code from kiosk screen" style="display:none" />
<button id="btn-auth" style="display:none">Authenticate</button>
<span class="status" id="status">Disconnected</span>
</div>
<div id="term"></div>
<input id="cmd-input" placeholder="Type here..." style="width:100%;background:#222;color:#fff;border:1px solid #333;padding:6px;font-family:monospace;margin-top:4px;display:none" />
<script>
(function(){
var term=document.getElementById('term'),status=document.getElementById('status');
var codeInput=document.getElementById('code-input'),authBtn=document.getElementById('btn-auth');
var cmdInput=document.getElementById('cmd-input');
var ws;
function connect(){
var proto=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(proto+'//'+location.host+'/ws/admin/debug/${id}?token=${wsToken}');
ws.onopen=function(){status.textContent='Connected (not authed)';};
ws.onmessage=function(e){
try{var m=JSON.parse(e.data);
if(m.type==='terminal-challenge'){
status.textContent='Code displayed on kiosk screen';
codeInput.style.display='';authBtn.style.display='';
}else if(m.type==='terminal-granted'){
status.textContent='Terminal active';
codeInput.style.display='none';authBtn.style.display='none';
cmdInput.style.display='';cmdInput.focus();
}else if(m.type==='terminal-denied'){
status.textContent='Denied: '+(m.reason||'unknown');
}else if(m.type==='terminal-data'){
var bytes=atob(m.data);term.textContent+=bytes;term.scrollTop=term.scrollHeight;
}
}catch{}
};
ws.onclose=function(){status.textContent='Disconnected';setTimeout(connect,3000)};
}
document.getElementById('btn-request').onclick=function(){
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-request'}));
status.textContent='Requesting...';
};
authBtn.onclick=function(){
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-auth',code:codeInput.value.toUpperCase()}));
};
cmdInput.onkeydown=function(e){
if(e.key==='Enter'){
var text=cmdInput.value+'\\n';
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-data',data:btoa(text)}));
cmdInput.value='';
}
};
connect();
})();
</script></body></html>`);
});
// ---- Layout switch ---------------------------------------------------- // ---- Layout switch ----------------------------------------------------
const emitLayoutChanged = (displayId: number | null, kioskId: number | null, layoutId: number) => { const emitLayoutChanged = (displayId: number | null, kioskId: number | null, layoutId: number) => {
const layout = deps.repo.getLayoutById(layoutId); const layout = deps.repo.getLayoutById(layoutId);

View file

@ -85,6 +85,40 @@ const pendingRequests = new Map<string, {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
}>(); }>();
// 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<number, Set<WebSocket>>();
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 { function sendToKiosk(kioskId: number, message: object): boolean {
const k = connectedKiosks.get(kioskId); const k = connectedKiosks.get(kioskId);
if (!k || k.ws.readyState !== WebSocket.OPEN) return false; if (!k || k.ws.readyState !== WebSocket.OPEN) return false;
@ -184,6 +218,57 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
httpServer.on("upgrade", async (req: IncomingMessage, socket, head) => { httpServer.on("upgrade", async (req: IncomingMessage, socket, head) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`); const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
// Admin debug WS: /ws/admin/debug/:kioskId?token=<admin_api_key>
// 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<string, unknown>;
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") { if (url.pathname !== "/ws/kiosk") {
socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
socket.destroy(); socket.destroy();
@ -237,6 +322,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
return; 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") { if (msg["type"] === "status") {
obs.log.info("kiosk status: {data}", { data: data.toString() }); obs.log.info("kiosk status: {data}", { data: data.toString() });
const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null;

View file

@ -1771,6 +1771,14 @@ export function KioskEditPage(props: KioskEditProps) {
{(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Remote Debug</h2>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
<a href={`/admin/kiosks/${k.id}/logs`} class="btn btn-sm" target="_blank">Journal Logs</a>
<a href={`/admin/kiosks/${k.id}/terminal`} class="btn btn-sm" target="_blank">Terminal (dev only)</a>
</div>
</div>
{/* 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>