torrust_tracker_deployer_lib/adapters/ssh/client.rs
1//! SSH client implementation for secure remote command execution
2//!
3//! This module provides the `SshClient` which handles SSH connections and remote
4//! command execution with predefined security settings optimized for automation.
5//!
6//! ## Key Features
7//!
8//! - Private key authentication with configurable credentials
9//! - Automated host key management (disabled strict checking for automation)
10//! - Connection timeout and retry mechanisms
11//! - Comprehensive error handling for network and authentication issues
12//! - Integration with the command execution framework
13//!
14//! The client is designed for automated deployment scenarios where security
15//! is important but strict host key checking would interfere with automation.
16
17use std::time::Duration;
18
19use tracing::{info, warn};
20
21use crate::shared::command::{CommandError, CommandExecutor};
22
23use super::{SshConfig, SshError};
24
25/// A specialized SSH client with predefined security settings
26///
27/// This client provides a secure SSH interface for connecting to remote hosts with:
28/// - Private key authentication
29/// - Disabled strict host key checking (for automation)
30/// - No known hosts file usage
31/// - Consistent connection settings
32///
33/// Uses `CommandExecutor` as a collaborator for actual command execution.
34pub struct SshClient {
35 ssh_config: SshConfig,
36 command_executor: CommandExecutor,
37}
38
39impl SshClient {
40 // ============================================================================
41 // PUBLIC API - Constructors
42 // ============================================================================
43
44 /// Creates a new `SshClient`
45 ///
46 /// # Arguments
47 ///
48 /// * `ssh_config` - SSH connection configuration containing credentials and host IP
49 #[must_use]
50 pub fn new(ssh_config: SshConfig) -> Self {
51 Self {
52 ssh_config,
53 command_executor: CommandExecutor::new(),
54 }
55 }
56
57 // ============================================================================
58 // PUBLIC API - Accessors
59 // ============================================================================
60
61 /// Get the SSH configuration
62 ///
63 /// Returns a reference to the SSH configuration used by this client.
64 #[must_use]
65 pub fn ssh_config(&self) -> &SshConfig {
66 &self.ssh_config
67 }
68
69 // ============================================================================
70 // PUBLIC API - Command Execution
71 // ============================================================================
72
73 /// Execute a command on a remote host via SSH
74 ///
75 /// # Arguments
76 ///
77 /// * `remote_command` - Command to execute on the remote host
78 ///
79 /// # Returns
80 ///
81 /// * `Ok(String)` - The stdout output if the command succeeds
82 /// * `Err(CommandError)` - Error describing what went wrong
83 ///
84 /// # Errors
85 ///
86 /// This function will return an error if:
87 /// * The SSH connection cannot be established
88 /// * The remote command execution fails with a non-zero exit code
89 pub fn execute(&self, remote_command: &str) -> Result<String, CommandError> {
90 self.execute_with_options(remote_command, &[])
91 }
92
93 /// Check if a command succeeds on a remote host (returns only status)
94 ///
95 /// # Arguments
96 ///
97 /// * `remote_command` - Command to execute on the remote host
98 ///
99 /// # Returns
100 ///
101 /// * `Ok(bool)` - true if command succeeded (exit code 0), false otherwise
102 /// * `Err(CommandError)` - Error if SSH connection could not be established
103 ///
104 /// # Errors
105 ///
106 /// This function will return an error if:
107 /// * The SSH connection cannot be established
108 pub fn check_command(&self, remote_command: &str) -> Result<bool, CommandError> {
109 self.check_command_with_options(remote_command, &[])
110 }
111
112 // ============================================================================
113 // PUBLIC API - Connectivity Testing
114 // ============================================================================
115
116 /// Test SSH connectivity to a host
117 ///
118 /// Uses the connection timeout configured in `SshConfig`.
119 ///
120 /// # Returns
121 ///
122 /// * `Ok(bool)` - true if SSH connection succeeds, false otherwise
123 /// * `Err(CommandError)` - Error if SSH command could not be started
124 ///
125 /// # Errors
126 ///
127 /// This function will return an error if:
128 /// * The SSH command could not be started
129 pub fn test_connectivity(&self) -> Result<bool, CommandError> {
130 self.check_command("echo 'SSH connected'")
131 }
132
133 /// Wait for SSH connectivity to be established with retry logic
134 ///
135 /// This method will repeatedly attempt to connect via SSH until successful
136 /// or the maximum number of attempts is reached. Progress is reported via
137 /// structured logging using the `tracing` crate.
138 ///
139 /// # Returns
140 ///
141 /// * `Ok(())` - SSH connectivity was successfully established
142 /// * `Err(SshError)` - SSH connectivity could not be established after all attempts
143 ///
144 /// # Errors
145 ///
146 /// This function will return an error if:
147 /// * SSH connectivity cannot be established after the configured maximum attempts
148 pub async fn wait_for_connectivity(&self) -> Result<(), SshError> {
149 info!(
150 operation = "ssh_connectivity",
151 host_ip = %self.ssh_config.host_ip(),
152 "Waiting for SSH connectivity"
153 );
154
155 let conn_config = &self.ssh_config.connection_config;
156 let max_attempts = conn_config.max_retry_attempts;
157 let timeout_seconds = conn_config.total_timeout_secs();
158 let mut attempt = 0;
159
160 while attempt < max_attempts {
161 match self.execute_with_options("echo 'SSH connected'", &[]) {
162 Ok(_) => {
163 info!(
164 operation = "ssh_connectivity",
165 host_ip = %self.ssh_config.host_ip(),
166 status = "success",
167 "SSH connectivity established"
168 );
169 return Ok(());
170 }
171 Err(CommandError::ExecutionFailed { ref stderr, .. }) => {
172 if (attempt + 1) % conn_config.retry_log_frequency == 0 {
173 info!(
174 operation = "ssh_connectivity",
175 host_ip = %self.ssh_config.host_ip(),
176 attempt = attempt + 1,
177 max_attempts = max_attempts,
178 reason = %stderr,
179 "Still waiting for SSH connectivity"
180 );
181 }
182 tokio::time::sleep(Duration::from_secs(u64::from(
183 conn_config.retry_interval_secs,
184 )))
185 .await;
186 attempt += 1;
187 }
188 Err(e) => {
189 return Err(SshError::CommandFailed { source: e });
190 }
191 }
192 }
193
194 Err(SshError::ConnectivityTimeout {
195 host_ip: self.ssh_config.host_ip().to_string(),
196 attempts: max_attempts,
197 timeout_seconds,
198 })
199 }
200
201 // ============================================================================
202 // PRIVATE - Helper Methods
203 // ============================================================================
204
205 /// Build default SSH options for automation
206 ///
207 /// Returns a map of default SSH option keys to their values:
208 /// - `StrictHostKeyChecking`: `no` (disable host key verification)
209 /// - `UserKnownHostsFile`: `/dev/null` (ignore known hosts file)
210 /// - `ConnectTimeout`: configured timeout (prevents hanging)
211 /// - `IdentitiesOnly`: `yes` (only use the configured key, ignore SSH agent)
212 ///
213 /// These defaults ensure reliable automation but can be overridden by
214 /// user-provided options in `additional_options`.
215 fn build_default_ssh_options(&self) -> std::collections::HashMap<String, String> {
216 let mut defaults = std::collections::HashMap::new();
217 defaults.insert("StrictHostKeyChecking".to_string(), "no".to_string());
218 defaults.insert("UserKnownHostsFile".to_string(), "/dev/null".to_string());
219 defaults.insert(
220 "ConnectTimeout".to_string(),
221 self.ssh_config
222 .connection_config
223 .connect_timeout_secs
224 .to_string(),
225 );
226 // Only use the explicitly configured identity file, ignoring any keys
227 // loaded in the SSH agent. Without this, SSH may exhaust the server's
228 // MaxAuthTries limit by trying agent keys before the configured key,
229 // causing "Too many authentication failures" on every attempt.
230 defaults.insert("IdentitiesOnly".to_string(), "yes".to_string());
231 defaults
232 }
233
234 /// Extract SSH option key from an option string
235 ///
236 /// Parses option strings in formats like:
237 /// - `"Key=value"` → `"Key"`
238 /// - `"Key"` → `"Key"`
239 ///
240 /// Returns `None` if the option string is empty or malformed.
241 fn extract_option_key(option: &str) -> Option<String> {
242 option.split('=').next().map(|s| s.trim().to_string())
243 }
244
245 /// Build SSH arguments for a connection
246 ///
247 /// Constructs the complete SSH command arguments including:
248 /// 1. Authentication credentials (private key)
249 /// 2. User-provided additional options (take precedence)
250 /// 3. Default options (only if not overridden by user)
251 /// 4. Connection details (port, host)
252 /// 5. Remote command to execute
253 ///
254 /// ## Option Override Behavior
255 ///
256 /// User-provided options in `additional_options` take precedence over defaults:
257 /// - If a user provides `StrictHostKeyChecking=yes`, it will override the default `no`
258 /// - If a user provides `ConnectTimeout=30`, it will override the configured default
259 /// - Default options are only added if the user hasn't provided them
260 ///
261 /// This allows users full control while providing sensible defaults for automation.
262 fn build_ssh_args(&self, remote_command: &str, additional_options: &[&str]) -> Vec<String> {
263 let mut args = vec![
264 // Specify the private key file for authentication
265 "-i".to_string(),
266 self.ssh_config
267 .ssh_priv_key_path()
268 .to_string_lossy()
269 .to_string(),
270 ];
271
272 // Build default options map
273 let mut defaults = self.build_default_ssh_options();
274
275 // Specify the SSH port to connect to
276 args.push("-p".to_string());
277 args.push(self.ssh_config.ssh_port().to_string());
278
279 // Add user-provided options FIRST (they take precedence)
280 // and remove them from defaults so we don't add them twice
281 for option in additional_options {
282 args.push("-o".to_string());
283 args.push((*option).to_string());
284
285 // Remove this option key from defaults to prevent duplication
286 if let Some(key) = Self::extract_option_key(option) {
287 defaults.remove(&key);
288 }
289 }
290
291 // Add remaining default options (those not overridden by user)
292 for (key, value) in defaults {
293 args.push("-o".to_string());
294 args.push(format!("{key}={value}"));
295 }
296
297 // SSH target: username@hostname
298 args.push(format!(
299 "{}@{}",
300 self.ssh_config.ssh_username(),
301 self.ssh_config.host_ip()
302 ));
303
304 // Remote command to execute
305 args.push(remote_command.to_string());
306
307 args
308 }
309
310 /// Execute a command with additional SSH options
311 ///
312 /// This method allows passing custom SSH options for specific commands,
313 /// useful for advanced scenarios like connection keep-alive or custom timeouts.
314 ///
315 /// # Arguments
316 ///
317 /// * `remote_command` - Command to execute on the remote host
318 /// * `additional_options` - SSH options (e.g., `["ServerAliveInterval=60"]`)
319 ///
320 /// # Examples
321 ///
322 /// ```no_run
323 /// use torrust_tracker_deployer_lib::adapters::ssh::{SshClient, SshConfig, SshCredentials};
324 /// use torrust_tracker_deployer_lib::shared::Username;
325 /// use std::path::PathBuf;
326 /// use std::net::{IpAddr, Ipv4Addr};
327 ///
328 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
329 /// let credentials = SshCredentials::new(
330 /// PathBuf::from("/path/to/key"),
331 /// PathBuf::from("/path/to/key.pub"),
332 /// Username::new("user")?,
333 /// );
334 /// let config = SshConfig::with_default_port(
335 /// credentials,
336 /// IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))
337 /// );
338 /// let client = SshClient::new(config);
339 ///
340 /// // Keep connection alive during long-running command
341 /// let output = client.execute_with_options(
342 /// "long_running_task",
343 /// &["ServerAliveInterval=60", "ServerAliveCountMax=3"]
344 /// )?;
345 ///
346 /// // Use custom connection timeout for specific command
347 /// let output = client.execute_with_options(
348 /// "quick_check",
349 /// &["ConnectTimeout=2"]
350 /// )?;
351 /// # Ok(())
352 /// # }
353 /// ```
354 ///
355 /// # Errors
356 ///
357 /// Returns `CommandError::ExecutionFailed` if the command exits with non-zero status,
358 /// or `CommandError::IoError` if SSH execution fails.
359 pub fn execute_with_options(
360 &self,
361 remote_command: &str,
362 additional_options: &[&str],
363 ) -> Result<String, CommandError> {
364 let args = self.build_ssh_args(remote_command, additional_options);
365 let args_str: Vec<&str> = args.iter().map(std::string::String::as_str).collect();
366
367 let result = self.command_executor.run_command("ssh", &args_str, None)?;
368
369 // Process stderr for SSH warnings and log them
370 self.process_ssh_warnings(&result.stderr);
371
372 Ok(result.stdout)
373 }
374
375 /// Check if a command succeeds with additional SSH options
376 ///
377 /// Wrapper around [`execute_with_options`] that returns `true` if the command
378 /// exits with code 0, `false` otherwise. Ideal for service checks and validation.
379 ///
380 /// # Arguments
381 ///
382 /// * `remote_command` - Command to execute on the remote host
383 /// * `additional_options` - SSH options (e.g., `["ConnectTimeout=2"]`)
384 ///
385 /// # Examples
386 ///
387 /// ```no_run
388 /// use torrust_tracker_deployer_lib::adapters::ssh::{SshClient, SshConfig, SshCredentials};
389 /// use torrust_tracker_deployer_lib::shared::Username;
390 /// use std::path::PathBuf;
391 /// use std::net::{IpAddr, Ipv4Addr};
392 ///
393 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
394 /// let credentials = SshCredentials::new(
395 /// PathBuf::from("/path/to/key"),
396 /// PathBuf::from("/path/to/key.pub"),
397 /// Username::new("user")?,
398 /// );
399 /// let config = SshConfig::with_default_port(
400 /// credentials,
401 /// IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))
402 /// );
403 /// let client = SshClient::new(config);
404 ///
405 /// // Quick service check with short timeout
406 /// let is_running = client.check_command_with_options(
407 /// "systemctl is-active myservice",
408 /// &["ConnectTimeout=2"]
409 /// )?;
410 ///
411 /// if is_running {
412 /// println!("Service is running");
413 /// }
414 /// # Ok(())
415 /// # }
416 /// ```
417 ///
418 /// # Errors
419 ///
420 /// Returns `CommandError::IoError` if SSH connection fails.
421 /// Command failures (non-zero exit) return `Ok(false)`, not an error.
422 ///
423 /// [`execute_with_options`]: Self::execute_with_options
424 pub fn check_command_with_options(
425 &self,
426 remote_command: &str,
427 additional_options: &[&str],
428 ) -> Result<bool, CommandError> {
429 match self.execute_with_options(remote_command, additional_options) {
430 Ok(_) => Ok(true),
431 Err(CommandError::ExecutionFailed { .. }) => Ok(false),
432 Err(other) => Err(other),
433 }
434 }
435
436 /// Process SSH stderr output to detect and log warnings
437 ///
438 /// SSH writes various informational messages to stderr, including host key
439 /// warnings. This method detects these warnings and logs them appropriately
440 /// using the tracing framework so they are visible to users at warn level.
441 ///
442 /// # Arguments
443 ///
444 /// * `stderr` - The stderr output from the SSH command
445 fn process_ssh_warnings(&self, stderr: &str) {
446 if stderr.trim().is_empty() {
447 return;
448 }
449
450 // Split stderr into lines and check each line for warnings
451 for line in stderr.lines() {
452 let trimmed_line = line.trim();
453 if trimmed_line.starts_with("Warning:") {
454 warn!(
455 operation = "ssh_warning",
456 host_ip = %self.ssh_config.host_ip(),
457 message = %trimmed_line,
458 "SSH warning detected"
459 );
460 }
461 }
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::super::SshCredentials;
468 use super::*;
469 use std::fs;
470 use std::net::{IpAddr, Ipv4Addr};
471 use tempfile::TempDir;
472
473 use crate::shared::Username;
474
475 /// Helper to create test SSH credentials with temporary key files
476 ///
477 /// Creates a temporary directory with actual (fake) SSH key files for testing.
478 /// The temporary directory is automatically cleaned up when the returned `TempDir` is dropped.
479 ///
480 /// # Returns
481 ///
482 /// A tuple of (`TempDir`, `SshCredentials`) where:
483 /// - `TempDir` must be kept alive to prevent cleanup during the test
484 /// - `SshCredentials` contains paths to the temporary key files
485 fn create_test_ssh_credentials() -> (TempDir, SshCredentials) {
486 let temp_dir =
487 TempDir::new().expect("Failed to create temp directory for SSH key test files");
488
489 let priv_key_path = temp_dir.path().join("test_key");
490 let pub_key_path = temp_dir.path().join("test_key.pub");
491
492 // Create actual (empty) key files for realism
493 fs::write(&priv_key_path, "fake private key content")
494 .expect("Failed to write test private key");
495 fs::write(&pub_key_path, "fake public key content")
496 .expect("Failed to write test public key");
497
498 let credentials = SshCredentials::new(
499 priv_key_path,
500 pub_key_path,
501 Username::new("testuser").unwrap(),
502 );
503
504 (temp_dir, credentials)
505 }
506
507 #[test]
508 fn it_should_create_ssh_client_with_valid_parameters() {
509 // Arrange
510 let (_temp_dir, credentials) = create_test_ssh_credentials();
511 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
512 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
513
514 // Act
515 let ssh_client = SshClient::new(ssh_config);
516
517 // Assert
518 assert!(ssh_client.ssh_config.ssh_priv_key_path().exists());
519 assert!(ssh_client.ssh_config.ssh_pub_key_path().exists());
520 assert_eq!(ssh_client.ssh_config.ssh_username(), "testuser");
521 assert_eq!(ssh_client.ssh_config.host_ip(), host_ip);
522 // Note: verbose is now encapsulated in the CommandExecutor collaborator
523
524 // TempDir automatically cleans up when dropped
525 }
526
527 #[test]
528 fn it_should_create_ssh_client_with_connection_details() {
529 // Arrange
530 let (_temp_dir, credentials) = create_test_ssh_credentials();
531 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
532 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
533
534 // Act
535 let ssh_client = SshClient::new(ssh_config);
536
537 // Assert
538 assert!(ssh_client.ssh_config.ssh_priv_key_path().exists());
539 assert!(ssh_client.ssh_config.ssh_pub_key_path().exists());
540 assert_eq!(ssh_client.ssh_config.ssh_username(), "testuser");
541 assert_eq!(ssh_client.ssh_config.host_ip(), host_ip);
542 // Note: logging is now handled by the tracing crate via CommandExecutor
543
544 // TempDir automatically cleans up when dropped
545 }
546
547 #[test]
548 fn it_should_detect_ssh_warnings_in_stderr() {
549 // Arrange
550 let (_temp_dir, credentials) = create_test_ssh_credentials();
551 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
552 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
553 let ssh_client = SshClient::new(ssh_config);
554
555 // Test stderr with SSH warning
556 let stderr_with_warning =
557 "Warning: Permanently added '10.140.190.144' (ED25519) to the list of known hosts.";
558
559 // This test verifies the method exists and processes warnings correctly
560 // In a real scenario, this would trigger tracing::warn! which would be captured
561 // by a tracing subscriber in integration tests
562 ssh_client.process_ssh_warnings(stderr_with_warning);
563
564 // Test stderr without warning
565 let stderr_without_warning = "Some other output";
566 ssh_client.process_ssh_warnings(stderr_without_warning);
567
568 // Test empty stderr
569 ssh_client.process_ssh_warnings("");
570
571 // TempDir automatically cleans up when dropped
572 }
573
574 #[test]
575 fn it_should_build_default_ssh_options_as_hashmap() {
576 // Arrange
577 let (_temp_dir, credentials) = create_test_ssh_credentials();
578 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
579 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
580 let expected_timeout = ssh_config.connection_config.connect_timeout_secs;
581 let ssh_client = SshClient::new(ssh_config);
582
583 // Act
584 let default_options = ssh_client.build_default_ssh_options();
585
586 // Assert: Should contain 4 key-value pairs
587 assert_eq!(default_options.len(), 4);
588
589 // Verify expected keys and values
590 assert_eq!(
591 default_options.get("StrictHostKeyChecking"),
592 Some(&"no".to_string())
593 );
594 assert_eq!(
595 default_options.get("UserKnownHostsFile"),
596 Some(&"/dev/null".to_string())
597 );
598 assert_eq!(
599 default_options.get("ConnectTimeout"),
600 Some(&expected_timeout.to_string())
601 );
602 assert_eq!(
603 default_options.get("IdentitiesOnly"),
604 Some(&"yes".to_string())
605 );
606 }
607
608 #[test]
609 fn it_should_build_ssh_args_with_user_options_before_defaults() {
610 // Arrange
611 let (_temp_dir, credentials) = create_test_ssh_credentials();
612 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
613 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
614 let ssh_client = SshClient::new(ssh_config);
615
616 // Act
617 let args = ssh_client.build_ssh_args("echo test", &["ServerAliveInterval=60"]);
618
619 // Assert: User options should appear before default options
620 let args_string = args.join(" ");
621
622 // Find positions of key options
623 let server_alive_pos = args
624 .iter()
625 .position(|s| s == "ServerAliveInterval=60")
626 .expect("ServerAliveInterval should be present");
627
628 let strict_pos = args
629 .iter()
630 .position(|s| s == "StrictHostKeyChecking=no")
631 .expect("StrictHostKeyChecking should be present");
632
633 // User option should come before default option (SSH uses first-occurrence-wins)
634 assert!(
635 server_alive_pos < strict_pos,
636 "User-provided options should appear before defaults for first-occurrence-wins precedence"
637 );
638
639 // Verify command structure
640 assert!(args_string.contains("-i")); // Private key
641 assert!(args_string.contains("StrictHostKeyChecking=no")); // Default option
642 assert!(args_string.contains("ServerAliveInterval=60")); // User option
643 assert!(args_string.contains("-p")); // Port
644 assert!(args_string.contains("testuser@")); // Username
645 assert!(args_string.contains("echo test")); // Command
646 }
647
648 #[test]
649 fn it_should_allow_users_to_override_default_options() {
650 // Arrange
651 let (_temp_dir, credentials) = create_test_ssh_credentials();
652 let host_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
653 let ssh_config = SshConfig::with_default_port(credentials, host_ip);
654 let ssh_client = SshClient::new(ssh_config);
655
656 // Act: Override default StrictHostKeyChecking
657 let args = ssh_client.build_ssh_args("echo test", &["StrictHostKeyChecking=yes"]);
658
659 // Assert: Only user-provided option should be present (not the default)
660 let strict_yes_count = args
661 .iter()
662 .filter(|s| *s == "StrictHostKeyChecking=yes")
663 .count();
664 let strict_no_count = args
665 .iter()
666 .filter(|s| *s == "StrictHostKeyChecking=no")
667 .count();
668
669 assert_eq!(
670 strict_yes_count, 1,
671 "User-provided StrictHostKeyChecking=yes should be present"
672 );
673 assert_eq!(
674 strict_no_count, 0,
675 "Default StrictHostKeyChecking=no should be excluded when user provides override"
676 );
677 }
678}