//! 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, // `smb` controls the outbound Samba server for Windows // install media. `share` lists/manages remote SMB shares // OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel // command for remote NFSv3 shares (v0.4.67, in-process via // nfs3_client — not the v0.4.64 kernel-mount path). "share" | "smb-share" => smb_share_command(state, tail).await, "smb" => smb_command(state, tail).await, "nfs" => nfs_share_command(state, tail).await, // v0.5.5: SFTP-over-SSH remote shares (in-process russh client). "sftp" => sftp_share_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 smb_shares = s.smb_shares.list(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); // v0.4.67: NFSv3 sources too. let nfs_shares = s.nfs_shares.list(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); // v0.5.5: SFTP-over-SSH sources too. let sftp_shares = s.sftp_shares.list(); let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count(); format!( "OpenPXE {ver}\n\ base url: {base}\n\ interface: {nic}\n\ uptime: {up}\n\ isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\ clients: {n_clients}\n\ queue: {n_entries}\n\ smb server: {smb}\n\ smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\ nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n\ sftp shares: {n_sftp_total} configured ({n_sftp_active} reachable)\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_smb = isos .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. })) .count(), n_nfs = isos .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. })) .count(), n_sftp = isos .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. })) .count(), n_clients = clients.len(), n_entries = queue_entries.len(), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), n_smb_total = smb_shares.len(), n_smb_active = smb_reachable, n_nfs_total = nfs_shares.len(), n_nfs_active = nfs_reachable, n_sftp_total = sftp_shares.len(), n_sftp_active = sftp_reachable, ) } 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::Smb { share_id, .. } => format!("smb:{share_id}"), // v0.4.67: NFSv3 via in-process nfs3_client. openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"), // v0.5.5: SFTP-over-SSH via in-process russh. openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_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]" )), } } // ── share (v0.4.65: SMB shares) ───────────────────────────────────────── async fn smb_share_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { let shares = s.smb_shares.list(); if shares.is_empty() { return Ok("(no SMB shares configured)".into()); } let mut out = String::new(); let _ = writeln!( out, "{:<24} {:<7} {:<6} {:<6} TARGET", "ID", "STATUS", "AUTH", "ISOS" ); for m in shares { let status = if m.reachable { "ok" } else { "down" }; let auth = if m.guest { "guest" } else { "user" }; let _ = writeln!( out, "{:<24} {:<7} {:<6} {:<6} //{}/{}", truncate(&m.id, 24), status, auth, m.iso_count, m.server, m.share, ); if let Some(e) = m.last_error { let _ = writeln!(out, " error: {e}"); } if let Some(h) = m.last_hint { let _ = writeln!(out, " hint: {h}"); } } Ok(out) } Some("add") => { // share add //server/share [guest|user:password] let target = args.get(1).ok_or_else(|| { "usage: share add //server/share [guest|user:password]".to_string() })?; // Accept either `//server/share` (UNC-style) or // `server:share` (shorter to type). let stripped = target.trim_start_matches('/').trim_start_matches('\\'); let (server, share) = if let Some((s, p)) = stripped.split_once('/') { (s, p) } else if let Some((s, p)) = stripped.split_once(':') { (s, p) } else { return Err("target must be '//server/share' or 'server:share'".into()); }; // Auth spec: "guest" or "user:password". Default: guest. let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into()); let (guest, username, password) = if auth == "guest" { (true, None, None) } else if let Some((u, p)) = auth.split_once(':') { (false, Some(u.to_string()), Some(p.to_string())) } else { return Err("auth must be 'guest' or 'user:password'".into()); }; let req = openpxe_iso_store::SmbAddRequest { server: server.to_string(), share: share.to_string(), username, password, guest, port: None, }; match s.smb_shares.add(req).await { Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), Err(e) => { let mut out = format!("add failed: {}", e.error); if let Some(h) = e.hint { out.push_str("\nhint: "); out.push_str(&h); } Err(out) } } } Some("remove") => { let id = args .get(1) .ok_or_else(|| "usage: share remove ".to_string())?; match s.smb_shares.remove(id).await { Ok(()) => Ok(format!("removed {id}")), Err(e) => Err(format!("remove failed: {e}")), } } Some("scan") => { let id = args .get(1) .ok_or_else(|| "usage: share scan ".to_string())?; match s.smb_shares.rescan(id).await { Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Err(e) => Err(format!("scan failed: {e}")), } } Some(other) => Err(format!( "unknown share subcommand: {other}\ntry: share [list|add|remove|scan]" )), } } // ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ──────────────────── async fn nfs_share_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { let shares = s.nfs_shares.list(); if shares.is_empty() { return Ok("(no NFS shares configured)".into()); } let mut out = String::new(); let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS"); for m in shares { let status = if m.reachable { "ok" } else { "down" }; let _ = writeln!( out, "{:<24} {:<7} {:<6} {}:{}", truncate(&m.id, 24), status, m.iso_count, m.server, m.export, ); if let Some(e) = m.last_error { let _ = writeln!(out, " error: {e}"); } if let Some(h) = m.last_hint { let _ = writeln!(out, " hint: {h}"); } } Ok(out) } Some("add") => { // nfs add : [port] let target = args .get(1) .ok_or_else(|| "usage: nfs add : [port]".to_string())?; let (server, export) = target .split_once(':') .ok_or_else(|| "target must be 'server:/export'".to_string())?; let port = args.get(2).and_then(|s| s.parse::().ok()); let req = openpxe_iso_store::NfsAddRequest { server: server.to_string(), export: export.to_string(), port, }; match s.nfs_shares.add(req).await { Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), Err(e) => { let mut out = format!("add failed: {}", e.error); if let Some(h) = e.hint { out.push_str("\nhint: "); out.push_str(&h); } Err(out) } } } Some("remove") => { let id = args .get(1) .ok_or_else(|| "usage: nfs remove ".to_string())?; match s.nfs_shares.remove(id).await { Ok(()) => Ok(format!("removed {id}")), Err(e) => Err(format!("remove failed: {e}")), } } Some("scan") => { let id = args .get(1) .ok_or_else(|| "usage: nfs scan ".to_string())?; match s.nfs_shares.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|add|remove|scan]" )), } } // ── sftp (v0.5.5) ──────────────────────────────────────────────────────── // // Parallel to nfs_share_command. The terminal `add` only supports // password auth — pasting a multiline PEM private key through the // terminal is impractical, so key-based shares are added via the WebUI. async fn sftp_share_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { let shares = s.sftp_shares.list(); if shares.is_empty() { return Ok("(no SFTP shares configured)".into()); } let mut out = String::new(); let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS"); for m in shares { let status = if m.reachable { "ok" } else { "down" }; let _ = writeln!( out, "{:<24} {:<7} {:<6} {}@{}:{}", truncate(&m.id, 24), status, m.iso_count, m.username, m.server, m.export, ); if let Some(e) = m.last_error { let _ = writeln!(out, " error: {e}"); } if let Some(h) = m.last_hint { let _ = writeln!(out, " hint: {h}"); } } Ok(out) } Some("add") => { // sftp add @: [port] let target = args.get(1).ok_or_else(|| { "usage: sftp add @: [port] \ (key auth: use the WebUI)" .to_string() })?; let password = args .get(2) .ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?; let (user, rest) = target .split_once('@') .ok_or_else(|| "target must be 'user@server:/export'".to_string())?; let (server, export) = rest .split_once(':') .ok_or_else(|| "target must be 'user@server:/export'".to_string())?; let port = args.get(3).and_then(|s| s.parse::().ok()); let req = openpxe_iso_store::SftpAddRequest { server: server.to_string(), export: export.to_string(), username: Some(user.to_string()), port, password: Some(password.clone()), private_key: None, passphrase: None, }; match s.sftp_shares.add(req).await { Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), Err(e) => { let mut out = format!("add failed: {}", e.error); if let Some(h) = e.hint { out.push_str("\nhint: "); out.push_str(&h); } Err(out) } } } Some("remove") => { let id = args .get(1) .ok_or_else(|| "usage: sftp remove ".to_string())?; match s.sftp_shares.remove(id).await { Ok(()) => Ok(format!("removed {id}")), Err(e) => Err(format!("remove failed: {e}")), } } Some("scan") => { let id = args .get(1) .ok_or_else(|| "usage: sftp scan ".to_string())?; match s.sftp_shares.rescan(id).await { Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Err(e) => Err(format!("scan failed: {e}")), } } Some(other) => Err(format!( "unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|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 share list list configured SMB shares share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass' share remove forget an SMB share share scan re-list a share for new ISOs nfs list list configured NFSv3 shares nfs add : [port] add an NFSv3 share nfs remove forget an NFS share nfs scan re-list an NFS share for new ISOs sftp list list configured SFTP-over-SSH shares sftp add @: [port] add an SFTP share (key auth: WebUI) sftp remove forget an SFTP share sftp scan re-list an SFTP share for new ISOs smb status outbound Samba state (Windows install media) smb start | stop | reload control the outbound 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("share list"), vec!["share", "list"]); assert_eq!( shell_split("share add //nas/isos guest"), vec!["share", "add", "//nas/isos", "guest"] ); } #[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")); } }