Skip to main content

faucet_common_sftp/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # faucet-common-sftp
4//!
5//! Shared SFTP connection configuration and connect helper for the
6//! [`faucet-source-sftp`](https://docs.rs/faucet-source-sftp) and
7//! [`faucet-sink-sftp`](https://docs.rs/faucet-sink-sftp) connectors.
8//!
9//! The single entry point is [`connect`], which opens an SSH transport
10//! (password or private-key auth), verifies the server host key against the
11//! configured [`HostKeyPolicy`], opens the `sftp` subsystem, and hands back a
12//! ready [`SftpSession`]. Both the source and the sink build their session
13//! through this helper so auth, host-key handling, and error mapping stay
14//! consistent.
15//!
16//! ## Host-key verification
17//!
18//! Man-in-the-middle protection is on by default. [`HostKeyPolicy`] defaults to
19//! [`AcceptNew`](HostKeyPolicy::AcceptNew) (trust-on-first-use — record an
20//! unknown key, reject a *changed* one). [`Strict`](HostKeyPolicy::Strict)
21//! requires the key to already be present in `known_hosts`.
22//! [`Insecure`](HostKeyPolicy::Insecure) disables verification entirely and
23//! must be selected explicitly.
24//!
25//! ## Secrets
26//!
27//! [`SftpAuth`] has a hand-written [`Debug`] impl that never prints the
28//! password or key passphrase, so a `Debug`-formatted
29//! [`SftpConnectionConfig`] is safe to log.
30
31use std::sync::Arc;
32
33use faucet_core::FaucetError;
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37pub use russh_sftp::client::SftpSession;
38// Re-exported so callers can open files for writing with explicit flags. The
39// `SftpSession::write` convenience opens with `WRITE` only (no `CREATE`), so it
40// cannot create a new file — writing one requires
41// `open_with_flags(path, OpenFlags::CREATE | OpenFlags::WRITE | OpenFlags::TRUNCATE)`.
42pub use russh_sftp::protocol::OpenFlags;
43
44/// Default SSH port.
45pub const DEFAULT_PORT: u16 = 22;
46
47fn default_port() -> u16 {
48    DEFAULT_PORT
49}
50
51/// How the server's host key is verified during the SSH handshake.
52///
53/// Defaults to [`AcceptNew`](Self::AcceptNew). The insecure, verification-off
54/// mode is a distinct explicit variant so it can never be selected by
55/// accident.
56#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
57#[serde(tag = "mode", rename_all = "snake_case")]
58pub enum HostKeyPolicy {
59    /// Reject any host key that is not already recorded in `known_hosts`.
60    /// The most secure policy; requires the key to be pre-provisioned.
61    Strict {
62        /// Path to the `known_hosts` file. `None` uses the standard
63        /// `~/.ssh/known_hosts` location.
64        #[serde(default)]
65        known_hosts_path: Option<String>,
66    },
67    /// Trust-on-first-use: accept and record a host key the first time it is
68    /// seen (in `~/.ssh/known_hosts`), but reject a key that has *changed*
69    /// from a previously recorded one. This is the default.
70    #[default]
71    AcceptNew,
72    /// Disable host-key verification entirely. **Insecure** — vulnerable to
73    /// man-in-the-middle attacks. Use only against trusted networks / test
74    /// servers.
75    Insecure,
76}
77
78/// SFTP authentication method.
79///
80/// Serializes with the faucet `{ "type": <method>, "config": { … } }`
81/// adjacently-tagged shape shared by every connector's auth block.
82#[derive(Clone, Serialize, Deserialize, JsonSchema)]
83#[serde(tag = "type", content = "config", rename_all = "snake_case")]
84pub enum SftpAuth {
85    /// Password authentication.
86    Password {
87        /// The account password.
88        password: String,
89    },
90    /// Public-key authentication with an OpenSSH/PEM private key on disk.
91    PrivateKey {
92        /// Path to the private-key file.
93        path: String,
94        /// Optional passphrase used to decrypt an encrypted private key.
95        #[serde(default)]
96        passphrase: Option<String>,
97    },
98}
99
100/// Secret-safe: never prints the password or passphrase material.
101impl std::fmt::Debug for SftpAuth {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            SftpAuth::Password { .. } => f
105                .debug_struct("Password")
106                .field("password", &"<redacted>")
107                .finish(),
108            SftpAuth::PrivateKey { path, passphrase } => f
109                .debug_struct("PrivateKey")
110                .field("path", path)
111                .field("passphrase", &passphrase.as_ref().map(|_| "<redacted>"))
112                .finish(),
113        }
114    }
115}
116
117/// Shared SFTP connection configuration.
118#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
119pub struct SftpConnectionConfig {
120    /// Server hostname or IP address.
121    pub host: String,
122    /// Server port (default: 22).
123    #[serde(default = "default_port")]
124    pub port: u16,
125    /// SSH username.
126    pub username: String,
127    /// Authentication method (`{ type, config }`).
128    #[serde(flatten)]
129    pub auth: SftpAuth,
130    /// Host-key verification policy (default: `accept_new`).
131    #[serde(default)]
132    pub known_hosts: HostKeyPolicy,
133}
134
135impl SftpConnectionConfig {
136    /// Build a config with password authentication and default host-key policy.
137    pub fn with_password(
138        host: impl Into<String>,
139        username: impl Into<String>,
140        password: impl Into<String>,
141    ) -> Self {
142        Self {
143            host: host.into(),
144            port: DEFAULT_PORT,
145            username: username.into(),
146            auth: SftpAuth::Password {
147                password: password.into(),
148            },
149            known_hosts: HostKeyPolicy::default(),
150        }
151    }
152
153    /// Set the port.
154    pub fn port(mut self, port: u16) -> Self {
155        self.port = port;
156        self
157    }
158
159    /// Set the host-key policy.
160    pub fn known_hosts(mut self, policy: HostKeyPolicy) -> Self {
161        self.known_hosts = policy;
162        self
163    }
164}
165
166/// Error type for the SSH client handler (host-key verification + transport).
167///
168/// Kept local to satisfy [`russh::client::Handler::Error`]'s
169/// `From<russh::Error>` bound; [`connect`] maps it into a [`FaucetError`] for
170/// callers.
171#[derive(Debug)]
172enum HandlerError {
173    /// A transport-level SSH error surfaced through the handler.
174    Ssh(russh::Error),
175    /// The server's host key was rejected by the configured policy.
176    HostKey(String),
177}
178
179impl std::fmt::Display for HandlerError {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match self {
182            HandlerError::Ssh(e) => write!(f, "SSH transport error: {e}"),
183            HandlerError::HostKey(m) => write!(f, "host key rejected: {m}"),
184        }
185    }
186}
187
188impl std::error::Error for HandlerError {}
189
190impl From<russh::Error> for HandlerError {
191    fn from(e: russh::Error) -> Self {
192        HandlerError::Ssh(e)
193    }
194}
195
196/// SSH client handler that verifies the server host key against a policy.
197struct ClientHandler {
198    policy: HostKeyPolicy,
199    host: String,
200    port: u16,
201}
202
203impl russh::client::Handler for ClientHandler {
204    type Error = HandlerError;
205
206    async fn check_server_key(
207        &mut self,
208        server_public_key: &russh::keys::PublicKey,
209    ) -> Result<bool, Self::Error> {
210        match &self.policy {
211            HostKeyPolicy::Insecure => {
212                tracing::warn!(
213                    host = %self.host,
214                    port = self.port,
215                    "SFTP host-key verification is DISABLED (insecure policy)"
216                );
217                Ok(true)
218            }
219            HostKeyPolicy::Strict { known_hosts_path } => {
220                let found = match known_hosts_path {
221                    Some(path) => russh::keys::check_known_hosts_path(
222                        &self.host,
223                        self.port,
224                        server_public_key,
225                        path,
226                    ),
227                    None => {
228                        russh::keys::check_known_hosts(&self.host, self.port, server_public_key)
229                    }
230                }
231                .map_err(|e| HandlerError::HostKey(format!("known_hosts lookup failed: {e}")))?;
232                if found {
233                    Ok(true)
234                } else {
235                    Err(HandlerError::HostKey(format!(
236                        "host key for {}:{} is not present in known_hosts (strict policy)",
237                        self.host, self.port
238                    )))
239                }
240            }
241            HostKeyPolicy::AcceptNew => {
242                match russh::keys::check_known_hosts(&self.host, self.port, server_public_key) {
243                    Ok(true) => Ok(true),
244                    Ok(false) => {
245                        russh::keys::known_hosts::learn_known_hosts(
246                            &self.host,
247                            self.port,
248                            server_public_key,
249                        )
250                        .map_err(|e| {
251                            HandlerError::HostKey(format!(
252                                "failed to record new host key for {}:{}: {e}",
253                                self.host, self.port
254                            ))
255                        })?;
256                        tracing::info!(
257                            host = %self.host,
258                            port = self.port,
259                            "recorded new SFTP host key (accept-new policy)"
260                        );
261                        Ok(true)
262                    }
263                    Err(e) => Err(HandlerError::HostKey(format!(
264                        "host key for {}:{} changed or is invalid: {e}",
265                        self.host, self.port
266                    ))),
267                }
268            }
269        }
270    }
271}
272
273/// Open an SSH transport to the configured server, authenticate, verify the
274/// host key, and open the `sftp` subsystem.
275///
276/// The returned [`SftpSession`] owns the underlying channel; the SSH session
277/// task stays alive for as long as the session is held and shuts down cleanly
278/// when it is dropped.
279///
280/// # Errors
281///
282/// Returns [`FaucetError::Auth`] when authentication or host-key verification
283/// fails, and [`FaucetError::Custom`] for transport / subsystem errors.
284pub async fn connect(cfg: &SftpConnectionConfig) -> Result<SftpSession, FaucetError> {
285    let config = Arc::new(russh::client::Config::default());
286    let handler = ClientHandler {
287        policy: cfg.known_hosts.clone(),
288        host: cfg.host.clone(),
289        port: cfg.port,
290    };
291
292    let mut session = russh::client::connect(config, (cfg.host.as_str(), cfg.port), handler)
293        .await
294        .map_err(map_handler_err)?;
295
296    let authenticated = match &cfg.auth {
297        SftpAuth::Password { password } => session
298            .authenticate_password(&cfg.username, password)
299            .await
300            .map_err(map_ssh_err)?,
301        SftpAuth::PrivateKey { path, passphrase } => {
302            let key = russh::keys::load_secret_key(path, passphrase.as_deref()).map_err(|e| {
303                FaucetError::Auth(format!("failed to load SFTP private key '{path}': {e}"))
304            })?;
305            let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), None);
306            session
307                .authenticate_publickey(&cfg.username, key)
308                .await
309                .map_err(map_ssh_err)?
310        }
311    };
312
313    if !authenticated.success() {
314        return Err(FaucetError::Auth(format!(
315            "SFTP authentication failed for user '{}' on {}:{}",
316            cfg.username, cfg.host, cfg.port
317        )));
318    }
319
320    let channel = session.channel_open_session().await.map_err(map_ssh_err)?;
321    channel
322        .request_subsystem(true, "sftp")
323        .await
324        .map_err(map_ssh_err)?;
325
326    let sftp = SftpSession::new(channel.into_stream())
327        .await
328        .map_err(|e| FaucetError::Custom(format!("failed to start SFTP subsystem: {e}").into()))?;
329
330    // The `Handle` (`session`) can now be dropped: the `SftpSession` holds its
331    // own clone of the session message sender, so the SSH session task stays
332    // alive as long as the returned session is held.
333    Ok(sftp)
334}
335
336fn map_handler_err(e: HandlerError) -> FaucetError {
337    match e {
338        HandlerError::HostKey(m) => {
339            FaucetError::Auth(format!("SFTP host-key verification failed: {m}"))
340        }
341        HandlerError::Ssh(e) => FaucetError::Custom(format!("SFTP connection failed: {e}").into()),
342    }
343}
344
345fn map_ssh_err(e: russh::Error) -> FaucetError {
346    FaucetError::Custom(format!("SFTP SSH error: {e}").into())
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn default_port_is_22() {
355        let json = r#"{
356            "host": "example.com",
357            "username": "user",
358            "type": "password",
359            "config": { "password": "secret" }
360        }"#;
361        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
362        assert_eq!(cfg.port, DEFAULT_PORT);
363    }
364
365    #[test]
366    fn default_host_key_policy_is_accept_new() {
367        let json = r#"{
368            "host": "example.com",
369            "username": "user",
370            "type": "password",
371            "config": { "password": "secret" }
372        }"#;
373        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
374        assert!(matches!(cfg.known_hosts, HostKeyPolicy::AcceptNew));
375    }
376
377    #[test]
378    fn password_auth_round_trips() {
379        let json = r#"{
380            "host": "h",
381            "port": 2222,
382            "username": "u",
383            "type": "password",
384            "config": { "password": "p" }
385        }"#;
386        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
387        assert_eq!(cfg.port, 2222);
388        match &cfg.auth {
389            SftpAuth::Password { password } => assert_eq!(password, "p"),
390            other => panic!("expected password auth, got {other:?}"),
391        }
392        // Serialize back and confirm the adjacently-tagged shape survives.
393        let value = serde_json::to_value(&cfg).unwrap();
394        assert_eq!(value["type"], "password");
395        assert_eq!(value["config"]["password"], "p");
396    }
397
398    #[test]
399    fn private_key_auth_round_trips() {
400        let json = r#"{
401            "host": "h",
402            "username": "u",
403            "type": "private_key",
404            "config": { "path": "/home/u/.ssh/id_ed25519" }
405        }"#;
406        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
407        match &cfg.auth {
408            SftpAuth::PrivateKey { path, passphrase } => {
409                assert_eq!(path, "/home/u/.ssh/id_ed25519");
410                assert!(passphrase.is_none());
411            }
412            other => panic!("expected private-key auth, got {other:?}"),
413        }
414    }
415
416    #[test]
417    fn strict_policy_round_trips_with_path() {
418        let json = r#"{
419            "host": "h",
420            "username": "u",
421            "type": "password",
422            "config": { "password": "p" },
423            "known_hosts": { "mode": "strict", "known_hosts_path": "/etc/known_hosts" }
424        }"#;
425        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
426        match &cfg.known_hosts {
427            HostKeyPolicy::Strict { known_hosts_path } => {
428                assert_eq!(known_hosts_path.as_deref(), Some("/etc/known_hosts"));
429            }
430            other => panic!("expected strict policy, got {other:?}"),
431        }
432    }
433
434    #[test]
435    fn insecure_policy_round_trips() {
436        let json = r#"{
437            "host": "h",
438            "username": "u",
439            "type": "password",
440            "config": { "password": "p" },
441            "known_hosts": { "mode": "insecure" }
442        }"#;
443        let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
444        assert!(matches!(cfg.known_hosts, HostKeyPolicy::Insecure));
445    }
446
447    #[test]
448    fn debug_redacts_password() {
449        let cfg = SftpConnectionConfig::with_password("h", "u", "hunter2");
450        let dbg = format!("{cfg:?}");
451        assert!(!dbg.contains("hunter2"), "password leaked in Debug: {dbg}");
452        assert!(dbg.contains("<redacted>"));
453    }
454
455    #[test]
456    fn debug_redacts_passphrase() {
457        let auth = SftpAuth::PrivateKey {
458            path: "/k".into(),
459            passphrase: Some("topsecret".into()),
460        };
461        let dbg = format!("{auth:?}");
462        assert!(!dbg.contains("topsecret"), "passphrase leaked: {dbg}");
463        assert!(dbg.contains("/k"), "path should still be visible");
464    }
465
466    #[test]
467    fn config_schema_is_object() {
468        let schema = serde_json::to_value(schemars::schema_for!(SftpConnectionConfig)).unwrap();
469        assert!(schema.is_object());
470    }
471}