add /ssh-exec command
This commit is contained in:
111
src/commands/args.rs
Normal file
111
src/commands/args.rs
Normal file
@@ -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::<u16>() {
|
||||
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<String> {
|
||||
#[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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
552
src/commands/mod.rs
Normal file
552
src/commands/mod.rs
Normal file
@@ -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<Mutex<Context>>, 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<Mutex<Context>>,
|
||||
ctx: Arc<Mutex<BotContext>>,
|
||||
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 <password>".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<Mutex<Context>>,
|
||||
ctx: Arc<Mutex<BotContext>>,
|
||||
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<String> = 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 <XX:XX:XX:XX:XX:XX>".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::<Vec<_>>()
|
||||
.join("|");
|
||||
if config.default_machine().is_some() {
|
||||
format!("Usage: /ssh-unlock-disk [{list}] <password>")
|
||||
} else {
|
||||
format!("Usage: /ssh-unlock-disk {list} <password>")
|
||||
}
|
||||
} else {
|
||||
"Command /ssh-unlock-disk unavailable: no machines configured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ssh_unlock_disk_command(
|
||||
dchat_ctx: Arc<Mutex<Context>>,
|
||||
ctx: Arc<Mutex<BotContext>>,
|
||||
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::<Vec<_>>()
|
||||
.join("|");
|
||||
format!("Usage: /ssh-exec <username>@({list}|<hostname>|<IP address>) <command...>")
|
||||
} else {
|
||||
"Command /ssh-exec unavailable: no machines configured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ssh_exec_command(
|
||||
dchat_ctx: Arc<Mutex<Context>>,
|
||||
ctx: Arc<Mutex<BotContext>>,
|
||||
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::<String>("target"),
|
||||
cmdline_opts.get_many::<String>("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<String> {
|
||||
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::<String>("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<bool> {
|
||||
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 <password>'.".to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
58
src/main.rs
58
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<ContactId>,
|
||||
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 {
|
||||
|
||||
127
src/ssh.rs
127
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<A: ToSocketAddrs + Debug, K: AsRef<[u8]>, P: Into<String>>(
|
||||
connect_timeout: Duration,
|
||||
auth_timeout: Duration,
|
||||
addr: A,
|
||||
username: &str,
|
||||
key: Option<K>,
|
||||
password: Option<P>,
|
||||
) -> AnyhowResult<Handle<ClientHandler>> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user