Skip to main content

torrust_tracker_deployer_lib/adapters/ssh/
service_checker.rs

1//! SSH Service Checker
2//!
3//! This module provides functionality to check if SSH service is available on a remote host
4//! without requiring authentication. It's designed for connectivity testing only - like a "ping"
5//! for SSH services to verify that the SSH daemon is running and accepting connections.
6//!
7//! ## Key Features
8//!
9//! - Pure connectivity testing without authentication
10//! - Minimal SSH command execution to test service availability
11//! - Distinguishes between "service not available" and "service available but auth failed"
12//! - Lightweight and focused on service discovery
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use std::net::{SocketAddr, IpAddr, Ipv4Addr};
18//! use torrust_tracker_deployer_lib::adapters::ssh::SshServiceChecker;
19//!
20//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! let checker = SshServiceChecker::new();
22//! let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 22);
23//! let is_available = checker.is_service_available(socket_addr)?;
24//! if is_available {
25//!     println!("SSH service is available");
26//! } else {
27//!     println!("SSH service is not available");
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use std::net::SocketAddr;
34use std::process::Command;
35use tracing::debug;
36
37/// SSH Service availability checker errors
38#[derive(Debug, thiserror::Error)]
39pub enum SshServiceError {
40    /// Command execution failed (e.g., ssh binary not found, process interrupted)
41    #[error("Failed to execute SSH service check command: {source}")]
42    CommandExecutionFailed {
43        #[source]
44        source: std::io::Error,
45    },
46}
47
48/// Result type for SSH service operations
49pub type Result<T> = std::result::Result<T, SshServiceError>;
50
51/// SSH Service Checker for testing service availability
52///
53/// This checker performs lightweight connectivity tests to determine if an SSH daemon
54/// is running and accepting connections on a given host and port. It does not attempt
55/// to authenticate or establish a working SSH session.
56///
57/// The checker uses minimal SSH commands with short timeouts and batch mode to quickly
58/// determine service availability without user interaction.
59#[derive(Debug)]
60pub struct SshServiceChecker {
61    /// Connection timeout in seconds for SSH attempts
62    connect_timeout: u16,
63}
64
65impl Default for SshServiceChecker {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl SshServiceChecker {
72    /// Create a new SSH service checker with default settings
73    ///
74    /// Default connection timeout is 5 seconds.
75    #[must_use]
76    pub fn new() -> Self {
77        Self { connect_timeout: 5 }
78    }
79
80    /// Create a new SSH service checker with custom connection timeout
81    ///
82    /// # Arguments
83    /// * `connect_timeout` - Timeout in seconds for connection attempts
84    #[must_use]
85    pub fn with_timeout(connect_timeout: u16) -> Self {
86        Self { connect_timeout }
87    }
88
89    /// Check if SSH service is available at the specified socket address
90    ///
91    /// This method attempts a minimal SSH connection to test service availability.
92    /// It distinguishes between:
93    /// - Service not available (connection refused, no route to host)
94    /// - Service available (authentication failures are considered as service available)
95    ///
96    /// # Arguments
97    /// * `socket_addr` - The socket address (IP and port) to test
98    ///
99    /// # Returns
100    /// * `Ok(true)` - SSH service is available and accepting connections
101    /// * `Ok(false)` - SSH service is not available or not reachable
102    /// * `Err(SshServiceError)` - Command execution error (e.g., ssh binary not found)
103    ///
104    /// # Errors
105    /// Returns an error if the SSH command cannot be executed (e.g., ssh binary not found
106    /// or process was terminated by signal).
107    pub fn is_service_available(&self, socket_addr: SocketAddr) -> Result<bool> {
108        debug!(
109            socket_addr = %socket_addr,
110            timeout = self.connect_timeout,
111            "Testing SSH service availability"
112        );
113
114        let host = socket_addr.ip().to_string();
115        let port = socket_addr.port();
116
117        let output = Command::new("ssh")
118            .args([
119                "-o",
120                "StrictHostKeyChecking=no",
121                "-o",
122                "UserKnownHostsFile=/dev/null",
123                "-o",
124                &format!("ConnectTimeout={}", self.connect_timeout),
125                "-o",
126                "BatchMode=yes", // Non-interactive mode
127                "-p",
128                &port.to_string(),
129                &format!("test@{host}"),
130                "echo",
131                "connectivity_test",
132            ])
133            .output()
134            .map_err(|source| SshServiceError::CommandExecutionFailed { source })?;
135
136        // Analyze the command result to determine service availability
137        match output.status.code() {
138            Some(0) => {
139                // SSH command succeeded - service is definitely available
140                debug!(
141                    socket_addr = %socket_addr,
142                    "SSH service available (command succeeded)"
143                );
144                Ok(true)
145            }
146            Some(255) => {
147                // Exit code 255 can indicate different scenarios
148                let stderr = String::from_utf8_lossy(&output.stderr);
149
150                if stderr.contains("Connection refused") || stderr.contains("No route to host") {
151                    // Service is not available or host is not reachable
152                    debug!(
153                        socket_addr = %socket_addr,
154                        error = %stderr.trim(),
155                        "SSH service not available"
156                    );
157                    Ok(false)
158                } else {
159                    // Authentication failed, permission denied, etc. - service is available
160                    debug!(
161                        socket_addr = %socket_addr,
162                        error = %stderr.trim(),
163                        "SSH service available (authentication failed)"
164                    );
165                    Ok(true)
166                }
167            }
168            Some(exit_code) => {
169                // Other non-zero exit codes typically indicate service is available
170                // but there are other issues (auth, command execution, etc.)
171                debug!(
172                    socket_addr = %socket_addr,
173                    exit_code = exit_code,
174                    "SSH service available (non-zero exit code)"
175                );
176                Ok(true)
177            }
178            None => {
179                // Process was terminated by signal - treat as command execution error
180                Err(SshServiceError::CommandExecutionFailed {
181                    source: std::io::Error::new(
182                        std::io::ErrorKind::Interrupted,
183                        "SSH process terminated by signal",
184                    ),
185                })
186            }
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn it_should_create_ssh_service_checker_with_defaults() {
197        let checker = SshServiceChecker::new();
198        assert_eq!(checker.connect_timeout, 5);
199    }
200
201    #[test]
202    fn it_should_create_ssh_service_checker_with_custom_timeout() {
203        let checker = SshServiceChecker::with_timeout(10);
204        assert_eq!(checker.connect_timeout, 10);
205    }
206
207    #[test]
208    fn it_should_implement_default_trait() {
209        let checker = SshServiceChecker::default();
210        assert_eq!(checker.connect_timeout, 5);
211    }
212
213    #[test]
214    fn it_should_have_proper_error_display() {
215        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "ssh command not found");
216        let error = SshServiceError::CommandExecutionFailed { source: io_error };
217
218        assert!(error
219            .to_string()
220            .contains("Failed to execute SSH service check command"));
221        assert!(std::error::Error::source(&error).is_some());
222    }
223
224    #[test]
225    fn it_should_support_debug_formatting() {
226        let checker = SshServiceChecker::new();
227        let debug_str = format!("{checker:?}");
228        assert!(debug_str.contains("SshServiceChecker"));
229        assert!(debug_str.contains("connect_timeout"));
230    }
231
232    // Note: We don't include integration tests that actually connect to SSH services
233    // as they would be flaky and depend on external services. The actual connectivity
234    // testing logic is documented through these unit tests and the implementation.
235}