From dda56f169420033c19de190db923e71a5dfe85ea Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sat, 25 Apr 2026 18:14:20 +0300 Subject: [PATCH] add /ssh-exec command --- src/commands/args.rs | 111 +++++++++ src/commands/mod.rs | 552 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 58 +++-- src/ssh.rs | 127 +++++++++- 4 files changed, 829 insertions(+), 19 deletions(-) create mode 100644 src/commands/args.rs create mode 100644 src/commands/mod.rs diff --git a/src/commands/args.rs b/src/commands/args.rs new file mode 100644 index 0000000..0fa7f94 --- /dev/null +++ b/src/commands/args.rs @@ -0,0 +1,111 @@ +pub(super) fn append_port_if_needed(input: &str, default_port: u16) -> String { + match input.rsplit_once(':') { + Some((_, port)) => match port.parse::() { + Ok(_) => input.to_owned(), + Err(_) => format!("{}:{}", input.to_owned(), default_port.to_string()), + }, + None => format!("{}:{}", input.to_owned(), default_port.to_string()), + } +} + +pub(crate) fn split_command_line(input: &str) -> Vec { + #[derive(Clone, Copy)] + enum Quote { + None, + Single, + Double, + } + + let mut args = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + let mut quote = Quote::None; + + while let Some(ch) = chars.next() { + match quote { + Quote::None => match ch { + '\'' => quote = Quote::Single, + '"' => quote = Quote::Double, + '\\' => { + if let Some(next) = chars.next() { + current.push(next); + } else { + current.push(ch); + } + } + _ if ch.is_whitespace() => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + }, + Quote::Single => match ch { + '\'' => quote = Quote::None, + '\\' => { + if let Some(next) = chars.next_if(|c| matches!(c, '\'' | '\\')) { + current.push(next); + } else { + current.push(ch); + } + } + _ => current.push(ch), + }, + Quote::Double => match ch { + '"' => quote = Quote::None, + '\\' => { + if let Some(next) = chars.next_if(|c| matches!(c, '"' | '\\')) { + current.push(next); + } else { + current.push(ch); + } + } + _ => current.push(ch), + }, + } + } + + if !current.is_empty() { + args.push(current); + } + + args +} + +#[cfg(test)] +mod tests { + use super::split_command_line; + + #[test] + fn parses_double_quoted_argument() { + assert_eq!( + split_command_line(r#"/cmd arg1 "arg2 value""#), + vec![ + "/cmd".to_string(), + "arg1".to_string(), + "arg2 value".to_string() + ] + ); + } + + #[test] + fn keeps_escaped_quotes_outside_quotes() { + assert_eq!( + split_command_line(r#"/cmd fake \"quotation marks\""#), + vec![ + "/cmd".to_string(), + "fake".to_string(), + "\"quotation".to_string(), + "marks\"".to_string() + ] + ); + } + + #[test] + fn parses_single_quoted_argument() { + assert_eq!( + split_command_line("/cmd 'arg with spaces'"), + vec!["/cmd".to_string(), "arg with spaces".to_string()] + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..0c816bc --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,552 @@ +pub(crate) mod args; + +use std::{fmt::Debug, fs::File, io::Read, ops::Add, path::PathBuf, sync::Arc, time::Duration}; + +use crate::{commands::args::append_port_if_needed, ssh}; +use anyhow::{Context as _, Result as AnyhowResult}; +use clap::{Arg, ArgAction, Command}; +use deltachat::{ + chat::{self, ChatId}, + contact::ContactId, + context::Context, + message::Message, +}; +use eui48::MacAddress; +use russh::{ + client::{AuthResult, Handle}, + keys::PrivateKeyWithHashAlg, +}; +use tokio::{ + sync::Mutex, + time::{Instant, error::Elapsed, timeout, timeout_at}, +}; + +use crate::{AUTH_REQUIRED, BotContext, config::BotConfig, data_path, ssh::ClientHandler}; + +pub async fn echo(dchat_ctx: Arc>, msg: Message) -> AnyhowResult<()> { + let chat_id = msg.get_chat_id(); + let dchat_ctx_lock = dchat_ctx.lock().await; + chat::send_text_msg(&dchat_ctx_lock, chat_id, msg.get_text()).await?; + Ok(()) +} + +pub async fn auth_command( + dchat_ctx: Arc>, + ctx: Arc>, + msg: Message, + args: &[&str], +) -> AnyhowResult<()> { + let chat_id = msg.get_chat_id(); + let contact_id = msg.get_from_id(); + + let mut ctx_lock = ctx.lock().await; + let dchat_ctx_lock = dchat_ctx.lock().await; + + if !AUTH_REQUIRED { + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + "Authentication is disabled".to_owned(), + ) + .await?; + return Ok(()); + } + + if ctx_lock.authed_contacts.contains(&contact_id) { + chat::send_text_msg(&dchat_ctx_lock, chat_id, "Already authenticated".to_owned()).await?; + return Ok(()); + } + + let Some(password) = args.first() else { + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + "Usage: /auth ".to_owned(), + ) + .await?; + return Ok(()); + }; + + if *password == ctx_lock.config.auth.password { + ctx_lock.authed_contacts.insert(contact_id); + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + "Authentication successful!".to_owned(), + ) + .await?; + } else { + chat::send_text_msg(&dchat_ctx_lock, chat_id, "Incorrect password".to_owned()).await?; + } + + Ok(()) +} + +pub async fn wol_command( + dchat_ctx: Arc>, + ctx: Arc>, + msg: Message, + args: &[&str], +) -> AnyhowResult<()> { + let chat_id = msg.get_chat_id(); + let contact_id = msg.get_from_id(); + + let ctx_lock = ctx.lock().await; + let dchat_ctx_lock = dchat_ctx.lock().await; + + if !ensure_auth(&dchat_ctx_lock, &ctx_lock, chat_id, contact_id).await? { + return Ok(()); + } + + let mac = match args.first() { + None => match ctx_lock.config.machines.values().find(|m| m.is_default) { + Some(machine) => machine.mac, + None => { + let usage = wol_usage(&ctx_lock.config); + chat::send_text_msg(&dchat_ctx_lock, chat_id, usage).await?; + return Ok(()); + } + }, + Some(arg) => { + if let Some(machine) = ctx_lock.config.machines.get(*arg) { + machine.mac + } else if let Ok(mac) = MacAddress::parse_str(arg) { + mac + } else { + let usage = wol_usage(&ctx_lock.config); + chat::send_text_msg(&dchat_ctx_lock, chat_id, usage).await?; + return Ok(()); + } + } + }; + + let reply = match send_magic_packet(mac).await { + Ok(()) => format!("Magic packet sent to {}.", mac_to_string(mac)), + Err(e) => format!("Failed to send magic packet: {e:#}"), + }; + chat::send_text_msg(&dchat_ctx_lock, chat_id, reply).await?; + + Ok(()) +} + +fn wol_usage(config: &BotConfig) -> String { + let mut forms: Vec = Vec::new(); + + if config.machines.values().any(|m| m.is_default) { + forms.push("/wol".to_owned()); + } + + let mut names: Vec<&str> = config.machines.keys().map(String::as_str).collect(); + names.sort(); + if !names.is_empty() { + forms.push(format!("/wol <{}>", names.join("|"))); + } + + forms.push("/wol ".to_owned()); + + format!("Usage: {}", forms.join(" OR ")) +} + +fn mac_to_string(mac: MacAddress) -> String { + let b = mac.as_bytes(); + format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5] + ) +} + +async fn send_magic_packet(mac: MacAddress) -> AnyhowResult<()> { + let mut packet = [0u8; 102]; + packet[..6].fill(0xFF); + let mac_bytes = mac.as_bytes(); + for i in 0..16 { + packet[6 + i * 6..6 + (i + 1) * 6].copy_from_slice(mac_bytes); + } + + let socket = tokio::net::UdpSocket::bind("0.0.0.0:0") + .await + .context("Failed to bind UDP socket for WoL")?; + socket + .set_broadcast(true) + .context("Failed to enable broadcast on UDP socket")?; + socket + .send_to(&packet, "255.255.255.255:9") + .await + .context("Failed to send magic packet")?; + + Ok(()) +} + +fn ssh_unlock_disk_usage(config: &BotConfig) -> String { + if !config.machines.is_empty() { + let list = config + .machines + .keys() + .cloned() + .collect::>() + .join("|"); + if config.default_machine().is_some() { + format!("Usage: /ssh-unlock-disk [{list}] ") + } else { + format!("Usage: /ssh-unlock-disk {list} ") + } + } else { + "Command /ssh-unlock-disk unavailable: no machines configured".to_string() + } +} + +pub async fn ssh_unlock_disk_command( + dchat_ctx: Arc>, + ctx: Arc>, + msg: Message, + args: &[&str], +) -> AnyhowResult<()> { + let chat_id = msg.get_chat_id(); + let contact_id = msg.get_from_id(); + + let ctx_lock = ctx.lock().await; + let dchat_ctx_lock = dchat_ctx.lock().await; + + if !ensure_auth(&dchat_ctx_lock, &ctx_lock, chat_id, contact_id).await? { + return Ok(()); + } + + if args.is_empty() || args.len() > 2 { + log::warn!("wrong number of args"); + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + ssh_unlock_disk_usage(&ctx_lock.config), + ) + .await?; + return Ok(()); + } + + let Some((machine_name, machine)) = ({ + if args.len() > 1 { + ctx_lock.config.machines.get(args[0]).map(|m| (args[0], m)) + } else if let Some((name, machine)) = ctx_lock.config.default_machine() { + log::info!("default machine: {:?} {:?}", name, machine); + Some((name, machine)) + } else { + log::warn!("no machine found :/"); + None + } + }) else { + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + ssh_unlock_disk_usage(&ctx_lock.config), + ) + .await?; + return Ok(()); + }; + + let Some(remote_unlock_cfg) = machine.remote_unlock.as_ref() else { + chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + format!("Remote unlock is not configured for machine '{machine_name}"), + ) + .await?; + return Ok(()); + }; + + let ssh_key_relative_path: PathBuf = remote_unlock_cfg.ssh_key_file.clone().into(); + let ssh_key_path = data_path().join(ssh_key_relative_path); + log::debug!("Reading SSH key from {:?}", &ssh_key_path); + let mut ssh_key_file = File::open(ssh_key_path)?; + let mut ssh_key = String::new(); + ssh_key_file.read_to_string(&mut ssh_key)?; + drop(ssh_key_file); + + let password = args[args.len() - 1].to_owned(); + let host = machine.static_ip.to_string(); + let ssh_key = + russh::keys::PrivateKey::from_openssh(ssh_key).context("Invalid remote unlock SSH key")?; + + drop(ctx_lock); + drop(dchat_ctx_lock); + + tokio::spawn(async move { + let ssh_config = Arc::new(russh::client::Config { + inactivity_timeout: None, + ..Default::default() + }); + // TODO implement real host key verification (needs config file changes) + const SSH_TIMEOUT: Duration = Duration::from_secs(15); + log::info!("Connecting to SSH {}:22", &host); + let mut connection = timeout( + SSH_TIMEOUT, + russh::client::connect(ssh_config, (host, 22), crate::ssh::ClientHandler {}), + ) + .await + .context("Connection to host timed out")? + .context("Cannot connect to host")?; + + log::info!("Trying to authenticate as root"); + let auth_result = timeout( + SSH_TIMEOUT, + connection.authenticate_publickey( + "root", + PrivateKeyWithHashAlg::new( + Arc::new(ssh_key), + connection.best_supported_rsa_hash().await?.flatten(), + ), + ), + ) + .await + .context("SSH authentication timed out")? + .context("SSH auth against remote unlock target failed")?; + + // ignoring both fields because we only have the key + if let AuthResult::Failure { + remaining_methods: _, + partial_success: _, + } = auth_result + { + anyhow::bail!("SSH authentication failed"); + } + + log::info!("Opening session"); + let mut channel = timeout(SSH_TIMEOUT, connection.channel_open_session()) + .await + .context("Timed out")? + .context("Cannot open SSH session")?; + + log::info!("Requesting shell"); + timeout(SSH_TIMEOUT, channel.request_shell(true)) + .await + .context("Timed out")? + .context("Cannot request disk password prompt")?; + + log::info!("Sending password"); + let input = password + "\n"; + timeout(SSH_TIMEOUT, channel.data(input.as_bytes())) + .await + .context("Timed out")? + .context("Cannot send password via SSH")?; + + loop { + let Some(msg) = timeout(SSH_TIMEOUT, channel.wait()) + .await + .context("Timed out")? + else { + break; + }; + log::info!("Received message from SSH server: {:?}", msg); + } + + Ok(()) + }) + .await? + .context("SSH unlock error")?; + + let dchat_ctx_lock = dchat_ctx.lock().await; + chat::send_text_msg(&dchat_ctx_lock, chat_id, "Unlock password sent".to_owned()).await?; + + Ok(()) +} + +fn ssh_exec_usage(config: &BotConfig) -> String { + if !config.machines.is_empty() { + let list = config + .machines + .keys() + .cloned() + .collect::>() + .join("|"); + format!("Usage: /ssh-exec @({list}||) ") + } else { + "Command /ssh-exec unavailable: no machines configured".to_string() + } +} + +pub async fn ssh_exec_command( + dchat_ctx: Arc>, + ctx: Arc>, + msg: Message, + args: &[&str], +) -> AnyhowResult<()> { + let chat_id = msg.get_chat_id(); + let contact_id = msg.get_from_id(); + + let ctx_lock = ctx.lock().await; + let dchat_ctx_lock = dchat_ctx.lock().await; + + if !ensure_auth(&dchat_ctx_lock, &ctx_lock, chat_id, contact_id).await? { + return Ok(()); + } + + let cmd = Command::new("ssh-exec").args([ + Arg::new("password").short('P').long("password"), + Arg::new("target"), + Arg::new("command") + .action(ArgAction::Append) + .num_args(1..) + .trailing_var_arg(true), + ]); + let mut args_with_dummy_program_name = vec!["ssh-exec"]; + args_with_dummy_program_name.extend_from_slice(args); + let Ok(cmdline_opts) = cmd.try_get_matches_from(args_with_dummy_program_name) else { + chat::send_text_msg(&dchat_ctx_lock, chat_id, ssh_exec_usage(&ctx_lock.config)).await?; + return Ok(()); + }; + + let (Some(target), Some(command)) = ( + cmdline_opts.get_one::("target"), + cmdline_opts.get_many::("command"), + ) else { + chat::send_text_msg(&dchat_ctx_lock, chat_id, ssh_exec_usage(&ctx_lock.config)).await?; + return Ok(()); + }; + + if !target.contains('@') { + chat::send_text_msg(&dchat_ctx_lock, chat_id, ssh_exec_usage(&ctx_lock.config)).await?; + return Ok(()); + } + + let command = command.fold(String::new(), |mut out, part| { + if !out.is_empty() { + out.push(' '); + } + out.push_str(part); + out + }); + + let (username, host) = target.split_once('@').unwrap(); + let username = username.to_owned(); + let (host_addr, key_filename) = match ctx_lock.config.machines.get(host) { + Some(machine) => ( + append_port_if_needed(&machine.static_ip.to_string(), 22), + Some(machine.remote_access.ssh_key_file.clone()), + ), + None => (append_port_if_needed(host, 22), None), + }; + + let key = key_filename + .map(|filename| -> AnyhowResult { + let filename = data_path().join(filename); + log::debug!("Reading SSH key from {:?}", &filename); + let mut ssh_key_file = File::open(filename)?; + let mut ssh_key = String::new(); + ssh_key_file.read_to_string(&mut ssh_key)?; + Ok(ssh_key) + }) + .transpose() + .context("Cannot read the SSH key file")?; + + let password = cmdline_opts.get_one::("password").cloned(); + + let report_message_id = chat::send_text_msg( + &dchat_ctx_lock, + chat_id, + format!("Executing {} on {}@{}...", &command, &username, &host_addr), + ) + .await + .context("Cannot send reply")?; + + drop(ctx_lock); + drop(dchat_ctx_lock); + + tokio::spawn(async move { + const TIMEOUT: Duration = Duration::from_secs(15); + const EXIT_TIMEOUT: Duration = Duration::from_secs(20); + + let connection = ssh::connect_ssh(TIMEOUT, TIMEOUT, &host_addr, &username, key, password) + .await + .context(format!("Cannot connect to {}", &host_addr))?; + + log::info!("Opening SSH channel"); + let mut session = timeout(TIMEOUT, connection.channel_open_session()) + .await + .context("Open SSH channel timed out")? + .context("Cannot open SSH channel")?; + + log::info!("Executing SSH command \"{}\"", &command); + timeout(TIMEOUT, session.exec(false, command.as_bytes())) + .await + .context("SSH exec timed out")? + .context("Cannot execute command via SSH")?; + + let exit_deadline = Instant::now().add(EXIT_TIMEOUT); + let mut report_text = String::new(); + loop { + match timeout_at(exit_deadline, session.wait()).await { + Ok(Some(msg)) => match msg { + russh::ChannelMsg::Data { data } => { + let dchat_ctx_lock = dchat_ctx.lock().await; + report_text += &String::from_utf8_lossy(&data); + chat::send_edit_request( + &dchat_ctx_lock, + report_message_id, + report_text.clone(), + ) + .await + .context("Cannot edit message")?; + } + russh::ChannelMsg::ExtendedData { data, ext: 1 } => { + let dchat_ctx_lock = dchat_ctx.lock().await; + report_text += &String::from_utf8_lossy(&data); + chat::send_edit_request( + &dchat_ctx_lock, + report_message_id, + report_text.clone(), + ) + .await + .context("Cannot edit message")?; + } + _ => { + log::info!("Message from SSH channel: {msg:?}"); + } + }, + Ok(None) => { + log::info!("No message from SSH channel. Is this EOF?"); + break; + } + Err(_) => { + log::error!("SSH exec command timed out"); + + let dchat_ctx_lock = dchat_ctx.lock().await; + report_text += "\n\nRead timed out"; + chat::send_edit_request( + &dchat_ctx_lock, + report_message_id, + report_text.clone(), + ) + .await + .context("Cannot edit message")?; + + break; + } + } + } + AnyhowResult::<()>::Ok(()) + }) + .await? + .context("SSH exec error")?; + + Ok(()) +} + +async fn ensure_auth( + dchat_ctx: &Context, + ctx: &BotContext, + chat_id: ChatId, + contact_id: ContactId, +) -> AnyhowResult { + if !AUTH_REQUIRED { + return Ok(true); + } + if !ctx.authed_contacts.contains(&contact_id) { + chat::send_text_msg( + dchat_ctx, + chat_id, + "Authenticate yourself first with '/auth '.".to_owned(), + ) + .await?; + Ok(false) + } else { + Ok(true) + } +} diff --git a/src/main.rs b/src/main.rs index e714733..4754d1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,43 @@ +mod commands; mod config; -mod handler; mod ssh; use anyhow::{Context as _, Result as AnyhowResult}; use clap::Parser; +use config::BotConfig; use deltachat::{ EventType, Events, chat, config::Config, + contact::ContactId, context::Context, message::{Message, MsgId}, securejoin, stock_str::StockStrings, }; +use std::collections::HashSet; use std::{path::PathBuf, sync::Arc}; use tokio::sync::Mutex; -use crate::handler::BotContext; - const CONFIG_FILENAME: &str = "bot.yml"; const APP_NAME: &str = "deltachat-remotecontrol-bot"; const APP_CONFIG_DIR: &str = APP_NAME; const APP_DATA_DIR: &str = APP_NAME; const BOT_DISPLAY_NAME: &str = "🤖Remote🖲️"; -const AUTH_REQUIRED: bool = true; +const AUTH_REQUIRED: bool = false; + +pub struct BotContext { + authed_contacts: HashSet, + config: BotConfig, +} + +impl BotContext { + pub fn new(config: BotConfig) -> BotContext { + BotContext { + authed_contacts: HashSet::new(), + config, + } + } +} /// Delta Chat bot for remote control of local network machines. #[derive(Parser)] @@ -119,8 +134,10 @@ async fn run_bot(cfg: config::BotConfig) -> AnyhowResult<()> { .await .context("Failed to generate invite link")?; + println!(); println!("Add this bot to your Delta Chat contacts:"); println!("{invite_link}"); + println!(); let dchat_ctx = Arc::new(Mutex::new(dchat_ctx)); @@ -129,12 +146,12 @@ async fn run_bot(cfg: config::BotConfig) -> AnyhowResult<()> { let ev_emitter = dchat_ctx.lock().await.get_event_emitter(); while let Some(ev) = ev_emitter.recv().await { - if let EventType::IncomingMsg { chat_id, msg_id } = ev.typ { - if let Err(e) = handle_message(dchat_ctx.clone(), bot_context.clone(), msg_id).await { - eprintln!("Error in message handler: {e:#}"); - let ctx_lock = dchat_ctx.lock().await; - chat::send_text_msg(&ctx_lock, chat_id, format!("Error: {e:#}")).await?; - } + if let EventType::IncomingMsg { chat_id, msg_id } = ev.typ + && let Err(e) = handle_message(dchat_ctx.clone(), bot_context.clone(), msg_id).await + { + log::error!("Error in message handler: {e:#}"); + let ctx_lock = dchat_ctx.lock().await; + chat::send_text_msg(&ctx_lock, chat_id, format!("Error: {e:#}")).await?; } } @@ -152,25 +169,28 @@ async fn handle_message( drop(dchat_ctx_lock); if !text.starts_with('/') { - handler::echo(dchat_ctx, msg).await?; + commands::echo(dchat_ctx, msg).await?; return Ok(()); } - let mut parts = text.split_whitespace(); - let Some(cmd) = parts.next() else { + let parsed = commands::args::split_command_line(&text); + let Some((cmd, args_owned)) = parsed.split_first() else { return Ok(()); }; - let args: Vec<&str> = parts.collect(); + let args: Vec<&str> = args_owned.iter().map(String::as_str).collect(); - match cmd { + match cmd.as_str() { "/auth" => { - handler::auth_command(dchat_ctx, bot_ctx, msg, &args).await?; + commands::auth_command(dchat_ctx, bot_ctx, msg, &args).await?; } "/wol" => { - handler::wol_command(dchat_ctx, bot_ctx, msg, &args).await?; + commands::wol_command(dchat_ctx, bot_ctx, msg, &args).await?; } "/ssh-unlock-disk" => { - handler::ssh_unlock_disk_command(dchat_ctx, bot_ctx, msg, &args).await?; + commands::ssh_unlock_disk_command(dchat_ctx, bot_ctx, msg, &args).await?; + } + "/ssh-exec" => { + commands::ssh_exec_command(dchat_ctx, bot_ctx, msg, &args).await?; } _ => {} } @@ -182,6 +202,8 @@ async fn handle_message( async fn main() { env_logger::init(); + log::info!("Starting {}", APP_NAME); + let args = Args::parse(); let config_path: PathBuf = match args.config { diff --git a/src/ssh.rs b/src/ssh.rs index ff98c7e..fc94fd5 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -1,4 +1,11 @@ -use russh::client::Handler; +use std::{fmt::Debug, sync::Arc, time::Duration}; + +use anyhow::{Context as _, Result as AnyhowResult}; +use russh::{ + client::{AuthResult, Handle, Handler}, + keys::PrivateKeyWithHashAlg, +}; +use tokio::net::ToSocketAddrs; pub(crate) struct ClientHandler {} @@ -12,3 +19,121 @@ impl Handler for ClientHandler { Ok(true) } } + +pub(crate) async fn connect_ssh, P: Into>( + connect_timeout: Duration, + auth_timeout: Duration, + addr: A, + username: &str, + key: Option, + password: Option

, +) -> AnyhowResult> { + let ssh_config = Arc::new(russh::client::Config { + inactivity_timeout: None, + ..Default::default() + }); + log::info!( + "Connecting to SSH {:?} {} key & {} password", + &addr, + if key.is_some() { "with" } else { "without" }, + if password.is_some() { + "with" + } else { + "without" + } + ); + let mut connection = tokio::time::timeout( + connect_timeout, + russh::client::connect(ssh_config, addr, crate::ssh::ClientHandler {}), + ) + .await + .context("Connection to host timed out")? + .context("Cannot connect to host")?; + + let key = match key { + Some(key) => { + let privkey = russh::keys::PrivateKey::from_openssh(key).context("Invalid SSH key")?; + Some(PrivateKeyWithHashAlg::new( + Arc::new(privkey), + connection.best_supported_rsa_hash().await?.flatten(), + )) + } + None => None, + }; + + let mut auth_success = false; + if !auth_success { + if let Some(key) = key { + log::info!("Trying to authenticate using key"); + match tokio::time::timeout( + auth_timeout, + connection.authenticate_publickey(username, key), + ) + .await + { + Ok(Ok(AuthResult::Success)) => { + auth_success = true; + } + Ok(Ok(AuthResult::Failure { + remaining_methods, + partial_success, + })) => { + if partial_success || !remaining_methods.contains(&russh::MethodKind::Password) + { + if partial_success { + anyhow::bail!("SSH auth failed: multi-factor auth is not supported"); + } else { + anyhow::bail!("SSH auth failed: no auth methods left"); + } + } + log::warn!("SSH public key rejected"); + } + Ok(Err(e)) => { + log::warn!("SSH public key auth failed: {e}"); + } + Err(_) => { + log::warn!("SSH public key auth timed out"); + } + } + } else { + log::info!("No key provided"); + } + } + if !auth_success { + if let Some(password) = password { + log::info!("Trying to authenticate using password"); + match tokio::time::timeout( + auth_timeout, + connection.authenticate_password(username, password), + ) + .await + { + Ok(Ok(AuthResult::Success)) => { + auth_success = true; + } + Ok(Ok(AuthResult::Failure { + remaining_methods: _, + partial_success, + })) => { + if partial_success { + anyhow::bail!("SSH auth failed: multi-factor auth is not supported"); + } else { + anyhow::bail!("SSH auth failed: no auth methods left"); + } + } + Ok(Err(e)) => { + log::warn!("SSH password auth failed: {e}"); + } + Err(_) => { + log::warn!("SSH password auth timed out"); + } + } + } else { + log::info!("No password provided"); + } + } + if !auth_success { + anyhow::bail!("SSH auth failed: no auth methods left"); + } + Ok(connection) +}