//! SFTP backend, over libssh2. //! //! URL form: `sftp://user@host:port/path` (an absolute remote path; //! `ssh://` is accepted as an alias). A URL without a user takes it //! from a matching lftp bookmark (see [`crate::lftp`]), then `$USER`. //! Authentication is tried in order: the key file named by //! `$BEEPING_SSH_KEY` (if set), the ssh-agent, the standard key files //! (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`), `$BEEPING_SSH_PASSWORD` //! (if set), and finally a password from a matching lftp bookmark. //! //! Host keys are checked against `~/.ssh/known_hosts` (or the file //! named by `$BEEPING_SSH_KNOWN_HOSTS`): a mismatch is a hard error //! (possible man-in-the-middle), and an unknown host is trusted on //! first use and appended to the file. //! //! At most `?connections=N` (default 4) SSH sessions are held at once, //! staying comfortably under a typical sshd's session limits. use std::{ io::Read as _, io::Write as _, net::TcpStream, path::PathBuf, sync::atomic::{AtomicBool, Ordering}, }; use repository::{Backend, ChunkId, Error, ObjectKey, ObjectKind}; use ssh2::{CheckResult, ErrorCode, KnownHostFileKind, RenameFlags, Session, Sftp}; use url::Url; use crate::{ lftp, percent_decode, pool::{Pool, temp_suffix}, }; /// SFTP status code for a missing file, per the protocol. const SFTP_NO_SUCH_FILE: i32 = 2; const DEFAULT_CONNECTIONS: usize = 4; pub struct SftpBackend { host: String, port: u16, user: String, /// Password found in an lftp bookmark, if any; the last resort of /// the authentication order. bookmark_password: Option, root: PathBuf, pool: Pool, /// Set once the server has declined to report free space, so a /// backup watching its floor stops asking. The extension is /// optional, and a refusal costs a pooled retry and a fresh dial /// every time it is tried. space_unknown: AtomicBool, } /// One authenticated session plus its SFTP channel. The session must /// outlive the channel, so they travel together. struct SftpConnection { _session: Session, sftp: Sftp, } impl SftpBackend { pub fn from_url(url: &Url) -> Result { let host = url .host_str() .ok_or_else(|| Error::Backend(format!("{url} has no host")))? .to_string(); let port = url.port().unwrap_or(22); let mut user = match url.username() { "" => None, user => Some(percent_decode(user)), }; let mut bookmark_password = None; if let Some(found) = lftp::find_credentials("sftp", &host, port, user.as_deref()) { user.get_or_insert(found.user); bookmark_password = found.password; } let user = match user.or_else(|| std::env::var("USER").ok()) { Some(user) => user, None => return Err(Error::Backend(format!("{url} has no user name"))), }; Ok(SftpBackend { host, port, user, bookmark_password, root: PathBuf::from(url.path()), pool: Pool::new(crate::connection_limit(url, DEFAULT_CONNECTIONS)?), space_unknown: AtomicBool::new(false), }) } fn with_connection( &self, op: impl FnMut(&mut SftpConnection) -> Result, ) -> Result { // SSH has no clean "server full" signal to classify; the // conservative cap does the work here self.pool.with(|| self.connect(), |_| false, op) } fn connect(&self) -> Result { let tcp = TcpStream::connect((self.host.as_str(), self.port)) .map_err(|err| Error::Backend(format!("sftp: connecting: {err}")))?; let mut session = Session::new().map_err(sftp_error)?; session.set_tcp_stream(tcp); session.handshake().map_err(sftp_error)?; self.check_host_key(&session)?; self.authenticate(&session)?; let sftp = session.sftp().map_err(sftp_error)?; Ok(SftpConnection { _session: session, sftp, }) } /// Verifies the server against `~/.ssh/known_hosts`, trusting and /// recording hosts seen for the first time. fn check_host_key(&self, session: &Session) -> Result<(), Error> { let (key, key_type) = session .host_key() .ok_or_else(|| Error::Backend("sftp: server offered no host key".to_string()))?; let mut known_hosts = session.known_hosts().map_err(sftp_error)?; let file = std::env::var_os("BEEPING_SSH_KNOWN_HOSTS") .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|home| home.join(".ssh/known_hosts"))); if let Some(file) = &file { // A missing file just means no hosts are known yet let _ = known_hosts.read_file(file, KnownHostFileKind::OpenSSH); } match known_hosts.check_port(&self.host, self.port, key) { CheckResult::Match => Ok(()), CheckResult::Mismatch => Err(Error::Backend(format!( "sftp: HOST KEY MISMATCH for {}: the server's key does not \ match ~/.ssh/known_hosts; someone may be intercepting the \ connection", self.host ))), CheckResult::NotFound | CheckResult::Failure => { // Trust on first use, and remember the decision let name = if self.port == 22 { self.host.clone() } else { format!("[{}]:{}", self.host, self.port) }; known_hosts .add(&name, key, "added by beeping", key_type.into()) .map_err(sftp_error)?; if let Some(file) = &file { known_hosts .write_file(file, KnownHostFileKind::OpenSSH) .map_err(sftp_error)?; } Ok(()) } } } fn authenticate(&self, session: &Session) -> Result<(), Error> { if let Some(key) = std::env::var_os("BEEPING_SSH_KEY") && session .userauth_pubkey_file(&self.user, None, std::path::Path::new(&key), None) .is_ok() && session.authenticated() { return Ok(()); } if session.userauth_agent(&self.user).is_ok() && session.authenticated() { return Ok(()); } if let Some(home) = dirs::home_dir() { for name in ["id_ed25519", "id_rsa"] { let key = home.join(".ssh").join(name); if key.exists() && session .userauth_pubkey_file(&self.user, None, &key, None) .is_ok() && session.authenticated() { return Ok(()); } } } let passwords = std::env::var("BEEPING_SSH_PASSWORD") .ok() .into_iter() .chain(self.bookmark_password.clone()); for password in passwords { if session.userauth_password(&self.user, &password).is_ok() && session.authenticated() { return Ok(()); } } Err(Error::Backend(format!( "sftp: could not authenticate as {}@{} (tried agent, key \ files, $BEEPING_SSH_PASSWORD, and lftp bookmarks)", self.user, self.host ))) } fn object_path(&self, key: &ObjectKey) -> PathBuf { let mut path = self.root.clone(); for segment in key.segments() { path.push(segment); } path } fn dir_names(&self, dir: PathBuf) -> Result, Error> { self.with_connection(|connection| match connection.sftp.readdir(&dir) { Ok(entries) => Ok(entries .into_iter() .filter_map(|(path, _)| { path.file_name() .and_then(|name| name.to_str()) .map(str::to_string) }) .collect()), Err(err) if is_not_found(&err) => Ok(Vec::new()), Err(err) => Err(sftp_error(err)), }) } } impl Backend for SftpBackend { fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), Error> { let path = self.object_path(key); self.with_connection(|connection| { let sftp = &connection.sftp; if sftp.stat(&path).is_ok() { // Write-once: the object is already this one return Ok(()); } // Create missing parents; errors mean "exists" more // often than not, and real trouble fails the write below let mut parent = self.root.clone(); let segments = key.segments(); let _ = sftp.mkdir(&parent, 0o755); for segment in &segments[..segments.len() - 1] { parent.push(segment); let _ = sftp.mkdir(&parent, 0o755); } // Write beside the final name, then rename into place let temp = { let mut temp = path.clone().into_os_string(); temp.push(temp_suffix()); PathBuf::from(temp) }; { let mut file = sftp.create(&temp).map_err(sftp_error)?; file.write_all(data) .map_err(|err| Error::Backend(format!("sftp: writing: {err}")))?; // Not every server implements the fsync extension; // decline is not failure let _ = file.fsync(); } let flags = RenameFlags::ATOMIC | RenameFlags::OVERWRITE | RenameFlags::NATIVE; match sftp.rename(&temp, &path, Some(flags)) { Ok(()) => Ok(()), Err(_) if sftp.stat(&path).is_ok() => { // Lost a benign race to a concurrent writer let _ = sftp.unlink(&temp); Ok(()) } Err(err) => Err(sftp_error(err)), } }) } fn get(&self, key: &ObjectKey) -> Result>, Error> { let path = self.object_path(key); self.with_connection(|connection| match connection.sftp.open(&path) { Ok(mut file) => { let mut data = Vec::new(); file.read_to_end(&mut data) .map_err(|err| Error::Backend(format!("sftp: reading: {err}")))?; Ok(Some(data)) } Err(err) if is_not_found(&err) => Ok(None), Err(err) => Err(sftp_error(err)), }) } fn contains(&self, key: &ObjectKey) -> Result { let path = self.object_path(key); self.with_connection(|connection| match connection.sftp.stat(&path) { Ok(_) => Ok(true), Err(err) if is_not_found(&err) => Ok(false), Err(err) => Err(sftp_error(err)), }) } fn list( &self, kind: ObjectKind, visit: &mut dyn FnMut(ObjectKey) -> Result<(), Error>, ) -> Result<(), Error> { match kind { ObjectKind::Header => { if self.contains(&ObjectKey::Header)? { visit(ObjectKey::Header)?; } } // The flat kinds: one directory of hex-named objects each ObjectKind::Snapshot | ObjectKind::Prune | ObjectKind::Lock => { for name in self.dir_names(self.root.join(kind.directory()))? { if let Some(key) = kind.key_for(&name) { visit(key)?; } } } ObjectKind::Chunk => { for prefix in self.dir_names(self.root.join("chunks"))? { if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) { continue; } for name in self.dir_names(self.root.join("chunks").join(&prefix))? { if let Ok(id) = ChunkId::from_hex(name) { visit(ObjectKey::Chunk(id))?; } } } } } Ok(()) } /// Asks the server for the filesystem statistics of the repository /// directory, over the OpenSSH `fstatvfs` SFTP extension. /// /// Servers that do not implement it simply cannot say, which is not /// an error — the floor is not applied rather than the backup /// failing — and the refusal is remembered so the question is asked /// only once. fn free_space(&self) -> Result, Error> { if self.space_unknown.load(Ordering::Acquire) { return Ok(None); } let stats = self.with_connection(|connection| { let mut dir = connection.sftp.opendir(&self.root).map_err(sftp_error)?; dir.statvfs().map_err(sftp_error) }); match stats { Ok(stats) => Ok(Some(stats.f_bavail.saturating_mul(stats.f_frsize))), Err(_) => { self.space_unknown.store(true, Ordering::Release); Ok(None) } } } /// Adds up everything under the repository directory, one readdir /// per directory — a few hundred round trips for the fanned-out /// chunk store, which is why this is asked once a run and not more. fn used_space(&self) -> Result, Error> { self.with_connection(|connection| tree_bytes(&connection.sftp, &self.root)) .map(Some) } fn delete(&self, key: &ObjectKey) -> Result<(), Error> { let path = self.object_path(key); self.with_connection(|connection| match connection.sftp.unlink(&path) { Ok(()) => Ok(()), Err(err) if is_not_found(&err) => Ok(()), Err(err) => Err(sftp_error(err)), }) } } /// How many bytes the tree under `path` holds, by the sizes the server /// reports. A directory that is not there holds nothing. fn tree_bytes(sftp: &Sftp, path: &std::path::Path) -> Result { let entries = match sftp.readdir(path) { Ok(entries) => entries, Err(err) if is_not_found(&err) => return Ok(0), Err(err) => return Err(sftp_error(err)), }; let mut total = 0; for (child, stat) in entries { total += if stat.is_dir() { tree_bytes(sftp, &child)? } else { stat.size.unwrap_or(0) }; } Ok(total) } fn is_not_found(err: &ssh2::Error) -> bool { matches!(err.code(), ErrorCode::SFTP(SFTP_NO_SUCH_FILE)) } fn sftp_error(err: ssh2::Error) -> Error { Error::Backend(format!("sftp: {err}")) }