//! Operator terminal — typed commands over HTTP. //! //! The Terminal tab posts a single command line per request. We split it //! into argv, dispatch to a whitelisted handler, and return plain-text //! output. The handler also pushes the input line and any output onto //! the LogBus so commands and their results show up inline in the live //! tail (Minecraft-server-style). //! //! ## Why a whitelist //! //! Exposing a real shell would be a remote-code-execution endpoint. We //! keep the surface tiny and read-mostly; mutations are limited to the //! same operations the rest of the UI already exposes. use crate::state::AppState; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::Deserialize; use serde_json::json; use std::fmt::Write as _; #[derive(Debug, Deserialize)] pub struct CommandRequest { /// Raw input as typed by the operator. Empty / all-whitespace is OK /// (returns the help banner). pub command: String, } pub async fn run_command( State(state): State, Json(req): Json, ) -> impl IntoResponse { let line = req.command.trim(); if line.is_empty() { return ( StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })), ); } // Echo the typed command into the live log so the Terminal tab shows // operator activity in-band with server-emitted log lines. state .log_bus .push("info", "openpxe::terminal", format!("> {line}")); let argv = shell_split(line); if argv.is_empty() { return ( StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })), ); } let result = dispatch(&state, &argv).await; let (ok, output) = match result { Ok(s) => (true, s), Err(s) => (false, s), }; // Mirror command output to the log bus (truncated for noisy commands) // so reading the live tail tells the same story as scrolling the // terminal pane. let mirror = if output.len() > 1024 { format!( "{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024 ) } else { output.clone() }; if ok { state.log_bus.push("info", "openpxe::terminal", mirror); } else { state.log_bus.push("warn", "openpxe::terminal", mirror); } (StatusCode::OK, Json(json!({ "output": output, "ok": ok }))) } async fn dispatch(state: &AppState, argv: &[String]) -> Result { let head = argv[0].as_str(); let tail = &argv[1..]; match head { "help" | "?" => Ok(HELP_TEXT.to_string()), "version" => Ok(format!("openpxe {}", env!("CARGO_PKG_VERSION"))), "uptime" => Ok(uptime_string(state)), "status" => Ok(status_text(state)), "isos" | "images" => Ok(isos_text(state)), "clients" => Ok(clients_text(state)), "queue" => queue_command(state, tail).await, "nfs" => nfs_command(state, tail).await, "smb" => smb_command(state, tail).await, "log" => log_command(state, tail), "whoami" => Ok("operator".to_string()), "echo" => Ok(tail.join(" ")), "clear" => Ok("\x0c".to_string()), // form feed — frontend clears panel other => Err(format!( "unknown command: {other}\ntype 'help' for the list" )), } } // ── status / lists ───────────────────────────────────────────────────── fn status_text(s: &AppState) -> String { let isos = s.iso_store.list(); let clients = s.clients.list(); let queue_entries = s.queue.list(); let smb = s.smb.as_ref().map(|m| m.snapshot()); let nfs = s.nfs.list(); let nfs_active = nfs.iter().filter(|m| m.mounted).count(); format!( "OpenPXE {ver}\n\ base url: {base}\n\ interface: {nic}\n\ uptime: {up}\n\ isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\ clients: {n_clients}\n\ queue: {n_entries}\n\ smb: {smb}\n\ nfs mounts: {n_total} configured ({n_active} active)\n", ver = env!("CARGO_PKG_VERSION"), base = s.public_base_url, nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() }, up = uptime_string(s), n_isos = isos.len(), n_local = isos .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)) .count(), n_nfs = isos .iter() .filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)) .count(), n_clients = clients.len(), n_entries = queue_entries.len(), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), n_total = nfs.len(), n_active = nfs_active, ) } fn isos_text(s: &AppState) -> String { let isos = s.iso_store.list(); if isos.is_empty() { return "(no isos)".into(); } let mut out = String::new(); let _ = writeln!( out, "{:<32} {:<10} {:<10} {:<8}", "ID", "FAMILY", "SIZE", "SOURCE" ); for i in isos { let src = match i.source { openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), }; let _ = writeln!( out, "{:<32} {:<10} {:<10} {:<8}", truncate(&i.id, 32), format!("{:?}", i.introspection.family), human_bytes(i.size_bytes), src, ); } out } fn clients_text(s: &AppState) -> String { let clients = s.clients.list(); if clients.is_empty() { return "(no clients yet)".into(); } let mut out = String::new(); let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS"); for c in clients { let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let _ = writeln!( out, "{:<19} {:<16} {:<8} {}", c.mac, ip, c.events.len(), c.last_seen .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default() ); } out } // ── queue ────────────────────────────────────────────────────────────── // `async` for symmetry with the other dispatch helpers — queue operations // are sync today but might grow to await on a database in a future phase. #[allow(clippy::unused_async)] async fn queue_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { let entries = s.queue.list(); if entries.is_empty() { return Ok("(queue empty)".into()); } let mut out = String::new(); for entry in entries { let _ = writeln!( out, "#{:<3} {:<19} {:<16} target={}", entry.position, entry.mac, entry.id, entry.assigned_target.unwrap_or_else(|| "-".into()) ); } Ok(out) } Some("assign-all") => { let target = args .get(1) .ok_or_else(|| "usage: queue assign-all ".to_string())?; let found = s .iso_store .list() .into_iter() .any(|i| i.boot_entries.iter().any(|e| &e.id == target)); if !found { return Err(format!("no such boot entry: {target}")); } let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect(); let n = s.queue.assign(&ids, target); Ok(format!("assigned {n} queue entries -> {target}")) } Some("assign") => { let entry_id = args .get(1) .ok_or_else(|| "usage: queue assign ".to_string())?; let target = args .get(2) .ok_or_else(|| "usage: queue assign ".to_string())?; let n = s.queue.assign(std::slice::from_ref(entry_id), target); if n == 0 { return Err(format!("no such queue entry: {entry_id}")); } Ok(format!("assigned 1 queue entry -> {target}")) } Some("release") => { let entry_id = args .get(1) .ok_or_else(|| "usage: queue release ".to_string())?; match s.queue.release(entry_id) { Some(_) => Ok(format!("released {entry_id}")), None => Err(format!("no such queue entry: {entry_id}")), } } Some(other) => Err(format!( "unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]" )), } } // ── nfs ──────────────────────────────────────────────────────────────── async fn nfs_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { let mounts = s.nfs.list(); if mounts.is_empty() { return Ok("(no NFS mounts configured)".into()); } let mut out = String::new(); let _ = writeln!( out, "{:<24} {:<6} {:<7} {:<6} TARGET", "ID", "VER", "STATUS", "ISOS" ); for m in mounts { let status = if m.mounted { "ok" } else { "down" }; let _ = writeln!( out, "{:<24} {:<6} {:<7} {:<6} {}:{}", truncate(&m.id, 24), match m.version { openpxe_iso_store::NfsVersion::V3 => "v3", openpxe_iso_store::NfsVersion::V41 => "v4.1", }, status, m.iso_count, m.server, m.export, ); if let Some(e) = m.last_error { let _ = writeln!(out, " error: {e}"); } } Ok(out) } Some("mount") => { // nfs mount : [v3|v41] [ro|rw] let target = args .get(1) .ok_or_else(|| "usage: nfs mount : [v3|v41] [ro|rw]".to_string())?; let (server, export) = target .split_once(':') .ok_or_else(|| "target must be 'server:/export'".to_string())?; let version = match args.get(2).map(String::as_str) { Some("v3") => openpxe_iso_store::NfsVersion::V3, Some("v41") | None => openpxe_iso_store::NfsVersion::V41, Some(other) => { return Err(format!("unknown nfs version: {other} (expect v3 or v41)")) } }; let read_only = !matches!(args.get(3).map(String::as_str), Some("rw")); let req = openpxe_iso_store::NfsAddRequest { server: server.to_string(), export: export.to_string(), version, read_only, }; match s.nfs.add(req).await { Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), Err(e) => Err(format!("mount failed: {e}")), } } Some("unmount") => { let id = args .get(1) .ok_or_else(|| "usage: nfs unmount ".to_string())?; match s.nfs.remove(id).await { Ok(()) => Ok(format!("unmounted {id}")), Err(e) => Err(format!("unmount failed: {e}")), } } Some("scan") => { let id = args .get(1) .ok_or_else(|| "usage: nfs scan ".to_string())?; match s.nfs.rescan(id).await { Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Err(e) => Err(format!("scan failed: {e}")), } } Some(other) => Err(format!( "unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]" )), } } // ── smb ──────────────────────────────────────────────────────────────── #[allow(clippy::unused_async)] async fn smb_command(s: &AppState, args: &[String]) -> Result { let smb = s .smb .as_ref() .ok_or_else(|| "SMB manager not configured (Windows support disabled)".to_string())?; match args.first().map(String::as_str) { None | Some("status") => Ok(format!("{:#?}", smb.snapshot())), Some("start") => { // start/reconcile return the new SmbState — there's no // separate Result type. The state itself indicates success // or failure via its variant. let st = smb.start(); Ok(format!("smbd start requested -> {st:?}")) } Some("stop") => { smb.stop(); Ok("smbd stop requested".into()) } Some("reload") => { let st = smb.reconcile(); Ok(format!("smbd reload (SIGHUP) sent -> {st:?}")) } Some(other) => Err(format!( "unknown smb subcommand: {other}\ntry: smb [status|start|stop|reload]" )), } } // ── log ──────────────────────────────────────────────────────────────── fn log_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { Some("clear") => { s.log_bus.clear(); Ok("log buffer cleared".into()) } Some("tail") => { let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20); let lines = s.log_bus.recent(); let start = lines.len().saturating_sub(n); let mut out = String::new(); for l in &lines[start..] { let _ = writeln!(out, "{}", l.render()); } if out.is_empty() { Ok("(empty)".into()) } else { Ok(out) } } _ => Err("usage: log [clear|tail [n]]".into()), } } // ── helpers ──────────────────────────────────────────────────────────── fn uptime_string(s: &AppState) -> String { let now = time::OffsetDateTime::now_utc(); let secs = (now - s.started_at).whole_seconds().max(0); let h = secs / 3600; let m = (secs % 3600) / 60; let s = secs % 60; format!("{h}h {m}m {s}s") } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { format!("{}…", &s[..max.saturating_sub(1)]) } } fn human_bytes(n: u64) -> String { const U: &[&str] = &["B", "KB", "MB", "GB", "TB"]; // Loss-of-precision past 2^52 is academic for ISO file sizes — even // a 4 PiB file still rounds to the right unit. #[allow(clippy::cast_precision_loss)] let mut x = n as f64; let mut i = 0; while x >= 1024.0 && i < U.len() - 1 { x /= 1024.0; i += 1; } if i == 0 || x >= 10.0 { format!("{:.0} {}", x, U[i]) } else { format!("{:.1} {}", x, U[i]) } } /// Tiny shell-like splitter — splits on whitespace, honoring `'…'` and /// `"…"` quoted segments. We deliberately don't expand `$VAR` or any /// other shell metacharacters; this is a parser for our own command /// vocabulary, not a real shell. pub fn shell_split(input: &str) -> Vec { let mut out = Vec::new(); let mut current = String::new(); let mut in_single = false; let mut in_double = false; for ch in input.chars() { match ch { '\'' if !in_double => in_single = !in_single, '"' if !in_single => in_double = !in_double, c if c.is_whitespace() && !in_single && !in_double => { if !current.is_empty() { out.push(std::mem::take(&mut current)); } } c => current.push(c), } } if !current.is_empty() { out.push(current); } out } const HELP_TEXT: &str = "\ OpenPXE terminal — available commands: help show this help version print server version status high-level server status uptime time since startup isos list registered ISOs clients list PXE clients seen this session queue list list queued clients queue assign assign one queued client to a boot entry queue assign-all assign every waiting client queue release release one queued client nfs list list NFS mounts nfs mount : [v3|v41] [ro|rw] add and mount an NFS share nfs unmount unmount and forget a share nfs scan re-scan a share for new ISOs smb status SMB (Samba) state smb start | stop | reload control smbd log clear drop the in-memory log ring buffer log tail [n] show the last n buffered lines (default 20) clear clear the terminal pane Tab to autocomplete is not implemented (sorry).\n"; #[cfg(test)] mod tests { use super::*; #[test] fn shell_split_basic() { assert_eq!(shell_split(""), Vec::::new()); assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]); assert_eq!( shell_split("nfs mount 10.0.0.5:/srv v41 ro"), vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"] ); } #[test] fn shell_split_quoted() { assert_eq!( shell_split("echo 'hello world' done"), vec!["echo", "hello world", "done"] ); assert_eq!( shell_split(r#"echo "double quotes" 'and singles'"#), vec!["echo", "double quotes", "and singles"] ); } #[test] fn human_bytes_units() { assert_eq!(human_bytes(0), "0 B"); assert_eq!(human_bytes(1023), "1023 B"); assert_eq!(human_bytes(1024), "1.0 KB"); assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MB"); assert_eq!(human_bytes(5 * 1024u64.pow(3)), "5.0 GB"); } #[test] fn truncate_keeps_short() { assert_eq!(truncate("hi", 10), "hi"); assert_eq!(truncate("longerthanfive", 5), "long…"); } #[test] fn help_uses_queue_language() { assert!(HELP_TEXT.contains("queue list")); assert!(HELP_TEXT.contains("queued clients")); } }