//! 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 //! `/sftp_creds/.cred`. The share metadata //! (server/user/export/host-key fingerprint) lives in //! `/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, pub last_error: Option, pub last_hint: Option, #[serde(with = "time::serde::rfc3339::option")] pub last_scan: Option, 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, #[serde(default)] pub port: Option, #[serde(default)] pub password: Option, /// PEM-encoded OpenSSH private key, pasted by the operator. #[serde(default)] pub private_key: Option, /// Passphrase for an encrypted private key. Optional. #[serde(default)] pub passphrase: Option, } 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, #[serde(default)] private_key: Option, #[serde(default)] passphrase: Option, } /// 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, } impl SftpShareError { fn from_raw(error: impl Into, stderr: impl Into) -> 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, } /// Manages SFTP shares. Cheap to clone — internal state is /// `Arc>`. #[derive(Debug, Clone)] pub struct SftpShareManager { creds_root: Arc, state_path: Arc, inner: Arc>, iso_store: IsoStore, /// Serializes scan operations on the same manager for predictable /// log output; each scan opens its own SSH connection. op_lock: Arc>, } impl SftpShareManager { /// Construct a manager. Secrets live under `/sftp_creds/` /// (0600); state persists to `/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::>(&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 { 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 { self.rescan_inner(id).await } #[must_use] pub fn list(&self) -> Vec { 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 { 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, ) -> Result { 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::>(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(¶ms, &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 { 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(¶ms, &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, hint: Option, 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 { 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 = 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>` so axum can build a response body /// via `Body::from_stream`. #[derive(Debug)] pub struct SftpStream { rx: tokio::sync::mpsc::Receiver>, /// 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; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { 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, } 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, 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, observed: Arc>>, } impl client::Handler for TofuHandler { type Error = russh::Error; async fn check_server_key( &mut self, server_public_key: &PublicKey, ) -> std::result::Result { 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::)); 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, 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, tx: tokio::sync::mpsc::Sender>, ) -> 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 { 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); } }