mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
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:
parent
e0b9955522
commit
c5068615ee
10 changed files with 802 additions and 111 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
256
kiosk/src/remote_debug.rs
Normal 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}"))
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,104 +41,64 @@ 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 {
|
|
||||||
Ok(Message::Text(text)) => {
|
// Channel for sync threads (journal, terminal) to send WS messages.
|
||||||
if text.contains("\"type\":\"ping\"") {
|
let (outbound_tx, mut outbound_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
let _ = ws
|
|
||||||
.send(Message::Text(r#"{"type":"pong"}"#.to_string()))
|
// State for journal streaming + terminal session.
|
||||||
.await;
|
let journal_stream: Arc<Mutex<Option<remote_debug::JournalStream>>> =
|
||||||
} else if text.contains("\"type\":\"onvif-soap-request\"") {
|
Arc::new(Mutex::new(None));
|
||||||
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text)
|
let terminal_session: Arc<Mutex<Option<remote_debug::TerminalSession>>> =
|
||||||
else {
|
Arc::new(Mutex::new(None));
|
||||||
warn!("ws: onvif request was not valid JSON");
|
let pending_code: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||||
continue;
|
|
||||||
};
|
loop {
|
||||||
let Ok(req) = serde_json::from_value::<OnvifSoapRequest>(msg)
|
tokio::select! {
|
||||||
else {
|
ws_msg = reader.next() => {
|
||||||
warn!("ws: onvif request missing fields");
|
let Some(ws_msg) = ws_msg else { break };
|
||||||
continue;
|
match ws_msg {
|
||||||
};
|
Ok(Message::Text(text)) => {
|
||||||
let response = perform_onvif_soap(req).await;
|
handle_message(
|
||||||
let _ = ws.send(Message::Text(response)).await;
|
&text,
|
||||||
} else if text.contains("\"type\":\"reload-bundle\"") {
|
&mut writer,
|
||||||
info!("ws: reload-bundle received");
|
&tx,
|
||||||
let _ = tx.send(ServerMsg::ReloadBundle);
|
&outbound_tx,
|
||||||
} else if text.contains("\"type\":\"standby\"") {
|
&journal_stream,
|
||||||
info!("ws: standby received");
|
&terminal_session,
|
||||||
let display_id = serde_json::from_str::<serde_json::Value>(&text)
|
&pending_code,
|
||||||
.ok()
|
).await;
|
||||||
.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\"") {
|
Ok(Message::Close(_)) => {
|
||||||
info!("ws: firmware_check received");
|
info!("ws: server closed connection");
|
||||||
let _ = tx.send(ServerMsg::FirmwareCheck);
|
break;
|
||||||
} else if text.contains("\"type\":\"fan\"") {
|
}
|
||||||
info!("ws: fan received: {text}");
|
Err(e) => {
|
||||||
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text)
|
warn!("ws: error: {e}");
|
||||||
else {
|
break;
|
||||||
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(_)) => {
|
Some(out_msg) = outbound_rx.recv() => {
|
||||||
info!("ws: server closed connection");
|
if writer.send(Message::Text(out_msg)).await.is_err() {
|
||||||
break;
|
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) => {
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue