Files
OpenPXE/crates/http-api/src/terminal.rs
T
Miles WardandClaude Opus 4.7 900b65b3ec v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)
v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.

What's gone:

* `crates/iso-store/src/nfs.rs` (in entirety)
* `NfsManager`, `NfsMount`, `NfsAddRequest`, `NfsVersion` types
* `IsoSource::Nfs` variant
* `IsoStore::nfs_root` / `IsoStore::set_nfs_root`
* `/api/nfs`, `/api/nfs/:id`, `/api/nfs/:id/scan` routes
* `nfs` terminal command
* Storage tab's NFS shares card and the v0.4.64 fstab-options
  diagnostics work (the whole error path is moot now)

What's new:

* `crates/iso-store/src/smb_share.rs` — `SmbShareManager` that drives
  `smbclient` as a subprocess. Indexes shares via `smbclient -c "ls
  *.iso"` and streams files via `smbclient -c "get file -"` piped
  straight into HTTP response bodies. No local cache, no double disk
  usage.
* `IsoSource::Smb { share_id, relative_path }` variant.
* `IsoStore::iso_path_for` returns None for SMB sources — the HTTP
  ISO download handler dispatches on the source kind and streams via
  the SmbShareManager when it's SMB.
* `/api/smb-shares` + `/api/smb-shares/:id` + `/api/smb-shares/:id/scan`
  routes.
* `share` terminal command (`list | add //srv/share [auth] | remove |
  scan`). Auth spec is `guest` or `user:password`.
* Storage tab: SMB shares card replaces the NFS one. Two-column form
  for server + share name, three-column form for guest checkbox /
  username / password. Username and password fields auto-disable when
  Guest is checked.
* Credentials live under <work_dir>/smb_creds/<id>.cred at 0600
  permissions so they don't leak through `ps`. Persisted state at
  <work_dir>/smb_shares.json (sans password — re-entered on add /
  re-scan).

Why subprocess and not a Rust crate:

* The Debian runtime image already ships the `samba` package
  (Dockerfile line 84) — `smbclient` is right there.
* Library options (pavao, etc.) wrap libsmbclient so they still pull
  in the same C library at runtime.
* Subprocess gives operators a verifiable mental model — anything
  OpenPXE can do over SMB, they can reproduce by running `smbclient`
  manually at a shell.

Range-request limitation, called out in the smb_share.rs module docs
and the UI explainer: `smbclient -c 'get file -'` is a sequential
whole-file stream. HTTP range requests on SMB-sourced ISOs return
416. PXE workloads (iPXE chain, casper sanboot, wimboot) do
whole-file sequential reads, so this works in practice. A follow-up
release can add libsmbclient-based seek if a real workload needs it.

Stderr-to-hint translation patterns mirror v0.4.64's NFS work:
NT_STATUS_LOGON_FAILURE → "check credentials", BAD_NETWORK_NAME →
"check share name", connection refused / timeout → "verify
reachability + firewall", etc. UI renders the raw smbclient error
plus the hint as two lines.

Tests (149 total, was 142 in v0.4.64):
* smb_share parser tests covering ISO + skipped directory, filenames
  with spaces, non-ISO filtering.
* hint_for() translation tests for the dominant NT_STATUS codes.
* Server normalization (smb://, cifs://, \\, // prefixes all stripped).
* HTTP integration: shares list starts empty, invalid server / missing
  username / path in share name all rejected with actionable hints.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:19:47 -04:00

578 lines
21 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,
// v0.4.65: `nfs` is gone — replaced with userspace SMB share
// consumer. `smb` still controls the outbound Samba server
// for Windows install media; `share` lists/manages remote SMB
// shares OpenPXE pulls ISOs from.
"share" | "smb-share" => smb_share_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 smb_shares = s.smb_shares.list();
let smb_reachable = smb_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})\n\
clients: {n_clients}\n\
queue: {n_entries}\n\
smb server: {smb}\n\
smb shares: {n_total} configured ({n_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_clients = clients.len(),
n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = smb_shares.len(),
n_active = smb_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(),
// v0.4.65: SMB userspace consumer replaced kernel-mount NFS.
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{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]"
)),
}
}
// ── 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
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"));
}
}