Name update
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
//! 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<AppState>,
|
||||
Json(req): Json<CommandRequest>,
|
||||
) -> 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", "pxeforge::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", "pxeforge::terminal", mirror);
|
||||
} else {
|
||||
state.log_bus.push("warn", "pxeforge::terminal", mirror);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
|
||||
}
|
||||
|
||||
async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
let head = argv[0].as_str();
|
||||
let tail = &argv[1..];
|
||||
match head {
|
||||
"help" | "?" => Ok(HELP_TEXT.to_string()),
|
||||
"version" => Ok(format!("pxeforge {}", 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)),
|
||||
"gate" => gate_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 gates = s.gates.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!(
|
||||
"PXEForge {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\
|
||||
gates: {n_gates}\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, pxeforge_iso_store::IsoSource::Local)).count(),
|
||||
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
|
||||
n_clients = clients.len(),
|
||||
n_gates = gates.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 {
|
||||
pxeforge_iso_store::IsoSource::Local => "local".to_string(),
|
||||
pxeforge_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} {}",
|
||||
"MAC", "IP", "EVENTS", "LAST SEEN"
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
// ── gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
// `async` for symmetry with the other dispatch helpers — gate operations
|
||||
// are sync today but might grow to await on a database in a future phase.
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let gs = s.gates.list();
|
||||
if gs.is_empty() {
|
||||
return Ok("(no gates)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for g in gs {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"#{:<3} {:<19} {:<16} target={}",
|
||||
g.position,
|
||||
g.mac,
|
||||
g.id,
|
||||
g.assigned_target.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("assign-all") => {
|
||||
let target = args.get(1).ok_or_else(|| {
|
||||
"usage: gate assign-all <iso_boot_entry_id>".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.gates.list().into_iter().map(|g| g.id).collect();
|
||||
let n = s.gates.assign(&ids, target);
|
||||
Ok(format!("assigned {n} gates -> {target}"))
|
||||
}
|
||||
Some("assign") => {
|
||||
let gate_id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
||||
let target = args
|
||||
.get(2)
|
||||
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
||||
let n = s.gates.assign(std::slice::from_ref(gate_id), target);
|
||||
if n == 0 {
|
||||
return Err(format!("no such gate: {gate_id}"));
|
||||
}
|
||||
Ok(format!("assigned 1 gate -> {target}"))
|
||||
}
|
||||
Some("release") => {
|
||||
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?;
|
||||
match s.gates.release(gate_id) {
|
||||
Some(_) => Ok(format!("released {gate_id}")),
|
||||
None => Err(format!("no such gate: {gate_id}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
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} {}",
|
||||
"ID", "VER", "STATUS", "ISOS", "TARGET"
|
||||
);
|
||||
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 {
|
||||
pxeforge_iso_store::NfsVersion::V3 => "v3",
|
||||
pxeforge_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 <server>:<export> [v3|v41] [ro|rw]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs mount <server>:<export> [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") => pxeforge_iso_store::NfsVersion::V3,
|
||||
Some("v41") | None => pxeforge_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 = pxeforge_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 <id>".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 <id>".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<String, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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 = "\
|
||||
PXEForge 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
|
||||
gate list list gated-deployment queue
|
||||
gate assign <gate_id> <target> assign one gate to a boot entry
|
||||
gate assign-all <target> assign every waiting gate
|
||||
gate release <gate_id> release one gate
|
||||
|
||||
nfs list list NFS mounts
|
||||
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
|
||||
nfs unmount <id> unmount and forget a share
|
||||
nfs scan <id> 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::<String>::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…");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user