Skip to main content

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}