use super::core::SshClient;
use crate::jump::{JumpHostChain, parse_jump_hosts};
use crate::security::Password;
use crate::ssh::known_hosts::StrictHostKeyChecking;
use crate::ssh::tokio_client::{
AuthMethod, Client, SshConnectionConfig, SshConnectionConfigResolver,
};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
fn connect_error_message(e: &crate::ssh::tokio_client::Error) -> Option<String> {
match e {
crate::ssh::tokio_client::Error::KeyAuthFailed => {
Some("The private key was rejected by the server".to_string())
}
crate::ssh::tokio_client::Error::ServerCheckFailed => Some(
"Host key verification failed: the server's host key was not recognized or has changed"
.to_string(),
),
crate::ssh::tokio_client::Error::HostKeyChanged { host, port, .. } => {
let entry = crate::ssh::tokio_client::host_verification::known_hosts_entry_name(
host, *port,
);
Some(format!(
"Possible man-in-the-middle attack: verify the server's new key out of band, or remove the old entry with 'ssh-keygen -R \"{entry}\"' if the change is expected"
))
}
crate::ssh::tokio_client::Error::HostKeyRevoked { .. } => Some(
"The offered host key matches a key explicitly revoked in known_hosts; do not connect unless you can independently verify the server's identity out of band"
.to_string(),
),
crate::ssh::tokio_client::Error::SshError(russh::Error::UnknownKey) => Some(
"The host is not in known_hosts and strict host key checking is enabled; connect once with '--strict-host-key-checking accept-new' or add the key manually"
.to_string(),
),
crate::ssh::tokio_client::Error::KeyInvalid(_) => {
Some("Check the key file format and passphrase".to_string())
}
crate::ssh::tokio_client::Error::AgentConnectionFailed => {
Some("Ensure SSH_AUTH_SOCK is set and the agent is running".to_string())
}
crate::ssh::tokio_client::Error::AgentNoIdentities => {
Some("Add your key to the agent using 'ssh-add'".to_string())
}
_ => None,
}
}
impl SshClient {
pub(super) async fn determine_auth_method(
&self,
key_path: Option<&Path>,
use_agent: bool,
use_password: bool,
#[cfg(target_os = "macos")] use_keychain: bool,
pre_collected_password: Option<Arc<Password>>,
) -> Result<AuthMethod> {
let mut auth_ctx =
crate::ssh::auth::AuthContext::new(self.username.clone(), self.host.clone())
.with_context(|| {
format!("Invalid credentials for {}@{}", self.username, self.host)
})?;
if let Some(path) = key_path {
auth_ctx = auth_ctx
.with_key_path(Some(path.to_path_buf()))
.with_context(|| format!("Invalid SSH key path: {path:?}"))?;
}
auth_ctx = auth_ctx
.with_agent(use_agent)
.with_password(use_password)
.with_pre_collected_password(pre_collected_password);
#[cfg(target_os = "macos")]
{
auth_ctx = auth_ctx.with_keychain(use_keychain);
}
auth_ctx.determine_method().await
}
pub(super) async fn connect_direct(
&self,
auth_method: &AuthMethod,
strict_mode: StrictHostKeyChecking,
connect_timeout_seconds: Option<u64>,
ssh_connection_config: Option<&SshConnectionConfig>,
) -> Result<Client> {
const RATE_LIMIT_DELAY: Duration = Duration::from_millis(100);
tokio::time::sleep(RATE_LIMIT_DELAY).await;
let start_time = std::time::Instant::now();
let addr = (self.host.as_str(), self.port);
let check_method = crate::ssh::known_hosts::get_check_method(strict_mode);
let connect_timeout =
Duration::from_secs(connect_timeout_seconds.unwrap_or(SSH_CONNECT_TIMEOUT_SECS));
let default_conn_cfg;
let conn_cfg = match ssh_connection_config {
Some(c) => c,
None => {
default_conn_cfg = SshConnectionConfig::default();
&default_conn_cfg
}
};
let result = match tokio::time::timeout(
connect_timeout,
Client::connect_with_ssh_config(
addr,
&self.username,
auth_method.clone(),
check_method,
conn_cfg,
),
)
.await
{
Ok(Ok(client)) => Ok(client),
Ok(Err(e)) => {
match connect_error_message(&e) {
Some(error_msg) => Err(anyhow::Error::new(e).context(error_msg)),
None => Err(anyhow::Error::new(e)),
}
}
Err(_) => Err(anyhow::anyhow!(
"Connection timeout after {} seconds. \
Please check if the host is reachable and SSH service is running.",
connect_timeout.as_secs()
)),
};
const MIN_AUTH_DURATION: Duration = Duration::from_millis(500);
let elapsed = start_time.elapsed();
if elapsed < MIN_AUTH_DURATION {
tokio::time::sleep(MIN_AUTH_DURATION - elapsed).await;
}
result
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn connect_via_jump_hosts(
&self,
jump_hosts: &[crate::jump::parser::JumpHost],
auth_method: &AuthMethod,
strict_mode: StrictHostKeyChecking,
key_path: Option<&Path>,
use_agent: bool,
use_password: bool,
connect_timeout_seconds: Option<u64>,
ssh_connection_config: Option<&SshConnectionConfig>,
ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
pre_collected_password: Option<Arc<Password>>,
) -> Result<Client> {
let connect_timeout =
Duration::from_secs(connect_timeout_seconds.unwrap_or(SSH_CONNECT_TIMEOUT_SECS));
let mut chain = JumpHostChain::new(jump_hosts.to_vec())
.with_connect_timeout(connect_timeout)
.with_command_timeout(Duration::from_secs(300))
.with_ssh_password(pre_collected_password);
if let Some(cfg) = ssh_connection_config {
chain = chain.with_ssh_connection_config(cfg.clone());
}
if let Some(resolver) = ssh_connection_config_resolver {
chain = chain.with_ssh_connection_config_resolver(resolver.clone());
}
let connection = chain
.connect(
&self.host,
self.port,
&self.username,
auth_method.clone(),
key_path,
Some(strict_mode),
use_agent,
use_password,
)
.await
.with_context(|| {
format!(
"Failed to establish jump host connection to {}:{}",
self.host, self.port
)
})?;
tracing::info!(
"Jump host connection established: {}",
connection.jump_info.path_description()
);
Ok(connection.client)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn establish_connection(
&self,
auth_method: &AuthMethod,
strict_mode: StrictHostKeyChecking,
jump_hosts_spec: Option<&str>,
key_path: Option<&Path>,
use_agent: bool,
use_password: bool,
connect_timeout_seconds: Option<u64>,
ssh_connection_config: Option<&SshConnectionConfig>,
ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
pre_collected_password: Option<Arc<Password>>,
) -> Result<Client> {
if let Some(jump_spec) = jump_hosts_spec {
let jump_hosts = parse_jump_hosts(jump_spec).with_context(|| {
format!("Failed to parse jump host specification: '{jump_spec}'")
})?;
if jump_hosts.is_empty() {
tracing::debug!("No valid jump hosts found, using direct connection");
self.connect_direct(
auth_method,
strict_mode,
connect_timeout_seconds,
ssh_connection_config,
)
.await
} else {
tracing::info!(
"Connecting to {}:{} via {} jump host(s): {}",
self.host,
self.port,
jump_hosts.len(),
jump_hosts
.iter()
.map(|j| j.to_string())
.collect::<Vec<_>>()
.join(" -> ")
);
self.connect_via_jump_hosts(
&jump_hosts,
auth_method,
strict_mode,
key_path,
use_agent,
use_password,
connect_timeout_seconds,
ssh_connection_config,
ssh_connection_config_resolver,
pre_collected_password,
)
.await
}
} else {
tracing::debug!("Using direct connection (no jump hosts)");
self.connect_direct(
auth_method,
strict_mode,
connect_timeout_seconds,
ssh_connection_config,
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::EnvGuard;
use serial_test::serial;
use tempfile::TempDir;
#[tokio::test]
async fn test_determine_auth_method_with_key() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("test_key");
std::fs::write(&key_path, "fake key content").unwrap();
let client = SshClient::new("test.com".to_string(), 22, "user".to_string());
let auth = client
.determine_auth_method(
Some(&key_path),
false,
false,
#[cfg(target_os = "macos")]
false,
None,
)
.await
.unwrap();
match auth {
AuthMethod::PrivateKeyFile { key_file_path, .. } => {
assert!(key_file_path.is_absolute());
}
_ => panic!("Expected PrivateKeyFile auth method"),
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
#[serial]
async fn test_determine_auth_method_with_agent() {
use std::os::unix::net::UnixListener;
let temp_dir = TempDir::new().unwrap();
let socket_path = temp_dir.path().join("ssh-agent.sock");
let _listener = UnixListener::bind(&socket_path).unwrap();
let ssh_dir = temp_dir.path().join(".ssh");
std::fs::create_dir_all(&ssh_dir).unwrap();
let key_content =
"-----BEGIN PRIVATE KEY-----\nfake key content\n-----END PRIVATE KEY-----";
std::fs::write(ssh_dir.join("id_rsa"), key_content).unwrap();
let _sock = EnvGuard::set("SSH_AUTH_SOCK", socket_path.to_str().unwrap());
let _home = EnvGuard::set("HOME", temp_dir.path());
let client = SshClient::new("test.com".to_string(), 22, "user".to_string());
let auth = client
.determine_auth_method(
None,
true,
false,
#[cfg(target_os = "macos")]
false,
None,
)
.await
.unwrap();
match auth {
AuthMethod::Agent => {
}
AuthMethod::PrivateKeyFile { .. } => {
}
_ => panic!("Expected Agent or PrivateKeyFile auth method"),
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[serial]
async fn test_determine_auth_method_with_agent() {
use std::os::unix::net::UnixListener;
let temp_dir = TempDir::new().unwrap();
let socket_path = temp_dir.path().join("ssh-agent.sock");
let _listener = UnixListener::bind(&socket_path).unwrap();
let ssh_dir = temp_dir.path().join(".ssh");
std::fs::create_dir_all(&ssh_dir).unwrap();
let key_content =
"-----BEGIN PRIVATE KEY-----\nfake key content\n-----END PRIVATE KEY-----";
std::fs::write(ssh_dir.join("id_rsa"), key_content).unwrap();
let _sock = EnvGuard::set("SSH_AUTH_SOCK", socket_path.to_str().unwrap());
let _home = EnvGuard::set("HOME", temp_dir.path());
let client = SshClient::new("test.com".to_string(), 22, "user".to_string());
let auth = client
.determine_auth_method(None, true, false, None)
.await
.unwrap();
match auth {
AuthMethod::Agent => {
}
AuthMethod::PrivateKeyFile { .. } => {
}
_ => panic!("Expected Agent or PrivateKeyFile auth method"),
}
}
#[test]
fn test_determine_auth_method_with_password() {
let _client = SshClient::new("test.com".to_string(), 22, "user".to_string());
}
#[tokio::test]
#[serial]
async fn test_determine_auth_method_fallback_to_default() {
let temp_dir = TempDir::new().unwrap();
let ssh_dir = temp_dir.path().join(".ssh");
std::fs::create_dir_all(&ssh_dir).unwrap();
let default_key = ssh_dir.join("id_rsa");
std::fs::write(&default_key, "fake key").unwrap();
let _home = EnvGuard::set("HOME", temp_dir.path().to_str().unwrap());
let _sock = EnvGuard::remove("SSH_AUTH_SOCK");
let client = SshClient::new("test.com".to_string(), 22, "user".to_string());
let auth = client
.determine_auth_method(
None,
false,
false,
#[cfg(target_os = "macos")]
false,
None,
)
.await
.unwrap();
match auth {
AuthMethod::PrivateKeyFile { key_file_path, .. } => {
assert!(key_file_path.is_absolute());
}
_ => panic!("Expected PrivateKeyFile auth method"),
}
}
#[test]
fn test_connect_error_ordering_puts_friendly_message_first() {
let e = crate::ssh::tokio_client::Error::KeyAuthFailed;
let cause_text = e.to_string();
let error_msg = connect_error_message(&e).expect("KeyAuthFailed adds guidance");
let err = anyhow::Error::new(e).context(error_msg);
let rendered = format!("{err:#}");
assert_eq!(
rendered, "The private key was rejected by the server: Key authentication failed",
"expected friendly message first, then the cause"
);
let friendly_end = rendered
.find("The private key was rejected by the server")
.unwrap()
+ "The private key was rejected by the server".len();
assert!(
rendered[friendly_end..].contains(&cause_text),
"expected the cause to appear after the friendly message, got: {rendered}"
);
}
#[test]
fn test_connect_error_message_omits_context_that_would_repeat_cause() {
for e in [
crate::ssh::tokio_client::Error::PasswordWrong,
crate::ssh::tokio_client::Error::AgentAuthenticationFailed,
crate::ssh::tokio_client::Error::CommandDidntExit,
] {
let cause_text = e.to_string();
assert!(
connect_error_message(&e).is_none(),
"{cause_text} should not get a context layer"
);
let rendered = format!("{:#}", anyhow::Error::new(e));
assert_eq!(rendered, cause_text);
assert_eq!(
rendered.matches(cause_text.as_str()).count(),
1,
"cause text should appear exactly once, got: {rendered}"
);
}
}
#[test]
fn test_connect_error_message_does_not_duplicate_inner_key_error() {
let key_err = russh::keys::Error::KeyIsCorrupt;
let inner_text = key_err.to_string();
let e = crate::ssh::tokio_client::Error::KeyInvalid(key_err);
let error_msg = connect_error_message(&e).expect("KeyInvalid adds guidance");
assert!(
!error_msg.contains(&inner_text),
"context must not echo the inner key error, got: {error_msg}"
);
let rendered = format!("{:#}", anyhow::Error::new(e).context(error_msg));
assert_eq!(
rendered.matches(inner_text.as_str()).count(),
1,
"inner key error should appear exactly once, got: {rendered}"
);
}
#[test]
fn test_connect_error_messages_have_no_trailing_period() {
for e in [
crate::ssh::tokio_client::Error::KeyAuthFailed,
crate::ssh::tokio_client::Error::ServerCheckFailed,
crate::ssh::tokio_client::Error::AgentConnectionFailed,
crate::ssh::tokio_client::Error::AgentNoIdentities,
crate::ssh::tokio_client::Error::HostKeyChanged {
host: "node1.example.com".to_string(),
port: 22,
line: 3,
},
crate::ssh::tokio_client::Error::HostKeyChanged {
host: "node1.example.com".to_string(),
port: 2222,
line: 3,
},
crate::ssh::tokio_client::Error::HostKeyRevoked {
host: "node1.example.com".to_string(),
port: 22,
line: 3,
},
crate::ssh::tokio_client::Error::SshError(russh::Error::UnknownKey),
] {
let message = connect_error_message(&e).expect("variant adds guidance");
assert!(
!message.ends_with('.'),
"context message must not end with a period, got: {message}"
);
}
}
#[test]
fn test_connect_error_message_host_key_changed_adds_guidance_without_echo() {
let e = crate::ssh::tokio_client::Error::HostKeyChanged {
host: "node1.example.com".to_string(),
port: 22,
line: 7,
};
let cause_text = e.to_string();
let message = connect_error_message(&e).expect("HostKeyChanged adds guidance");
assert!(
message.contains("ssh-keygen -R \"node1.example.com\""),
"guidance must include the removal command, got: {message}"
);
assert!(
!message.contains("has changed and no longer matches"),
"context must not restate the cause, got: {message}"
);
let rendered = format!("{:#}", anyhow::Error::new(e).context(message));
assert_eq!(
rendered.matches(cause_text.as_str()).count(),
1,
"cause text should appear exactly once, got: {rendered}"
);
assert!(
rendered.contains("line 7"),
"the conflicting line number must survive into the chain, got: {rendered}"
);
}
#[test]
fn test_connect_error_message_host_key_changed_names_port_qualified_entry() {
let e = crate::ssh::tokio_client::Error::HostKeyChanged {
host: "node1.example.com".to_string(),
port: 2222,
line: 7,
};
let message = connect_error_message(&e).expect("HostKeyChanged adds guidance");
assert!(
message.contains("ssh-keygen -R \"[node1.example.com]:2222\""),
"guidance must name the port-qualified entry, got: {message}"
);
assert!(
!message.contains("has changed and no longer matches"),
"context must not restate the cause, got: {message}"
);
}
#[test]
fn test_connect_error_message_host_key_revoked_adds_guidance_without_echo() {
let e = crate::ssh::tokio_client::Error::HostKeyRevoked {
host: "node1.example.com".to_string(),
port: 22,
line: 7,
};
let cause_text = e.to_string();
let message = connect_error_message(&e).expect("HostKeyRevoked adds guidance");
assert!(
message.contains("explicitly revoked"),
"guidance must warn about revocation, got: {message}"
);
assert!(
!message.contains("is explicitly revoked by the known_hosts entry at line"),
"context must not restate the cause, got: {message}"
);
let rendered = format!("{:#}", anyhow::Error::new(e).context(message));
assert_eq!(
rendered.matches(cause_text.as_str()).count(),
1,
"cause text should appear exactly once, got: {rendered}"
);
}
}