2026-05-22 18:13:39 +00:00
|
|
|
//! 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 || {
|
2026-05-22 21:34:49 +00:00
|
|
|
// Use systemd-run to escape NoNewPrivileges and read journal as root.
|
|
|
|
|
let mut child = match Command::new("systemd-run")
|
|
|
|
|
.args([
|
|
|
|
|
"--pipe", "--quiet", "--service-type=exec",
|
|
|
|
|
"--property=User=root",
|
|
|
|
|
"journalctl", "-u", "betterframe-kiosk", "-f", "--no-pager", "-o", "short-iso", "-n", "50",
|
|
|
|
|
])
|
2026-05-22 18:13:39 +00:00
|
|
|
.stdout(Stdio::piped())
|
2026-05-22 19:08:24 +00:00
|
|
|
.stderr(Stdio::piped())
|
2026-05-22 18:13:39 +00:00
|
|
|
.spawn()
|
2026-05-22 19:08:24 +00:00
|
|
|
.or_else(|_| Command::new("journalctl")
|
|
|
|
|
.args(["-f", "--no-pager", "-o", "short-iso", "-n", "50"])
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn())
|
2026-05-22 18:13:39 +00:00
|
|
|
{
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("remote-debug: journalctl spawn failed: {e}");
|
2026-05-22 19:08:24 +00:00
|
|
|
on_line(&format!("[ERROR] journalctl spawn failed: {e}"));
|
2026-05-22 18:13:39 +00:00
|
|
|
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());
|
|
|
|
|
}
|
2026-05-22 18:51:18 +00:00
|
|
|
// Check channel — terminal allowed when EITHER firmware or OS channel
|
|
|
|
|
// is "dev". Channels pushed from server via heartbeat response, cached
|
|
|
|
|
// in server.rs. No env var, no build-time check.
|
|
|
|
|
let fw = crate::server::cached_firmware_channel();
|
|
|
|
|
let os = crate::server::cached_os_channel();
|
|
|
|
|
if fw != "dev" && os != "dev" {
|
2026-05-22 18:13:39 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-22 18:23:20 +00:00
|
|
|
// Successful terminal auth resets all lockout state.
|
|
|
|
|
let _ = std::fs::remove_file(lockout_path());
|
2026-05-22 18:13:39 +00:00
|
|
|
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> {
|
2026-05-22 21:34:49 +00:00
|
|
|
// The kiosk runs under NoNewPrivileges=yes (WebKit bwrap needs
|
|
|
|
|
// it), which blocks sudo/nsenter from this process tree. Use
|
|
|
|
|
// systemd-run to spawn a SEPARATE service unit that runs bash
|
|
|
|
|
// as root in its own process tree — not a child of the kiosk.
|
|
|
|
|
// The --pipe flag connects stdin/stdout/stderr to our process.
|
|
|
|
|
let mut child = Command::new("systemd-run")
|
|
|
|
|
.args([
|
|
|
|
|
"--pipe", // connect stdio to us
|
|
|
|
|
"--quiet", // suppress service info on stderr
|
|
|
|
|
"--service-type=exec",
|
|
|
|
|
"--property=User=root",
|
|
|
|
|
"-E", "TERM=xterm-256color",
|
|
|
|
|
"-E", "HOME=/root",
|
|
|
|
|
"bash", "--login",
|
|
|
|
|
])
|
2026-05-22 18:13:39 +00:00
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
2026-05-22 21:30:13 +00:00
|
|
|
.or_else(|_| {
|
2026-05-22 21:34:49 +00:00
|
|
|
// Fallback: plain bash as bfkiosk (limited but something).
|
2026-05-22 21:30:13 +00:00
|
|
|
Command::new("bash")
|
|
|
|
|
.args(["--login"])
|
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.env("TERM", "xterm-256color")
|
|
|
|
|
.spawn()
|
|
|
|
|
})
|
2026-05-22 21:34:49 +00:00
|
|
|
.map_err(|e| format!("shell spawn: {e}"))?;
|
2026-05-22 18:13:39 +00:00
|
|
|
|
|
|
|
|
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}"))
|
|
|
|
|
}
|