1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3use std::sync::Arc;
32
33use faucet_core::FaucetError;
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37pub use russh_sftp::client::SftpSession;
38pub use russh_sftp::protocol::OpenFlags;
43
44pub const DEFAULT_PORT: u16 = 22;
46
47fn default_port() -> u16 {
48 DEFAULT_PORT
49}
50
51#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
57#[serde(tag = "mode", rename_all = "snake_case")]
58pub enum HostKeyPolicy {
59 Strict {
62 #[serde(default)]
65 known_hosts_path: Option<String>,
66 },
67 #[default]
71 AcceptNew,
72 Insecure,
76}
77
78#[derive(Clone, Serialize, Deserialize, JsonSchema)]
83#[serde(tag = "type", content = "config", rename_all = "snake_case")]
84pub enum SftpAuth {
85 Password {
87 password: String,
89 },
90 PrivateKey {
92 path: String,
94 #[serde(default)]
96 passphrase: Option<String>,
97 },
98}
99
100impl 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#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
119pub struct SftpConnectionConfig {
120 pub host: String,
122 #[serde(default = "default_port")]
124 pub port: u16,
125 pub username: String,
127 #[serde(flatten)]
129 pub auth: SftpAuth,
130 #[serde(default)]
132 pub known_hosts: HostKeyPolicy,
133}
134
135impl SftpConnectionConfig {
136 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 pub fn port(mut self, port: u16) -> Self {
155 self.port = port;
156 self
157 }
158
159 pub fn known_hosts(mut self, policy: HostKeyPolicy) -> Self {
161 self.known_hosts = policy;
162 self
163 }
164}
165
166#[derive(Debug)]
172enum HandlerError {
173 Ssh(russh::Error),
175 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
196struct 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
273pub 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 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 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}