Files
OpenPXE/crates/iso-store/src/sftp_share.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

1146 lines
41 KiB
Rust

//! SFTP share consumer — in-process userspace SSH/SFTP client.
//!
//! v0.5.5 adds a third remote-library protocol alongside SMB (v0.4.65)
//! and NFS (v0.4.67). Like NFS it's a pure-Rust, in-process client —
//! no subprocess, no kernel mount, no `CAP_SYS_ADMIN` — so it works in
//! every container the other two work in (Unraid, restricted-SCC
//! OpenShift).
//!
//! ## Why SFTP and not SCP
//!
//! The obvious "SSH-based shares" ask is SCP, but SCP is the wrong
//! protocol for this job twice over: (1) it's a sequential whole-file
//! stream with no random access, so it could only ever behave like the
//! SMB path (whole-file, no HTTP Range); and (2) the mature Rust SCP
//! crates wrap libssh2 — a C library that needs OpenSSL — which would
//! break the static-musl, OpenSSL-free build. SFTP sidesteps both:
//! `SSH_FXP_READ` takes an explicit offset (so Range works, like NFS),
//! and `russh` + `russh-sftp` are pure Rust on the `ring` crypto
//! backend already in the binary (rustls + bergshamra), adding zero new
//! C dependencies.
//!
//! ## How it works
//!
//! 1. Operator submits `{ server, export, username, port, +secret }`
//! via the Storage tab. The secret is either a password or an SSH
//! private key (PEM, optionally passphrase-protected) — never both.
//! 2. We write the secret to a 0600 JSON file under
//! `<work_dir>/sftp_creds/<id>.cred`. The share metadata
//! (server/user/export/host-key fingerprint) lives in
//! `<work_dir>/sftp_shares.json`; the secret never touches that file.
//! 3. We connect over SSH, authenticate, open the `sftp` subsystem, and
//! `READDIR` the export directory for `*.iso` files (size comes back
//! in the same listing — no extra round trip).
//! 4. Each ISO registers with the `IsoStore` as
//! `IsoSource::Sftp { share_id, relative_path }`.
//! 5. On a PXE client request the HTTP handler asks for a byte stream
//! at an offset; we open the file, `seek`, and pipe bounded chunks
//! into the response body. HTTP Range → `206 Partial Content`, same
//! as NFS.
//!
//! ## Host-key trust (TOFU)
//!
//! On the first successful connect we pin the server's SSH host-key
//! fingerprint (SHA256) into the share. Every later connect compares
//! the presented key against the pinned one and refuses the connection
//! on a mismatch — classic trust-on-first-use, like a fresh
//! `known_hosts`. A rebuilt server (new host key) surfaces a clear
//! "host key changed" error; the operator removes and re-adds to trust
//! the new key. This is strictly more authentication than NFS/SMB do
//! (neither authenticates the server at all), and it's cheap over SSH.
//!
//! ## Same public surface
//!
//! `SftpShareManager` mirrors `NfsShareManager` / `SmbShareManager`;
//! `SftpShare` mirrors `NfsShare` / `SmbShare`; the `{error, stderr,
//! hint}` error shape is shared so the storage tab renders all three
//! protocols through one code path.
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use russh::client;
use russh::keys::{decode_secret_key, HashAlg, PrivateKeyWithHashAlg, PublicKey};
use russh_sftp::client::SftpSession;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
/// Default TCP port for SSH (and thus the SFTP subsystem).
const DEFAULT_SFTP_PORT: u16 = 22;
/// How long we wait for the TCP connect + SSH handshake before giving
/// up. Matches the shape of the NFS/SMB pre-flight timeouts so the UI
/// banner reads consistently across protocols. The SSH key exchange is
/// a couple of round trips plus some asymmetric crypto, so we give it a
/// little more headroom than the bare TCP probe.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Bounded TCP pre-flight so a wrong IP / closed port surfaces a clean
/// error before we spin up the (heavier) SSH handshake.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Read chunk size for streaming. 64 KiB keeps in-flight memory bounded
/// and matches the NFS path; russh-sftp pipelines reads internally so
/// the chunk size is about response-body granularity, not throughput.
const READ_CHUNK_BYTES: usize = 64 * 1024;
/// Bound on the in-flight queue between the read-loop task and the HTTP
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
const STREAM_BUFFER_DEPTH: usize = 16;
/// Which credential the share authenticates with. The secret itself
/// lives in the 0600 creds file, never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SftpAuthKind {
Password,
Key,
}
/// One configured SFTP share. The id is derived from server+export so
/// re-adding the same coordinates is idempotent. No secret is stored on
/// this struct — it's safe to serialize into the API JSON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SftpShare {
pub id: String,
pub server: String,
/// Directory on the server holding the ISOs (e.g. "/srv/isos").
pub export: String,
/// SSH username.
pub username: String,
/// Which credential kind this share uses. The UI echoes it; the
/// secret lives in the creds file.
pub auth: SftpAuthKind,
/// TCP port — 22 unless overridden.
#[serde(default = "default_port")]
pub port: u16,
/// Pinned SSH host-key fingerprint (SHA256, e.g. "SHA256:abc…").
/// `None` until the first successful connect; once set, a changed
/// key is refused (TOFU). Public information — safe to surface.
#[serde(default)]
pub host_key_fingerprint: Option<String>,
pub last_error: Option<String>,
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_scan: Option<OffsetDateTime>,
pub iso_count: u32,
pub reachable: bool,
}
/// Submission from the UI / API. Exactly one of `password` /
/// `private_key` should be set.
#[derive(Debug, Clone, Deserialize)]
pub struct SftpAddRequest {
pub server: String,
pub export: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub password: Option<String>,
/// PEM-encoded OpenSSH private key, pasted by the operator.
#[serde(default)]
pub private_key: Option<String>,
/// Passphrase for an encrypted private key. Optional.
#[serde(default)]
pub passphrase: Option<String>,
}
fn default_port() -> u16 {
DEFAULT_SFTP_PORT
}
/// The secret material for a share, persisted to a 0600 sidecar JSON
/// file. Never serialized into the public share JSON or API responses.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SftpCreds {
#[serde(default)]
password: Option<String>,
#[serde(default)]
private_key: Option<String>,
#[serde(default)]
passphrase: Option<String>,
}
/// Structured error surfaced to the API and rendered in the UI. Same
/// shape as `NfsShareError` / `SmbShareError`.
#[derive(Debug, Clone, Serialize)]
pub struct SftpShareError {
pub error: String,
pub stderr: String,
pub hint: Option<String>,
}
impl SftpShareError {
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
let stderr = stderr.into();
let error = error.into();
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
Self {
error,
stderr,
hint,
}
}
}
#[derive(Debug, Default)]
struct Inner {
shares: HashMap<String, SftpShare>,
}
/// Manages SFTP shares. Cheap to clone — internal state is
/// `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SftpShareManager {
creds_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Serializes scan operations on the same manager for predictable
/// log output; each scan opens its own SSH connection.
op_lock: Arc<tokio::sync::Mutex<()>>,
}
impl SftpShareManager {
/// Construct a manager. Secrets live under `<work_dir>/sftp_creds/`
/// (0600); state persists to `<work_dir>/sftp_shares.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
Self {
creds_root: Arc::new(work_dir.join("sftp_creds")),
state_path: Arc::new(work_dir.join("sftp_shares.json")),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Load persisted state and re-scan every share. Per-share failures
/// are logged but never propagated — startup must not block on a
/// single offline server.
pub async fn load_and_rescan(&self) -> Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<SftpShare>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut s in shares {
s.last_error = None;
s.last_hint = None;
s.reachable = false;
self.inner.lock().shares.insert(s.id.clone(), s.clone());
if let Err(e) = self.rescan_inner(&s.id).await {
tracing::warn!(
target: "openpxe::sftp",
id = %s.id, server = %s.server, export = %s.export,
"rescan on startup failed: {e}"
);
}
}
Ok(())
}
/// Register an SFTP share. Validates, writes the creds file, probes
/// connectivity by performing a real SSH connect + SFTP READDIR,
/// pins the host key (TOFU), and registers the resulting ISOs.
pub async fn add(&self, req: SftpAddRequest) -> std::result::Result<SftpShare, SftpShareError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(SftpShareError::from_raw("server is required", ""));
}
if !export.starts_with('/') {
return Err(SftpShareError::from_raw(
"export path must be absolute (start with '/', e.g. /srv/isos)",
"",
));
}
if server.contains('\0') || export.contains('\0') {
return Err(SftpShareError::from_raw("NUL bytes are not allowed", ""));
}
let username = req.username.unwrap_or_default().trim().to_string();
if username.is_empty() {
return Err(SftpShareError::from_raw("username is required", ""));
}
// Exactly one secret. Empty strings count as "not provided".
let password = req.password.filter(|s| !s.is_empty());
let private_key = req.private_key.filter(|s| !s.trim().is_empty());
let (auth, creds) = match (password, private_key) {
(Some(_), Some(_)) => {
return Err(SftpShareError::from_raw(
"provide either a password or a private key, not both",
"",
));
}
(Some(pw), None) => (
SftpAuthKind::Password,
SftpCreds {
password: Some(pw),
..Default::default()
},
),
(None, Some(key)) => (
SftpAuthKind::Key,
SftpCreds {
private_key: Some(key),
passphrase: req.passphrase.filter(|s| !s.is_empty()),
..Default::default()
},
),
(None, None) => {
return Err(SftpShareError::from_raw(
"a password or an SSH private key is required",
"",
));
}
};
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SFTP_PORT);
let id = share_id(&server, &export);
// Pre-flight TCP probe so a wrong IP / firewall surfaces a clean
// error instead of a slow SSH-handshake timeout.
if let Err((err, hint)) = tcp_probe(&server, port).await {
return Err(SftpShareError {
error: err,
stderr: String::new(),
hint: Some(hint),
});
}
// Persist the secret (0600) before we register the spec.
if let Err(e) = self.write_creds(&id, &creds).await {
return Err(SftpShareError::from_raw(
format!("could not write credentials file: {e}"),
"",
));
}
let spec = SftpShare {
id: id.clone(),
server,
export,
username,
auth,
port,
host_key_fingerprint: None,
last_error: None,
last_hint: None,
last_scan: None,
iso_count: 0,
reachable: false,
};
self.inner.lock().shares.insert(id.clone(), spec);
self.persist_locked();
if let Err(e) = self.rescan_inner(&id).await {
// Clean up the creds file and the half-registered share so a
// failed add doesn't leave a dead row behind.
let _ = tokio::fs::remove_file(self.creds_path_for(&id)).await;
let m = self.get(&id);
self.inner.lock().shares.remove(&id);
self.persist_locked();
return Err(SftpShareError {
error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
});
}
Ok(self.get(&id).expect("just inserted"))
}
/// Remove a share. Drops every ISO sourced from it and scrubs the
/// creds file. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
let removed = self.inner.lock().shares.remove(id).is_some();
if removed {
self.iso_store.drop_external_source(id);
let _ = tokio::fs::remove_file(self.creds_path_for(id)).await;
self.persist_locked();
}
Ok(())
}
/// Re-walk a share for new / removed ISOs.
pub async fn rescan(&self, id: &str) -> Result<u32> {
self.rescan_inner(id).await
}
#[must_use]
pub fn list(&self) -> Vec<SftpShare> {
let g = self.inner.lock();
let mut v: Vec<_> = g.shares.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<SftpShare> {
self.inner.lock().shares.get(id).cloned()
}
/// Open a byte stream reading `filename` out of share `share_id`,
/// starting at `start_offset` and reading at most `max_len` bytes.
/// Supports HTTP Range requests because SFTP opens a file handle we
/// can `seek` into — the same protocol-level advantage NFS has over
/// the SMB userspace path.
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
start_offset: u64,
max_len: Option<u64>,
) -> Result<SftpStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
// Flat path model (matching NFS/SMB): ISOs live at the export
// root, no nested directories. Reject anything that looks like a
// path so a crafted name can't escape the export.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let creds = self
.read_creds(share_id)
.await
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
let params = ConnParams::from_share(&share);
let fname = filename.to_string();
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
// Spawn a task that owns the SSH connection for the lifetime of
// the stream — like the NFS path, each stream gets its own
// connection so there's no sharing across scan/stream.
let task = tokio::spawn(async move {
let result =
stream_loop(&params, &creds, &fname, start_offset, max_len, tx.clone()).await;
if let Err(e) = result {
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
}
});
Ok(SftpStream { rx, _task: task })
}
// ── internals ─────────────────────────────────────────────────────
fn creds_path_for(&self, id: &str) -> PathBuf {
self.creds_root.join(format!("{id}.cred"))
}
async fn rescan_inner(&self, id: &str) -> Result<u32> {
let _g = self.op_lock.lock().await;
let share = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
let now = OffsetDateTime::now_utc();
let creds = match self.read_creds(id).await {
Ok(c) => c,
Err(e) => {
let msg = format!("could not read credentials: {e}");
self.update_status(id, 0, false, Some(msg.clone()), None, now);
return Err(Error::Invalid(msg));
}
};
// Drop prior entries so a deleted file disappears from the store
// on the next scan.
self.iso_store.drop_external_source(id);
let params = ConnParams::from_share(&share);
let (listing, fingerprint) = match list_isos(&params, &creds).await {
Ok(v) => v,
Err(err) => {
let stderr = err.to_string();
let hint = hint_for(&stderr);
self.update_status(id, 0, false, Some(stderr.clone()), hint, now);
return Err(Error::Invalid(stderr));
}
};
let mut count = 0u32;
for entry in listing {
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
// Same as NFS/SMB: no over-the-network introspection yet, so
// register `Unknown` and let the boot-entry generator fall
// back to filename-based detection. SFTP *could* do bounded
// PVD reads (it has random access) — a follow-up can add it.
let report = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Sftp {
share_id: share.id.clone(),
relative_path: entry.filename.clone(),
};
self.iso_store.register_external(
iso_id,
entry.filename,
entry.size,
report,
boot_entries,
source,
);
count += 1;
}
// Pin the host key (TOFU) on first sight; later scans connect
// with it as the expected key and would have failed above on a
// mismatch, so this only ever sets or re-confirms.
self.pin_fingerprint(id, fingerprint);
self.update_status(id, count, true, None, None, now);
tracing::info!(
target: "openpxe::sftp",
id = %id, server = %share.server, export = %share.export,
iso_count = count,
"SFTP share scanned"
);
Ok(count)
}
fn pin_fingerprint(&self, id: &str, fingerprint: String) {
if fingerprint.is_empty() {
return;
}
let mut g = self.inner.lock();
if let Some(s) = g.shares.get_mut(id) {
if s.host_key_fingerprint.is_none() {
s.host_key_fingerprint = Some(fingerprint);
}
}
}
fn update_status(
&self,
id: &str,
iso_count: u32,
reachable: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(s) = self.inner.lock().shares.get_mut(id) {
s.iso_count = iso_count;
s.reachable = reachable;
s.last_error = err;
s.last_hint = hint;
s.last_scan = Some(ts);
}
self.persist_locked();
}
async fn write_creds(&self, id: &str, creds: &SftpCreds) -> std::io::Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
let body = serde_json::to_vec(creds).map_err(std::io::Error::other)?;
let path = self.creds_path_for(id);
// Synchronous write to set 0600 atomically with create — std has
// no async OpenOptions+mode on stable.
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
f.write_all(&body)?;
Ok(())
})
.await
.map_err(std::io::Error::other)??;
Ok(())
}
async fn read_creds(&self, id: &str) -> std::io::Result<SftpCreds> {
let body = tokio::fs::read(self.creds_path_for(id)).await?;
serde_json::from_slice(&body).map_err(std::io::Error::other)
}
fn persist_locked(&self) {
let shares: Vec<SftpShare> = self.inner.lock().shares.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&shares) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::sftp", "serialize: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::sftp", "write tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::sftp", "rename: {e}");
}
}
}
/// HTTP body stream for an SFTP-sourced ISO read. Implements
/// `Stream<Item = io::Result<Bytes>>` so axum can build a response body
/// via `Body::from_stream`.
#[derive(Debug)]
pub struct SftpStream {
rx: tokio::sync::mpsc::Receiver<std::io::Result<Bytes>>,
/// Kept alive so the read-loop task (which owns the SSH connection)
/// isn't cancelled while the client is still consuming. Dropping the
/// stream cancels the task — the right behaviour on client disconnect.
_task: tokio::task::JoinHandle<()>,
}
impl futures::Stream for SftpStream {
type Item = std::io::Result<Bytes>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Connection coordinates extracted from a share (no secret).
#[derive(Debug, Clone)]
struct ConnParams {
server: String,
port: u16,
username: String,
export: String,
/// Pinned host key to enforce, or `None` for trust-on-first-use.
expected_fingerprint: Option<String>,
}
impl ConnParams {
fn from_share(s: &SftpShare) -> Self {
Self {
server: s.server.clone(),
port: s.port,
username: s.username.clone(),
export: s.export.clone(),
expected_fingerprint: s.host_key_fingerprint.clone(),
}
}
}
#[derive(Debug, Clone)]
struct SftpListEntry {
filename: String,
size: u64,
}
/// A live SSH session plus its SFTP subsystem. The session `Handle`
/// must outlive the `SftpSession` — dropping the last `Handle` closes
/// the SSH connection out from under the subsystem channel.
struct SftpConn {
_session: client::Handle<TofuHandler>,
sftp: SftpSession,
}
/// russh client handler implementing trust-on-first-use host-key
/// verification. We never construct an `Err` from `check_server_key`;
/// returning `Ok(false)` makes russh abort the handshake, and the
/// caller distinguishes "host key mismatch" from other connect failures
/// by comparing the captured `observed` fingerprint to the expected one.
struct TofuHandler {
expected: Option<String>,
observed: Arc<Mutex<Option<String>>>,
}
impl client::Handler for TofuHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKey,
) -> std::result::Result<bool, Self::Error> {
let fp = server_public_key
.fingerprint(HashAlg::default())
.to_string();
*self.observed.lock() = Some(fp.clone());
match &self.expected {
Some(exp) if exp != &fp => Ok(false), // mismatch → abort handshake
_ => Ok(true),
}
}
}
/// Connect, authenticate, and open the SFTP subsystem. Returns the live
/// connection plus the observed host-key fingerprint (for TOFU pinning).
async fn connect(
p: &ConnParams,
creds: &SftpCreds,
) -> std::result::Result<(SftpConn, String), SftpClientError> {
let observed = Arc::new(Mutex::new(None::<String>));
let handler = TofuHandler {
expected: p.expected_fingerprint.clone(),
observed: observed.clone(),
};
let config = Arc::new(client::Config::default());
let connect_fut = client::connect(config, (p.server.as_str(), p.port), handler);
let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
// Distinguish a host-key mismatch (handshake aborted by our
// handler) from a generic connect failure.
if let (Some(exp), Some(got)) = (&p.expected_fingerprint, observed.lock().clone()) {
if exp != &got {
return Err(SftpClientError::HostKeyMismatch {
expected: exp.clone(),
got,
});
}
}
return Err(SftpClientError::Connect(e.to_string()));
}
Err(_) => return Err(SftpClientError::Timeout(p.server.clone(), p.port)),
};
let observed_fp = observed.lock().clone().unwrap_or_default();
let authed = if let Some(pw) = creds.password.as_deref() {
session
.authenticate_password(p.username.as_str(), pw)
.await
.map_err(|e| SftpClientError::Auth(e.to_string()))?
.success()
} else if let Some(pem) = creds.private_key.as_deref() {
let key = decode_secret_key(pem, creds.passphrase.as_deref())
.map_err(|e| SftpClientError::Auth(format!("private key: {e}")))?;
// For RSA keys, request rsa-sha2-256 (modern servers reject the
// legacy ssh-rsa/SHA-1); `new` ignores the hash for other key
// types (ed25519, ecdsa), so this is safe to pass unconditionally.
let pk = PrivateKeyWithHashAlg::new(Arc::new(key), Some(HashAlg::Sha256));
session
.authenticate_publickey(p.username.as_str(), pk)
.await
.map_err(|e| SftpClientError::Auth(e.to_string()))?
.success()
} else {
return Err(SftpClientError::Auth("no credential configured".into()));
};
if !authed {
return Err(SftpClientError::AuthRejected);
}
let channel = session
.channel_open_session()
.await
.map_err(|e| SftpClientError::Connect(e.to_string()))?;
channel
.request_subsystem(true, "sftp")
.await
.map_err(|e| SftpClientError::Protocol(e.to_string()))?;
let sftp = SftpSession::new(channel.into_stream())
.await
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
Ok((
SftpConn {
_session: session,
sftp,
},
observed_fp,
))
}
/// Connect and READDIR the export for `*.iso` files. Returns the listing
/// plus the observed host-key fingerprint.
async fn list_isos(
p: &ConnParams,
creds: &SftpCreds,
) -> std::result::Result<(Vec<SftpListEntry>, String), SftpClientError> {
let (conn, fingerprint) = connect(p, creds).await?;
let dir = conn
.sftp
.read_dir(p.export.as_str())
.await
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
let mut entries = Vec::new();
for entry in dir {
let name = entry.file_name();
if name == "." || name == ".." {
continue;
}
let md = entry.metadata();
if md.is_dir() {
continue;
}
if !name.to_ascii_lowercase().ends_with(".iso") {
continue;
}
entries.push(SftpListEntry {
filename: name,
size: md.len(),
});
}
// `conn` drops here, closing the SSH session.
Ok((entries, fingerprint))
}
async fn stream_loop(
p: &ConnParams,
creds: &SftpCreds,
filename: &str,
start_offset: u64,
max_len: Option<u64>,
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
) -> std::result::Result<(), SftpClientError> {
let (conn, _fp) = connect(p, creds).await?;
let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
let mut file = conn
.sftp
.open(full)
.await
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
if start_offset > 0 {
file.seek(SeekFrom::Start(start_offset))
.await
.map_err(|e| SftpClientError::Io(e.to_string()))?;
}
let mut remaining = max_len;
let mut buf = vec![0u8; READ_CHUNK_BYTES];
loop {
if remaining == Some(0) {
break;
}
let want = match remaining {
Some(r) if (r as usize) < READ_CHUNK_BYTES => r as usize,
_ => READ_CHUNK_BYTES,
};
let n = file
.read(&mut buf[..want])
.await
.map_err(|e| SftpClientError::Io(e.to_string()))?;
if n == 0 {
break; // EOF
}
let bytes = Bytes::copy_from_slice(&buf[..n]);
if tx.send(Ok(bytes)).await.is_err() {
break; // client disconnected
}
if let Some(r) = remaining.as_mut() {
*r = r.saturating_sub(n as u64);
}
}
// `conn` drops here.
Ok(())
}
/// Pre-flight TCP probe to `server:port`. Mirrors the SMB probe so the
/// UI banner reads consistently.
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach SSH port: {addr}: {e}"),
format!("verify the SSH service is running on {server} and that port {port} is open"),
)),
Err(_) => Err((
format!(
"cannot reach SSH port: {addr}: timed out after {}s",
PROBE_TIMEOUT.as_secs()
),
format!(
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
PROBE_TIMEOUT.as_secs()
),
)),
}
}
#[derive(Debug)]
enum SftpClientError {
Connect(String),
Timeout(String, u16),
Auth(String),
AuthRejected,
HostKeyMismatch { expected: String, got: String },
Protocol(String),
Sftp(String),
Io(String),
}
impl std::fmt::Display for SftpClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connect(msg) => write!(f, "connect failed: {msg}"),
Self::Timeout(host, port) => write!(
f,
"connect timed out after {}s talking to {host}:{port}",
CONNECT_TIMEOUT.as_secs()
),
Self::Auth(msg) => write!(f, "authentication error: {msg}"),
Self::AuthRejected => write!(f, "the server rejected the credentials"),
Self::HostKeyMismatch { expected, got } => {
write!(f, "SSH host key changed (expected {expected}, got {got})")
}
Self::Protocol(msg) => write!(f, "SSH channel error: {msg}"),
Self::Sftp(msg) => write!(f, "SFTP error: {msg}"),
Self::Io(msg) => write!(f, "read error: {msg}"),
}
}
}
impl std::error::Error for SftpClientError {}
/// Translate well-known SFTP/SSH failures into actionable hints.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("host key changed") || s.contains("host key mismatch") {
Some(
"the server's SSH host key is different from the one pinned when \
this share was added. Either the server was rebuilt / its host \
key rotated, or this is a man-in-the-middle. If the change is \
expected, remove and re-add the share to trust the new key."
.into(),
)
} else if s.contains("rejected the credentials")
|| s.contains("authentication error")
|| s.contains("auth")
{
Some(
"SSH authentication failed. Double-check the username and the \
password / private key. For key auth, make sure you pasted the \
PRIVATE key (the file without .pub), that its passphrase is \
correct, and that the matching public key is in the server's \
~/.ssh/authorized_keys for this user."
.into(),
)
} else if s.contains("no such file") || s.contains("does not exist") || s.contains("no such") {
Some(
"the export directory or ISO wasn't found on the server. Check \
the export path (absolute, e.g. /srv/isos) and that the SSH user \
can list it."
.into(),
)
} else if s.contains("permission denied") {
Some(
"authenticated, but the SSH user can't read the export directory \
or file. Check the directory's permissions on the server."
.into(),
)
} else if s.contains("connection refused") {
Some(
"nothing is listening on this SSH port. Verify sshd is running on \
the server and that the port (default 22) is correct."
.into(),
)
} else if s.contains("timed out") || s.contains("timeout") {
Some(
"no answer from the server within the connect timeout. Verify the \
IP, the port (default 22), and any firewall in between."
.into(),
)
} else if s.contains("no route to host") || s.contains("network is unreachable") {
Some("the server isn't reachable on this network. Check the IP and routes.".into())
} else {
None
}
}
fn share_id(server: &str, export: &str) -> String {
slugify_str(&format!("{server}{export}"))
}
/// Normalize a server input: trim, strip schemes, drop trailing slashes.
/// Mirrors the NFS/SMB normalizers so paste-from-anywhere works.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("sftp://")
.or_else(|| s.strip_prefix("ssh://"))
.unwrap_or(s);
s.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn share_id_is_stable_and_safe() {
let a = share_id("10.0.0.5", "/srv/isos");
let b = share_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_schemes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("sftp://nas.lan/"), "nas.lan");
assert_eq!(normalize_server("ssh://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("nas.lan"), "nas.lan");
}
#[test]
fn hint_for_auth_failure_mentions_credentials() {
let h = hint_for("authentication error: bad password").unwrap();
let lc = h.to_lowercase();
assert!(lc.contains("username") || lc.contains("password") || lc.contains("key"));
}
#[test]
fn hint_for_host_key_mismatch_warns_about_mitm_and_readd() {
let h = hint_for("SSH host key changed (expected SHA256:a, got SHA256:b)").unwrap();
let lc = h.to_lowercase();
assert!(lc.contains("host key"));
assert!(lc.contains("re-add") || lc.contains("man-in-the-middle"));
}
#[test]
fn hint_for_refused_points_at_sshd() {
let h = hint_for("connect failed: Connection refused").unwrap();
assert!(h.to_lowercase().contains("sshd") || h.to_lowercase().contains("listening"));
}
#[test]
fn hint_for_unknown_is_none() {
assert!(hint_for("some entirely unrelated string").is_none());
}
#[tokio::test]
async fn add_requires_username() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().join("isos"));
let mgr = SftpShareManager::new(dir.path(), store);
let err = mgr
.add(SftpAddRequest {
server: "10.0.0.5".into(),
export: "/srv/isos".into(),
username: None,
port: None,
password: Some("pw".into()),
private_key: None,
passphrase: None,
})
.await
.unwrap_err();
assert!(
err.error.to_lowercase().contains("username"),
"{}",
err.error
);
}
#[tokio::test]
async fn add_requires_a_secret() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().join("isos"));
let mgr = SftpShareManager::new(dir.path(), store);
let err = mgr
.add(SftpAddRequest {
server: "10.0.0.5".into(),
export: "/srv/isos".into(),
username: Some("root".into()),
port: None,
password: None,
private_key: None,
passphrase: None,
})
.await
.unwrap_err();
let lc = err.error.to_lowercase();
assert!(
lc.contains("password") || lc.contains("private key"),
"{}",
err.error
);
}
#[tokio::test]
async fn add_rejects_both_secrets() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().join("isos"));
let mgr = SftpShareManager::new(dir.path(), store);
let err = mgr
.add(SftpAddRequest {
server: "10.0.0.5".into(),
export: "/srv/isos".into(),
username: Some("root".into()),
port: None,
password: Some("pw".into()),
private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----".into()),
passphrase: None,
})
.await
.unwrap_err();
assert!(
err.error.to_lowercase().contains("not both"),
"{}",
err.error
);
}
#[tokio::test]
async fn add_requires_absolute_export() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().join("isos"));
let mgr = SftpShareManager::new(dir.path(), store);
let err = mgr
.add(SftpAddRequest {
server: "10.0.0.5".into(),
export: "relative/path".into(),
username: Some("root".into()),
port: None,
password: Some("pw".into()),
private_key: None,
passphrase: None,
})
.await
.unwrap_err();
assert!(
err.error.to_lowercase().contains("absolute"),
"{}",
err.error
);
}
#[tokio::test]
async fn creds_round_trip_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().join("isos"));
let mgr = SftpShareManager::new(dir.path(), store);
let creds = SftpCreds {
password: Some("hunter2".into()),
private_key: None,
passphrase: None,
};
mgr.write_creds("share1", &creds).await.unwrap();
let read = mgr.read_creds("share1").await.unwrap();
assert_eq!(read.password.as_deref(), Some("hunter2"));
// The secret file must be 0600.
let meta = std::fs::metadata(mgr.creds_path_for("share1")).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}