use crate::{
extensions::ssh_remote::{
self, auth,
state::{RemoteConfig, load_known_hosts, save_known_host},
},
utils::{net, paths::expand_tilde_path, process, proxy, truncate},
};
use russh::{
ChannelMsg,
client::{self, Handle, Handler},
keys::{PrivateKey, PrivateKeyWithHashAlg, PublicKeyOrCertificate, load_secret_key},
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use tokio::net::TcpStream;
const DEFAULT_KEYS: &[&str] = &["id_ed25519", "id_ecdsa", "id_rsa"];
pub const CANCELLED: &str = "SSH authentication cancelled by user";
trait TransportStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
impl TransportStream for TcpStream {}
impl TransportStream for russh::ChannelStream<russh::client::Msg> {}
#[derive(Debug, Clone)]
pub struct Target {
pub host: String,
pub port: u16,
pub user: String,
pub identity: Option<String>,
pub password_file: Option<String>,
pub jump: Option<String>,
}
impl Target {
pub fn id(&self) -> String {
format!("{}:{}", self.host, self.port)
}
pub fn scope(&self) -> String {
format!("{}@{}", self.user, self.id())
}
}
pub struct Connection {
pub handle: Handle<ClientHandler>,
pub jump_handles: Vec<Handle<ClientHandler>>,
}
pub struct ClientHandler {
id: String,
seen: Arc<Mutex<Option<String>>>,
rejection: Arc<Mutex<Option<String>>>,
}
impl Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let line = server_public_key
.public_key()
.to_openssh()
.unwrap_or_default();
*self.seen.lock().unwrap() = Some(line.clone());
let hosts = load_known_hosts();
match hosts.get(&self.id) {
Some(known) if known != &line => {
*self.rejection.lock().unwrap() = Some(format!(
"host key for {} changed (known fingerprint differs)\nrun `/ssh forget {}` to trust the new key",
self.id, self.id
));
Ok(false)
}
_ => Ok(true),
}
}
async fn disconnected(
&mut self,
_reason: client::DisconnectReason<Self::Error>,
) -> Result<(), Self::Error> {
Ok(())
}
}
pub fn is_cancelled(err: &str) -> bool {
err.contains(CANCELLED)
}
pub async fn connect(target: &Target) -> Result<Connection, String> {
let id = target.id();
let seen = Arc::new(Mutex::new(None));
let rejection = Arc::new(Mutex::new(None));
let handler = ClientHandler {
id: id.clone(),
seen: seen.clone(),
rejection: rejection.clone(),
};
let config = client_config();
let (mut handle, jump_handles) =
if let Some(jump) = target.jump.as_deref().filter(|j| !j.trim().is_empty()) {
connect_via_jump(target, jump, config, handler, rejection.clone()).await?
} else {
let stream = connect_transport(&target.host, target.port).await?;
let handshake = net::stage_timeout(
&format!("SSH handshake to {id}"),
client::connect_stream(config, stream, handler),
)
.await;
match handshake {
Ok(handle) => (handle, Vec::new()),
Err(e) => {
return Err(rejection.lock().unwrap().clone().unwrap_or(e));
}
}
};
authenticate(&mut handle, target).await?;
if let Some(line) = seen.lock().unwrap().clone()
&& !line.is_empty()
{
_ = save_known_host(&id, &line);
}
Ok(Connection {
handle,
jump_handles,
})
}
async fn connect_transport(host: &str, port: u16) -> Result<Box<dyn TransportStream>, String> {
let configured = RemoteConfig::load().proxy;
let stream: Box<dyn TransportStream> = match proxy::from_url(configured.as_deref())? {
Some(proxy) => {
let s = net::stage_timeout(
&format!("proxy connect to {host}:{port} via {}", proxy.describe()),
proxy.connect(host, port),
)
.await?;
Box::new(s)
}
None => {
let s = net::stage_timeout(
&format!("TCP connect to {host}:{port}"),
TcpStream::connect((host, port)),
)
.await?;
Box::new(s)
}
};
Ok(stream)
}
async fn connect_via_jump(
target: &Target,
jump: &str,
config: Arc<client::Config>,
handler: ClientHandler,
rejection: Arc<Mutex<Option<String>>>,
) -> Result<(Handle<ClientHandler>, Vec<Handle<ClientHandler>>), String> {
let (jump_target, _) = parse_jump(jump)?;
let jump_conn = Box::pin(connect_jump(&jump_target)).await?;
let jump_handle = jump_conn.handle;
let channel = net::stage_timeout(
&format!(
"open jump tunnel {}:{} → {}:{}",
jump_target.host, jump_target.port, target.host, target.port
),
jump_handle.channel_open_direct_tcpip(
target.host.clone(),
target.port as u32,
"127.0.0.1".to_string(),
0,
),
)
.await?;
let handshake = net::stage_timeout(
&format!("SSH handshake to {}:{} via jump", target.host, target.port),
client::connect_stream(config, channel.into_stream(), handler),
)
.await;
match handshake {
Ok(handle) => Ok((handle, vec![jump_handle])),
Err(e) => Err(rejection.lock().unwrap().clone().unwrap_or(e)),
}
}
async fn connect_jump(target: &Target) -> Result<Connection, String> {
let id = target.id();
let seen = Arc::new(Mutex::new(None));
let rejection = Arc::new(Mutex::new(None));
let handler = ClientHandler {
id: id.clone(),
seen,
rejection: rejection.clone(),
};
let stream = connect_transport(&target.host, target.port).await?;
let handshake = net::stage_timeout(
&format!("SSH handshake to jump host {id}"),
client::connect_stream(client_config(), stream, handler),
)
.await;
let mut handle = match handshake {
Ok(handle) => handle,
Err(e) => return Err(rejection.lock().unwrap().clone().unwrap_or(e)),
};
authenticate(&mut handle, target).await?;
Ok(Connection {
handle,
jump_handles: Vec::new(),
})
}
pub fn parse_jump(value: &str) -> Result<(Target, ()), String> {
let value = value.trim();
let (user, hostport) = match value.split_once('@') {
Some((u, h)) => (u.to_string(), h),
None => (
std::env::var("USER").unwrap_or_else(|_| "root".to_string()),
value,
),
};
let (host, port) = net::split_host_port(hostport, 22);
if host.is_empty() {
return Err("jump host is empty".to_string());
}
Ok((
Target {
host,
port,
user,
identity: None,
password_file: None,
jump: None,
},
(),
))
}
async fn authenticate(handle: &mut Handle<ClientHandler>, target: &Target) -> Result<(), String> {
let mut last_error = String::new();
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(identity) = target.identity.as_deref().filter(|s| !s.trim().is_empty()) {
let path = expand_tilde_path(identity.trim().to_string());
if path.exists() {
candidates.push(path);
} else {
last_error = format!("identity file not found: {}", path.display());
}
} else if let Some(ssh_dir) = ssh_remote::sshcfg::ssh_dir() {
for name in DEFAULT_KEYS {
let path = ssh_dir.join(name);
if path.exists() {
candidates.push(path);
}
}
}
for path in candidates {
let key = match load_key(&path, target).await {
Ok(key) => key,
Err(e) if is_cancelled(&e) => return Err(CANCELLED.to_string()),
Err(e) => {
last_error = e;
continue;
}
};
let hash = key
.algorithm()
.is_rsa()
.then_some(russh::keys::HashAlg::Sha256);
let with_hash = PrivateKeyWithHashAlg::new(Arc::new(key), hash);
let result = net::stage_timeout(
&format!("public-key auth at {} as {}", target.id(), target.user),
handle.authenticate_publickey(&target.user, with_hash),
)
.await?;
if result.success() {
return Ok(());
}
last_error = format!("{}: rejected by server", path.display());
}
if password_allowed(target) {
let scope = target.scope();
let prompt = format!("password for {scope}");
match auth::acquire(
&scope,
auth::SecretKind::Password,
&prompt,
target.password_file.as_deref(),
)
.await
{
auth::Acquired::Found(password) => {
let result = net::stage_timeout(
&format!("password auth at {} as {}", target.id(), target.user),
handle.authenticate_password(&target.user, password.clone()),
)
.await?;
if result.success() {
return Ok(());
}
if keyboard_interactive(handle, target, &password).await? {
return Ok(());
}
last_error = "password rejected by server".to_string();
}
auth::Acquired::Cancelled => return Err(CANCELLED.to_string()),
auth::Acquired::Rejected(reason) => last_error = reason,
auth::Acquired::Missing => {
if last_error.is_empty() {
last_error = "no private key and no password available".to_string();
}
}
}
} else if last_error.is_empty() {
last_error = format!(
"no usable private key for {}; pass one with `-i <path>`, set IdentityFile in ~/.ssh/config, or add `-o PasswordFile=<path>`",
target.scope()
);
}
Err(format!(
"authentication failed for {} ({last_error})",
target.scope()
))
}
fn password_allowed(target: &Target) -> bool {
if target.password_file.is_some() {
return true;
}
if std::env::var("PRUX_SSH_PASSWORD").is_ok_and(|v| !v.is_empty()) {
return true;
}
target.identity.is_none()
}
async fn load_key(path: &Path, target: &Target) -> Result<PrivateKey, String> {
match load_secret_key(path, None) {
Ok(key) => Ok(key),
Err(first) => {
let scope = target.scope();
let prompt = format!("passphrase for {}", path.display());
match auth::acquire(
&scope,
auth::SecretKind::KeyPassphrase,
&prompt,
None::<&str>,
)
.await
{
auth::Acquired::Found(passphrase) => load_secret_key(path, Some(&passphrase))
.map_err(|e| format!("{}: {e}", path.display())),
auth::Acquired::Cancelled => Err(CANCELLED.to_string()),
auth::Acquired::Rejected(reason) => {
Err(format!("{}: {first} ({reason})", path.display()))
}
auth::Acquired::Missing => Err(format!(
"{}: {first} (encrypted key needs a passphrase)",
path.display()
)),
}
}
}
}
async fn keyboard_interactive(
handle: &mut Handle<ClientHandler>,
target: &Target,
password: &str,
) -> Result<bool, String> {
use russh::client::KeyboardInteractiveAuthResponse as Response;
let start = net::stage_timeout(
&format!("keyboard-interactive auth at {}", target.id()),
handle.authenticate_keyboard_interactive_start(&target.user, None::<String>),
)
.await?;
match start {
Response::Success => Ok(true),
Response::Failure { .. } => Ok(false),
Response::InfoRequest { prompts, .. } => {
let responses: Vec<String> = prompts.iter().map(|_| password.to_string()).collect();
let next = net::stage_timeout(
&format!("keyboard-interactive response at {}", target.id()),
handle.authenticate_keyboard_interactive_respond(responses),
)
.await?;
Ok(matches!(next, Response::Success))
}
}
}
fn client_config() -> Arc<client::Config> {
Arc::new(client::Config {
keepalive_interval: Some(Duration::from_secs(30)),
..<_>::default()
})
}
pub async fn exec_capture(
handle: &Handle<ClientHandler>,
command: &str,
cwd: &str,
max_bytes: usize,
timeout: Duration,
) -> Result<CommandOutput, String> {
let full = process::wrap_cd(command, cwd);
let collected = tokio::time::timeout(timeout, async {
let mut channel = handle
.channel_open_session()
.await
.map_err(|e| format!("open exec channel: {e}"))?;
channel
.exec(true, full.as_bytes())
.await
.map_err(|e| format!("send command: {e}"))?;
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut exit_code = None;
let mut truncated = false;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => {
truncate::append_bounded(&mut stdout, &data, max_bytes, &mut truncated);
}
ChannelMsg::ExtendedData { data, .. } => {
truncate::append_bounded(&mut stderr, &data, max_bytes, &mut truncated);
}
ChannelMsg::ExitStatus { exit_status } => exit_code = Some(exit_status as i32),
ChannelMsg::Close => break,
_ => {}
}
}
Ok::<CommandOutput, String>(CommandOutput {
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&stderr).to_string(),
exit_code,
truncated,
})
})
.await;
match collected {
Ok(Ok(out)) => Ok(out),
Ok(Err(e)) => Err(e),
Err(_) => Err(format!(
"remote command timed out after {}s",
timeout.as_secs()
)),
}
}
#[derive(Debug, Clone, Default)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub truncated: bool,
}
impl CommandOutput {
pub fn combined(&self) -> String {
if self.stderr.trim().is_empty() {
self.stdout.clone()
} else if self.stdout.trim().is_empty() {
self.stderr.clone()
} else {
format!("{}\n{}", self.stdout, self.stderr)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_jump_host() {
let (t, _) = parse_jump("deploy@bastion:2222").unwrap();
assert_eq!(t.user, "deploy");
assert_eq!(t.host, "bastion");
assert_eq!(t.port, 2222);
}
}