Skip to content

Commit fda966f

Browse files
committed
refactor: refactor SSH wait functionality to use centralized SSH service checker
- Create new SshServiceChecker in src/shared/ssh/service_checker.rs for pure SSH service availability testing without authentication - Update SshWaitAction to use SshServiceChecker instead of direct SSH commands - Add service_checker module export to src/shared/ssh/mod.rs - Separate concerns between authenticated SSH operations (SshClient) and service availability checking (SshServiceChecker) - Maintain same external API and behavior while centralizing SSH connectivity logic - Add comprehensive unit tests and documentation for the new service checker
1 parent 1e30ad6 commit fda966f

File tree

3 files changed

+256
-67
lines changed

3 files changed

+256
-67
lines changed

src/e2e/containers/actions/ssh_wait.rs

Lines changed: 17 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use std::time::{Duration, Instant};
77
use tracing::{info, warn};
88

9+
use crate::shared::ssh::SshServiceChecker;
10+
911
/// Specific error types for SSH wait operations
1012
#[derive(Debug, thiserror::Error)]
1113
pub enum SshWaitError {
@@ -152,77 +154,25 @@ impl SshWaitAction {
152154
})
153155
}
154156

155-
/// Test SSH connection by attempting a simple SSH command
157+
/// Test SSH connection by checking if SSH service is available
156158
fn test_ssh_connection(host: &str, port: u16) -> Result<()> {
157-
use std::process::Command;
159+
let checker = SshServiceChecker::new();
158160

159-
let output = Command::new("ssh")
160-
.args([
161-
"-o",
162-
"StrictHostKeyChecking=no",
163-
"-o",
164-
"UserKnownHostsFile=/dev/null",
165-
"-o",
166-
"ConnectTimeout=5",
167-
"-o",
168-
"BatchMode=yes", // Non-interactive mode
169-
"-p",
170-
&port.to_string(),
171-
&format!("test@{host}"),
172-
"echo",
173-
"test",
174-
])
175-
.output()
176-
.map_err(|source| SshWaitError::SshConnectionTestFailed {
161+
match checker.is_service_available(host, port) {
162+
Ok(true) => Ok(()),
163+
Ok(false) => Err(SshWaitError::SshConnectionTestFailed {
177164
host: host.to_string(),
178165
port,
179-
source,
180-
})?;
181-
182-
// For SSH connectivity testing, we just need to know if the SSH server
183-
// is responding, even if authentication fails
184-
match output.status.code() {
185-
Some(0) => {
186-
// SSH command succeeded (unlikely in this test scenario, but good)
187-
Ok(())
188-
}
189-
Some(255) => {
190-
// Exit code 255 can mean either:
191-
// 1. Authentication failed (server reachable) - this is OK for connectivity test
192-
// 2. Connection refused (server not reachable) - this is what we want to catch
193-
194-
let stderr = String::from_utf8_lossy(&output.stderr);
195-
196-
if stderr.contains("Connection refused") || stderr.contains("No route to host") {
197-
// Server is not reachable
198-
Err(SshWaitError::SshConnectionTestFailed {
199-
host: host.to_string(),
200-
port,
201-
source: std::io::Error::new(
202-
std::io::ErrorKind::ConnectionRefused,
203-
"SSH server not reachable",
204-
),
205-
})
206-
} else {
207-
// Server is reachable (auth failed, but that's OK for connectivity test)
208-
Ok(())
209-
}
210-
}
211-
Some(_) => {
212-
// Other exit codes indicate server is reachable (auth issues, command issues, etc.)
213-
Ok(())
214-
}
215-
None => {
216-
// Process terminated by signal
217-
Err(SshWaitError::SshConnectionTestFailed {
218-
host: host.to_string(),
219-
port,
220-
source: std::io::Error::new(
221-
std::io::ErrorKind::Interrupted,
222-
"SSH process terminated by signal",
223-
),
224-
})
225-
}
166+
source: std::io::Error::new(
167+
std::io::ErrorKind::ConnectionRefused,
168+
"SSH server not reachable",
169+
),
170+
}),
171+
Err(e) => Err(SshWaitError::SshConnectionTestFailed {
172+
host: host.to_string(),
173+
port,
174+
source: std::io::Error::other(format!("SSH service check failed: {e}")),
175+
}),
226176
}
227177
}
228178
}

src/shared/ssh/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
//! - `client` - SSH client implementation for remote command execution
1010
//! - `connection` - SSH connection configuration and management
1111
//! - `credentials` - SSH authentication credentials and key management
12+
//! - `service_checker` - SSH service availability testing without authentication
1213
//!
1314
//! ## Key Features
1415
//!
1516
//! - Private key authentication with configurable credentials
1617
//! - Connection timeout and retry mechanisms
1718
//! - Secure remote command execution with error handling
19+
//! - SSH service availability checking for connectivity testing
1820
//! - Integration with deployment automation workflows
1921
//!
2022
//! The SSH wrapper is designed for automated deployment scenarios where
@@ -23,10 +25,12 @@
2325
pub mod client;
2426
pub mod connection;
2527
pub mod credentials;
28+
pub mod service_checker;
2629

2730
pub use client::SshClient;
2831
pub use connection::SshConnection;
2932
pub use credentials::SshCredentials;
33+
pub use service_checker::SshServiceChecker;
3034

3135
use thiserror::Error;
3236

src/shared/ssh/service_checker.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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 torrust_tracker_deploy::shared::ssh::SshServiceChecker;
18+
//!
19+
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
20+
//! let checker = SshServiceChecker::new();
21+
//! let is_available = checker.is_service_available("192.168.1.1", 22)?;
22+
//! if is_available {
23+
//! println!("SSH service is available");
24+
//! } else {
25+
//! println!("SSH service is not available");
26+
//! }
27+
//! # Ok(())
28+
//! # }
29+
//! ```
30+
31+
use std::process::Command;
32+
use tracing::debug;
33+
34+
/// SSH Service availability checker errors
35+
#[derive(Debug, thiserror::Error)]
36+
pub enum SshServiceError {
37+
/// Command execution failed (e.g., ssh binary not found, process interrupted)
38+
#[error("Failed to execute SSH service check command: {source}")]
39+
CommandExecutionFailed {
40+
#[source]
41+
source: std::io::Error,
42+
},
43+
}
44+
45+
/// Result type for SSH service operations
46+
pub type Result<T> = std::result::Result<T, SshServiceError>;
47+
48+
/// SSH Service Checker for testing service availability
49+
///
50+
/// This checker performs lightweight connectivity tests to determine if an SSH daemon
51+
/// is running and accepting connections on a given host and port. It does not attempt
52+
/// to authenticate or establish a working SSH session.
53+
///
54+
/// The checker uses minimal SSH commands with short timeouts and batch mode to quickly
55+
/// determine service availability without user interaction.
56+
#[derive(Debug)]
57+
pub struct SshServiceChecker {
58+
/// Connection timeout in seconds for SSH attempts
59+
connect_timeout: u16,
60+
}
61+
62+
impl Default for SshServiceChecker {
63+
fn default() -> Self {
64+
Self::new()
65+
}
66+
}
67+
68+
impl SshServiceChecker {
69+
/// Create a new SSH service checker with default settings
70+
///
71+
/// Default connection timeout is 5 seconds.
72+
#[must_use]
73+
pub fn new() -> Self {
74+
Self { connect_timeout: 5 }
75+
}
76+
77+
/// Create a new SSH service checker with custom connection timeout
78+
///
79+
/// # Arguments
80+
/// * `connect_timeout` - Timeout in seconds for connection attempts
81+
#[must_use]
82+
pub fn with_timeout(connect_timeout: u16) -> Self {
83+
Self { connect_timeout }
84+
}
85+
86+
/// Check if SSH service is available on the specified host and port
87+
///
88+
/// This method attempts a minimal SSH connection to test service availability.
89+
/// It distinguishes between:
90+
/// - Service not available (connection refused, no route to host)
91+
/// - Service available (authentication failures are considered as service available)
92+
///
93+
/// # Arguments
94+
/// * `host` - The hostname or IP address to test
95+
/// * `port` - The SSH port to test
96+
///
97+
/// # Returns
98+
/// * `Ok(true)` - SSH service is available and accepting connections
99+
/// * `Ok(false)` - SSH service is not available or not reachable
100+
/// * `Err(SshServiceError)` - Command execution error (e.g., ssh binary not found)
101+
///
102+
/// # Errors
103+
/// Returns an error if the SSH command cannot be executed (e.g., ssh binary not found
104+
/// or process was terminated by signal).
105+
pub fn is_service_available(&self, host: &str, port: u16) -> Result<bool> {
106+
debug!(
107+
host = host,
108+
port = port,
109+
timeout = self.connect_timeout,
110+
"Testing SSH service availability"
111+
);
112+
113+
let output = Command::new("ssh")
114+
.args([
115+
"-o",
116+
"StrictHostKeyChecking=no",
117+
"-o",
118+
"UserKnownHostsFile=/dev/null",
119+
"-o",
120+
&format!("ConnectTimeout={}", self.connect_timeout),
121+
"-o",
122+
"BatchMode=yes", // Non-interactive mode
123+
"-p",
124+
&port.to_string(),
125+
&format!("test@{host}"),
126+
"echo",
127+
"connectivity_test",
128+
])
129+
.output()
130+
.map_err(|source| SshServiceError::CommandExecutionFailed { source })?;
131+
132+
// Analyze the command result to determine service availability
133+
match output.status.code() {
134+
Some(0) => {
135+
// SSH command succeeded - service is definitely available
136+
debug!(
137+
host = host,
138+
port = port,
139+
"SSH service available (command succeeded)"
140+
);
141+
Ok(true)
142+
}
143+
Some(255) => {
144+
// Exit code 255 can indicate different scenarios
145+
let stderr = String::from_utf8_lossy(&output.stderr);
146+
147+
if stderr.contains("Connection refused") || stderr.contains("No route to host") {
148+
// Service is not available or host is not reachable
149+
debug!(
150+
host = host,
151+
port = port,
152+
error = %stderr.trim(),
153+
"SSH service not available"
154+
);
155+
Ok(false)
156+
} else {
157+
// Authentication failed, permission denied, etc. - service is available
158+
debug!(
159+
host = host,
160+
port = port,
161+
error = %stderr.trim(),
162+
"SSH service available (authentication failed)"
163+
);
164+
Ok(true)
165+
}
166+
}
167+
Some(exit_code) => {
168+
// Other non-zero exit codes typically indicate service is available
169+
// but there are other issues (auth, command execution, etc.)
170+
debug!(
171+
host = host,
172+
port = port,
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)]
192+
mod 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+
}

0 commit comments

Comments
 (0)