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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
674a69f93b
commit
44a2212abe
+123
-12
@@ -96,6 +96,8 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
"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(" ")),
|
||||
@@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String {
|
||||
// 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})\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",
|
||||
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() {
|
||||
@@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String {
|
||||
.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:?}")),
|
||||
@@ -157,6 +167,8 @@ fn status_text(s: &AppState) -> String {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,6 +189,8 @@ fn isos_text(s: &AppState) -> 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,
|
||||
@@ -321,11 +335,9 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
||||
}
|
||||
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()
|
||||
})?;
|
||||
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('\\');
|
||||
@@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
||||
return Ok("(no NFS shares configured)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<7} {:<6} TARGET",
|
||||
"ID", "STATUS", "ISOS"
|
||||
);
|
||||
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
|
||||
for m in shares {
|
||||
let status = if m.reachable { "ok" } else { "down" };
|
||||
let _ = writeln!(
|
||||
@@ -476,6 +484,104 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)]
|
||||
@@ -622,6 +728,11 @@ OpenPXE terminal — available commands:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user