use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use russh::client::{self, Handle};
use russh::keys::{PrivateKeyWithHashAlg, PublicKeyOrCertificate, load_secret_key, ssh_key};
use russh::{ChannelMsg, Disconnect};
use crate::network::ssh_target::SshTarget;
use crate::network::ssh_transport::StrictHostKey;
pub const MAX_COMMAND_STDOUT_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_COMMAND_STDERR_BYTES: usize = 1024 * 1024;
pub const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(15);
pub const DEFAULT_INACTIVITY: Duration = Duration::from_secs(60);
#[derive(Debug, thiserror::Error)]
pub enum SshClientError {
#[error("SSH I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("SSH protocol error: {0}")]
Protocol(String),
#[error("SSH authentication failed for {user}@{host}:{port}")]
AuthFailed {
user: String,
host: String,
port: u16,
},
#[error("SSH host-key rejected for {host}:{port}: {reason}")]
HostKeyRejected {
host: String,
port: u16,
reason: String,
},
#[error("SSH connect timeout after {0:?}")]
ConnectTimeout(Duration),
#[error("SSH exec timeout after {0:?}")]
ExecTimeout(Duration),
#[error("no usable SSH private key found (tried: {0})")]
NoUsableKey(String),
#[error("SSH command output exceeded {limit} bytes")]
OutputTooLarge { limit: usize },
}
impl SshClientError {
pub fn ui_label(&self) -> &'static str {
match self {
Self::AuthFailed { .. } => "auth-failed",
Self::HostKeyRejected { .. } => "host-key-rejected",
Self::ConnectTimeout(_) => "timeout",
Self::ExecTimeout(_) => "timeout",
Self::NoUsableKey(_) => "no-key",
Self::OutputTooLarge { .. } => "disconnected",
Self::Io(_) | Self::Protocol(_) => "disconnected",
}
}
}
#[async_trait]
pub trait HostKeyVerifier: Send + Sync + 'static {
async fn verify(
&self,
host: &str,
port: u16,
key: &ssh_key::PublicKey,
) -> Result<bool, SshClientError>;
}
pub struct ConnectParams {
pub target: SshTarget,
pub explicit_key: Option<PathBuf>,
#[allow(dead_code)]
pub strict_host_key: StrictHostKey,
pub connect_timeout: Duration,
pub inactivity: Duration,
#[allow(dead_code)]
pub known_hosts: Option<PathBuf>,
}
impl Default for ConnectParams {
fn default() -> Self {
Self {
target: SshTarget {
user: String::new(),
host: String::new(),
port: 22,
},
explicit_key: None,
strict_host_key: StrictHostKey::Yes,
connect_timeout: Duration::from_secs(10),
inactivity: DEFAULT_INACTIVITY,
known_hosts: None,
}
}
}
#[derive(Clone)]
pub struct SshSession {
inner: Arc<Handle<RusshClient>>,
target: SshTarget,
}
struct RusshClient {
verifier: Arc<dyn HostKeyVerifier>,
host: String,
port: u16,
}
impl client::Handler for RusshClient {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let server_public_key = server_public_key.public_key();
match self
.verifier
.verify(&self.host, self.port, &server_public_key)
.await
{
Ok(accept) => Ok(accept),
Err(e) => {
tracing::warn!(
host = %self.host,
port = self.port,
error = %e,
"host-key verifier returned an error; rejecting connection"
);
Ok(false)
}
}
}
}
impl SshSession {
pub async fn connect(
params: ConnectParams,
verifier: Arc<dyn HostKeyVerifier>,
) -> Result<Self, SshClientError> {
let target = params.target.clone();
let host = target.host.clone();
let port = target.port;
let key_path = resolve_key_path(params.explicit_key.as_deref())
.ok_or_else(|| SshClientError::NoUsableKey(default_key_probe_paths()))?;
let key_pair = load_secret_key(&key_path, None).map_err(|e| {
SshClientError::Protocol(format!(
"failed to load private key {}: {e}",
key_path.display()
))
})?;
let config = Arc::new(client::Config {
inactivity_timeout: Some(params.inactivity),
keepalive_interval: Some(Duration::from_secs(30)),
keepalive_max: 3,
..Default::default()
});
let handler = RusshClient {
verifier,
host: host.clone(),
port,
};
let connect_fut = client::connect(config, (host.as_str(), port), handler);
let mut session = match tokio::time::timeout(params.connect_timeout, connect_fut).await {
Err(_) => return Err(SshClientError::ConnectTimeout(params.connect_timeout)),
Ok(Err(e)) => return Err(map_russh_error(e, &host, port)),
Ok(Ok(session)) => session,
};
let best_hash = session
.best_supported_rsa_hash()
.await
.map_err(|e| map_russh_error(e, &host, port))?
.flatten();
let auth_res = session
.authenticate_publickey(
target.user.clone(),
PrivateKeyWithHashAlg::new(Arc::new(key_pair), best_hash),
)
.await
.map_err(|e| map_russh_error(e, &host, port))?;
if !auth_res.success() {
return Err(SshClientError::AuthFailed {
user: target.user.clone(),
host: host.clone(),
port,
});
}
Ok(Self {
inner: Arc::new(session),
target,
})
}
pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshClientError> {
self.exec_with_timeout(command, DEFAULT_EXEC_TIMEOUT).await
}
pub async fn exec_with_timeout(
&self,
command: &str,
timeout: Duration,
) -> Result<ExecOutput, SshClientError> {
let run = async {
let mut channel = self
.inner
.channel_open_session()
.await
.map_err(|e| map_russh_error(e, &self.target.host, self.target.port))?;
channel
.exec(true, command)
.await
.map_err(|e| map_russh_error(e, &self.target.host, self.target.port))?;
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut exit_code: Option<u32> = None;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { ref data } => {
if stdout.len() + data.len() > MAX_COMMAND_STDOUT_BYTES {
return Err(SshClientError::OutputTooLarge {
limit: MAX_COMMAND_STDOUT_BYTES,
});
}
stdout.extend_from_slice(data);
}
ChannelMsg::ExtendedData { ref data, ext: 1 } => {
if stderr.len() + data.len() > MAX_COMMAND_STDERR_BYTES {
return Err(SshClientError::OutputTooLarge {
limit: MAX_COMMAND_STDERR_BYTES,
});
}
stderr.extend_from_slice(data);
}
ChannelMsg::ExitStatus { exit_status } => {
exit_code = Some(exit_status);
}
ChannelMsg::Eof | ChannelMsg::Close => {
}
_ => {}
}
}
Ok(ExecOutput {
stdout: bytes_to_string_zero_copy(stdout),
stderr: bytes_to_string_zero_copy(stderr),
exit_status: exit_code,
})
};
match tokio::time::timeout(timeout, run).await {
Err(_) => Err(SshClientError::ExecTimeout(timeout)),
Ok(result) => result,
}
}
#[allow(dead_code)]
pub async fn close(&self) {
if let Err(e) = self
.inner
.disconnect(Disconnect::ByApplication, "view --ssh shutting down", "en")
.await
{
tracing::debug!(
host = %self.target.host,
port = self.target.port,
error = %e,
"ssh disconnect returned an error"
);
}
}
#[allow(dead_code)]
pub fn target(&self) -> &SshTarget {
&self.target
}
}
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
pub exit_status: Option<u32>,
}
impl ExecOutput {
pub fn is_success(&self) -> bool {
self.exit_status == Some(0)
}
}
fn bytes_to_string_zero_copy(bytes: Vec<u8>) -> String {
match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
}
}
fn map_russh_error(err: russh::Error, host: &str, port: u16) -> SshClientError {
use russh::Error as E;
match err {
E::IO(e) => SshClientError::Io(e),
E::NotAuthenticated => SshClientError::AuthFailed {
user: String::new(),
host: host.to_string(),
port,
},
E::UnknownKey => SshClientError::HostKeyRejected {
host: host.to_string(),
port,
reason: "host key not trusted".to_string(),
},
other => SshClientError::Protocol(format!("{other:?}")),
}
}
fn resolve_key_path(explicit: Option<&Path>) -> Option<PathBuf> {
if let Some(p) = explicit {
let expanded = crate::common::paths::expand_tilde(p);
if expanded.exists() {
return Some(expanded);
}
return None;
}
key_probe_paths()
.into_iter()
.find(|candidate| candidate.exists())
}
fn key_probe_paths() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(home) = dirs::home_dir() {
out.push(home.join(".ssh").join("id_ed25519"));
out.push(home.join(".ssh").join("id_ecdsa"));
out.push(home.join(".ssh").join("id_rsa"));
}
out
}
fn default_key_probe_paths() -> String {
key_probe_paths()
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_ui_labels_are_stable() {
assert_eq!(
SshClientError::AuthFailed {
user: "u".to_string(),
host: "h".to_string(),
port: 22,
}
.ui_label(),
"auth-failed"
);
assert_eq!(
SshClientError::ConnectTimeout(Duration::from_secs(1)).ui_label(),
"timeout"
);
assert_eq!(
SshClientError::HostKeyRejected {
host: "h".to_string(),
port: 22,
reason: "x".to_string()
}
.ui_label(),
"host-key-rejected"
);
}
#[test]
fn key_probe_paths_contains_home_ssh_defaults() {
if dirs::home_dir().is_some() {
let paths = key_probe_paths();
assert!(
paths.iter().any(|p| p.ends_with("id_ed25519")),
"expected id_ed25519 in probe list, got {paths:?}"
);
assert!(paths.iter().any(|p| p.ends_with("id_rsa")));
}
}
}