#![cfg_attr(docsrs, feature(doc_cfg))]
use std::sync::Arc;
use faucet_core::FaucetError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use russh_sftp::client::SftpSession;
pub use russh_sftp::protocol::OpenFlags;
pub const DEFAULT_PORT: u16 = 22;
fn default_port() -> u16 {
DEFAULT_PORT
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum HostKeyPolicy {
Strict {
#[serde(default)]
known_hosts_path: Option<String>,
},
#[default]
AcceptNew,
Insecure,
}
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
pub enum SftpAuth {
Password {
password: String,
},
PrivateKey {
path: String,
#[serde(default)]
passphrase: Option<String>,
},
}
impl std::fmt::Debug for SftpAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SftpAuth::Password { .. } => f
.debug_struct("Password")
.field("password", &"<redacted>")
.finish(),
SftpAuth::PrivateKey { path, passphrase } => f
.debug_struct("PrivateKey")
.field("path", path)
.field("passphrase", &passphrase.as_ref().map(|_| "<redacted>"))
.finish(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct SftpConnectionConfig {
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
pub username: String,
#[serde(flatten)]
pub auth: SftpAuth,
#[serde(default)]
pub known_hosts: HostKeyPolicy,
}
impl SftpConnectionConfig {
pub fn with_password(
host: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
host: host.into(),
port: DEFAULT_PORT,
username: username.into(),
auth: SftpAuth::Password {
password: password.into(),
},
known_hosts: HostKeyPolicy::default(),
}
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn known_hosts(mut self, policy: HostKeyPolicy) -> Self {
self.known_hosts = policy;
self
}
}
#[derive(Debug)]
enum HandlerError {
Ssh(russh::Error),
HostKey(String),
}
impl std::fmt::Display for HandlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HandlerError::Ssh(e) => write!(f, "SSH transport error: {e}"),
HandlerError::HostKey(m) => write!(f, "host key rejected: {m}"),
}
}
}
impl std::error::Error for HandlerError {}
impl From<russh::Error> for HandlerError {
fn from(e: russh::Error) -> Self {
HandlerError::Ssh(e)
}
}
struct ClientHandler {
policy: HostKeyPolicy,
host: String,
port: u16,
}
impl russh::client::Handler for ClientHandler {
type Error = HandlerError;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::PublicKey,
) -> Result<bool, Self::Error> {
match &self.policy {
HostKeyPolicy::Insecure => {
tracing::warn!(
host = %self.host,
port = self.port,
"SFTP host-key verification is DISABLED (insecure policy)"
);
Ok(true)
}
HostKeyPolicy::Strict { known_hosts_path } => {
let found = match known_hosts_path {
Some(path) => russh::keys::check_known_hosts_path(
&self.host,
self.port,
server_public_key,
path,
),
None => {
russh::keys::check_known_hosts(&self.host, self.port, server_public_key)
}
}
.map_err(|e| HandlerError::HostKey(format!("known_hosts lookup failed: {e}")))?;
if found {
Ok(true)
} else {
Err(HandlerError::HostKey(format!(
"host key for {}:{} is not present in known_hosts (strict policy)",
self.host, self.port
)))
}
}
HostKeyPolicy::AcceptNew => {
match russh::keys::check_known_hosts(&self.host, self.port, server_public_key) {
Ok(true) => Ok(true),
Ok(false) => {
russh::keys::known_hosts::learn_known_hosts(
&self.host,
self.port,
server_public_key,
)
.map_err(|e| {
HandlerError::HostKey(format!(
"failed to record new host key for {}:{}: {e}",
self.host, self.port
))
})?;
tracing::info!(
host = %self.host,
port = self.port,
"recorded new SFTP host key (accept-new policy)"
);
Ok(true)
}
Err(e) => Err(HandlerError::HostKey(format!(
"host key for {}:{} changed or is invalid: {e}",
self.host, self.port
))),
}
}
}
}
}
pub async fn connect(cfg: &SftpConnectionConfig) -> Result<SftpSession, FaucetError> {
let config = Arc::new(russh::client::Config::default());
let handler = ClientHandler {
policy: cfg.known_hosts.clone(),
host: cfg.host.clone(),
port: cfg.port,
};
let mut session = russh::client::connect(config, (cfg.host.as_str(), cfg.port), handler)
.await
.map_err(map_handler_err)?;
let authenticated = match &cfg.auth {
SftpAuth::Password { password } => session
.authenticate_password(&cfg.username, password)
.await
.map_err(map_ssh_err)?,
SftpAuth::PrivateKey { path, passphrase } => {
let key = russh::keys::load_secret_key(path, passphrase.as_deref()).map_err(|e| {
FaucetError::Auth(format!("failed to load SFTP private key '{path}': {e}"))
})?;
let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), None);
session
.authenticate_publickey(&cfg.username, key)
.await
.map_err(map_ssh_err)?
}
};
if !authenticated.success() {
return Err(FaucetError::Auth(format!(
"SFTP authentication failed for user '{}' on {}:{}",
cfg.username, cfg.host, cfg.port
)));
}
let channel = session.channel_open_session().await.map_err(map_ssh_err)?;
channel
.request_subsystem(true, "sftp")
.await
.map_err(map_ssh_err)?;
let sftp = SftpSession::new(channel.into_stream())
.await
.map_err(|e| FaucetError::Custom(format!("failed to start SFTP subsystem: {e}").into()))?;
Ok(sftp)
}
fn map_handler_err(e: HandlerError) -> FaucetError {
match e {
HandlerError::HostKey(m) => {
FaucetError::Auth(format!("SFTP host-key verification failed: {m}"))
}
HandlerError::Ssh(e) => FaucetError::Custom(format!("SFTP connection failed: {e}").into()),
}
}
fn map_ssh_err(e: russh::Error) -> FaucetError {
FaucetError::Custom(format!("SFTP SSH error: {e}").into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_port_is_22() {
let json = r#"{
"host": "example.com",
"username": "user",
"type": "password",
"config": { "password": "secret" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.port, DEFAULT_PORT);
}
#[test]
fn default_host_key_policy_is_accept_new() {
let json = r#"{
"host": "example.com",
"username": "user",
"type": "password",
"config": { "password": "secret" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
assert!(matches!(cfg.known_hosts, HostKeyPolicy::AcceptNew));
}
#[test]
fn password_auth_round_trips() {
let json = r#"{
"host": "h",
"port": 2222,
"username": "u",
"type": "password",
"config": { "password": "p" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.port, 2222);
match &cfg.auth {
SftpAuth::Password { password } => assert_eq!(password, "p"),
other => panic!("expected password auth, got {other:?}"),
}
let value = serde_json::to_value(&cfg).unwrap();
assert_eq!(value["type"], "password");
assert_eq!(value["config"]["password"], "p");
}
#[test]
fn private_key_auth_round_trips() {
let json = r#"{
"host": "h",
"username": "u",
"type": "private_key",
"config": { "path": "/home/u/.ssh/id_ed25519" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
match &cfg.auth {
SftpAuth::PrivateKey { path, passphrase } => {
assert_eq!(path, "/home/u/.ssh/id_ed25519");
assert!(passphrase.is_none());
}
other => panic!("expected private-key auth, got {other:?}"),
}
}
#[test]
fn strict_policy_round_trips_with_path() {
let json = r#"{
"host": "h",
"username": "u",
"type": "password",
"config": { "password": "p" },
"known_hosts": { "mode": "strict", "known_hosts_path": "/etc/known_hosts" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
match &cfg.known_hosts {
HostKeyPolicy::Strict { known_hosts_path } => {
assert_eq!(known_hosts_path.as_deref(), Some("/etc/known_hosts"));
}
other => panic!("expected strict policy, got {other:?}"),
}
}
#[test]
fn insecure_policy_round_trips() {
let json = r#"{
"host": "h",
"username": "u",
"type": "password",
"config": { "password": "p" },
"known_hosts": { "mode": "insecure" }
}"#;
let cfg: SftpConnectionConfig = serde_json::from_str(json).unwrap();
assert!(matches!(cfg.known_hosts, HostKeyPolicy::Insecure));
}
#[test]
fn debug_redacts_password() {
let cfg = SftpConnectionConfig::with_password("h", "u", "hunter2");
let dbg = format!("{cfg:?}");
assert!(!dbg.contains("hunter2"), "password leaked in Debug: {dbg}");
assert!(dbg.contains("<redacted>"));
}
#[test]
fn debug_redacts_passphrase() {
let auth = SftpAuth::PrivateKey {
path: "/k".into(),
passphrase: Some("topsecret".into()),
};
let dbg = format!("{auth:?}");
assert!(!dbg.contains("topsecret"), "passphrase leaked: {dbg}");
assert!(dbg.contains("/k"), "path should still be visible");
}
#[test]
fn config_schema_is_object() {
let schema = serde_json::to_value(schemars::schema_for!(SftpConnectionConfig)).unwrap();
assert!(schema.is_object());
}
}