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}