use std::net::SocketAddr;
use std::process::Command;
use tracing::debug;
#[derive(Debug, thiserror::Error)]
pub enum SshServiceError {
#[error("Failed to execute SSH service check command: {source}")]
CommandExecutionFailed {
#[source]
source: std::io::Error,
},
}
pub type Result<T> = std::result::Result<T, SshServiceError>;
#[derive(Debug)]
pub struct SshServiceChecker {
connect_timeout: u16,
}
impl Default for SshServiceChecker {
fn default() -> Self {
Self::new()
}
}
impl SshServiceChecker {
#[must_use]
pub fn new() -> Self {
Self { connect_timeout: 5 }
}
#[must_use]
pub fn with_timeout(connect_timeout: u16) -> Self {
Self { connect_timeout }
}
pub fn is_service_available(&self, socket_addr: SocketAddr) -> Result<bool> {
debug!(
socket_addr = %socket_addr,
timeout = self.connect_timeout,
"Testing SSH service availability"
);
let host = socket_addr.ip().to_string();
let port = socket_addr.port();
let output = Command::new("ssh")
.args([
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
&format!("ConnectTimeout={}", self.connect_timeout),
"-o",
"BatchMode=yes", "-p",
&port.to_string(),
&format!("test@{host}"),
"echo",
"connectivity_test",
])
.output()
.map_err(|source| SshServiceError::CommandExecutionFailed { source })?;
match output.status.code() {
Some(0) => {
debug!(
socket_addr = %socket_addr,
"SSH service available (command succeeded)"
);
Ok(true)
}
Some(255) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Connection refused") || stderr.contains("No route to host") {
debug!(
socket_addr = %socket_addr,
error = %stderr.trim(),
"SSH service not available"
);
Ok(false)
} else {
debug!(
socket_addr = %socket_addr,
error = %stderr.trim(),
"SSH service available (authentication failed)"
);
Ok(true)
}
}
Some(exit_code) => {
debug!(
socket_addr = %socket_addr,
exit_code = exit_code,
"SSH service available (non-zero exit code)"
);
Ok(true)
}
None => {
Err(SshServiceError::CommandExecutionFailed {
source: std::io::Error::new(
std::io::ErrorKind::Interrupted,
"SSH process terminated by signal",
),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_create_ssh_service_checker_with_defaults() {
let checker = SshServiceChecker::new();
assert_eq!(checker.connect_timeout, 5);
}
#[test]
fn it_should_create_ssh_service_checker_with_custom_timeout() {
let checker = SshServiceChecker::with_timeout(10);
assert_eq!(checker.connect_timeout, 10);
}
#[test]
fn it_should_implement_default_trait() {
let checker = SshServiceChecker::default();
assert_eq!(checker.connect_timeout, 5);
}
#[test]
fn it_should_have_proper_error_display() {
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "ssh command not found");
let error = SshServiceError::CommandExecutionFailed { source: io_error };
assert!(error
.to_string()
.contains("Failed to execute SSH service check command"));
assert!(std::error::Error::source(&error).is_some());
}
#[test]
fn it_should_support_debug_formatting() {
let checker = SshServiceChecker::new();
let debug_str = format!("{checker:?}");
assert!(debug_str.contains("SshServiceChecker"));
assert!(debug_str.contains("connect_timeout"));
}
}