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]>
792 lines
30 KiB
Rust
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"));
|
|
}
|
|
}
|