Files
OpenPXE/crates/http-api/src/terminal.rs
T
Miles WardandClaude Opus 4.8 44a2212abe v0.5.5: SFTP-over-SSH remote shares (russh, pure-Rust, ring backend)
Adds SFTP as a third remote ISO-library protocol alongside SMB and NFS.
Pure-Rust russh + russh-sftp on the ring crypto backend — no kernel
mount, no subprocess, no OpenSSL, no new C deps. Like NFS (and unlike
SMB), SFTP-sourced ISOs support HTTP Range requests because SFTP opens
a seekable file handle.

- iso-store: SftpShareManager (connect/auth/READDIR/seekable stream),
  IsoSource::Sftp, password OR SSH-key auth, trust-on-first-use host-key
  pinning, 0600 credential sidecar with a restart-safe derived path.
- http-api: /api/sftp-shares routes, Range-aware ISO dispatch arm,
  status/metrics counts, /api/docs entry, `sftp` terminal commands.
- webui: "SFTP (SSH)" protocol option with a password/key auth toggle,
  host-key fingerprint display, dashboard tile, updated copy.

SCP was deliberately rejected: sequential-only (no Range) and its crates
wrap libssh2 (C + OpenSSL), which would break the static-musl build.

russh is pinned to =0.55.0: russh 0.61 needs the stable RustCrypto
generation (pkcs8 0.11), which is API-incompatible with the release-
candidate crates bergshamra-crypto pins (pkcs8 =0.11.0-rc.11). 0.55 is
the newest russh on the prior generation (pkcs8 0.7) that coexists. Do
not bump past 0.55 until bergshamra adopts stable RustCrypto.

252 tests pass, clippy clean, static musl x86_64 binary (ring already
present via rustls + bergshamra, so no new crypto/C deps).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:49:18 -04:00

792 lines
30 KiB
Rust

//! 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", "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<String, String> {
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<String, String> {
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 <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.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 <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args
.get(2)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".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 <entry_id>".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<String, String> {
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 <id>".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 <id>".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<String, String> {
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 <server>:<export> [port]
let target = args
.get(1)
.ok_or_else(|| "usage: nfs add <server>:<export> [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::<u16>().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 <id>".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 <id>".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<String, String> {
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 <user>@<server>:<export> <password> [port]
let target = args.get(1).ok_or_else(|| {
"usage: sftp add <user>@<server>:<export> <password> [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::<u16>().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 <id>".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 <id>".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<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 = "\
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 <entry_id> <target> assign one queued client to a boot entry
queue assign-all <target> assign every waiting client
queue release <entry_id> 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 <id> forget an SMB share
share scan <id> re-list a share for new ISOs
nfs list list configured NFSv3 shares
nfs add <srv>:<export> [port] add an NFSv3 share
nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs
sftp list list configured SFTP-over-SSH shares
sftp add <user>@<srv>:<export> <pass> [port] add an SFTP share (key auth: WebUI)
sftp remove <id> forget an SFTP share
sftp scan <id> 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::<String>::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"));
}
}