Compare commits

...

6 Commits

Author SHA1 Message Date
aa8a624360 Merge pull request '33-lazy-ssh-agent' (#46) from 33-lazy-ssh-agent into master
Reviewed-on: #46
2026-08-31 21:28:29 +02:00
29e6453c56 Merge branch 'master' into 33-lazy-ssh-agent 2026-08-31 21:28:15 +02:00
Leonard Steppy
ffc3c478d9 Don't use scp on localhost 2026-08-31 21:27:06 +02:00
Leonard Steppy
a4d7fd7506 Fix nix build by updating dependencies 2026-08-31 15:40:44 +02:00
Leonard Steppy
952ca847cd Start ssh agent only when needed 2026-08-31 15:27:10 +02:00
Leonard Steppy
df26eb435f Ensure ssh agent is only started once 2026-08-27 08:27:08 +02:00
7 changed files with 157 additions and 68 deletions

View File

@ -1,10 +1,10 @@
[package] [package]
name = "multi-ssh" name = "multi-ssh"
version = "0.2.0" version = "0.2.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.5.23", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
lazy-regex = "3.3.0" lazy-regex = "3.6.1"
shell-words = "1.1.0" shell-words = "1.1.1"
homedir = "0.3.4" homedir = "0.3.6"

View File

@ -2,16 +2,16 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1759580034, "lastModified": 1787962033,
"narHash": "sha256-YWo57PL7mGZU7D4WeKFMiW4ex/O6ZolUS6UNBHTZfkI=", "narHash": "sha256-u6z9VTZA4Kf3RkHQo9sQI7NI4Ei/uiU9vrMqOiwWP1Y=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3bcc93c5f7a4b30335d31f21e2f1281cba68c318", "rev": "c5c4a43b0e8056328ec4529f735cabdb8f1942bb",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-25.05", "ref": "nixos-26.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@ -43,11 +43,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1759631821, "lastModified": 1788165049,
"narHash": "sha256-V8A1L0FaU/aSXZ1QNJScxC12uP4hANeRBgI4YdhHeRM=", "narHash": "sha256-en4IoUeCqvq9F66YhwOrUFw1nc70OBhrJwrzfalezvY=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "1d7cbdaad90f8a5255a89a6eddd8af24dc89cafe", "rev": "d03cd474bd97389dcc2e8cd3b3bb6b8c6e346b1a",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -2,7 +2,7 @@
description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh"; description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
}; };

View File

@ -1,6 +1,6 @@
use crate::shell_interface::{ use crate::shell_interface::{CommandOutput, CommandResult, ExitStatus, ShellCommand,
build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand,
ShellInterface, StartError, ShellInterface, StartError,
build_command_from_shell_command,
}; };
use std::env::VarError; use std::env::VarError;
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
@ -27,10 +27,14 @@ pub trait Environment {
V: AsRef<OsStr>; V: AsRef<OsStr>;
fn get_home_directory(&self) -> Option<PathBuf>; fn get_home_directory(&self) -> Option<PathBuf>;
fn read_line(&mut self) -> Result<String, io::Error>; fn read_line(&mut self) -> Result<String, io::Error>;
fn is_ssh_agent_started(&self) -> bool;
fn set_ssh_agent_started(&mut self, enabled: bool);
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Prod; pub struct Prod {
ssh_agent_started: bool,
}
impl Environment for Prod { impl Environment for Prod {
fn args_os(&self) -> Vec<OsString> { fn args_os(&self) -> Vec<OsString> {
@ -39,7 +43,7 @@ impl Environment for Prod {
fn var_os<K>(&self, key: K) -> Option<OsString> fn var_os<K>(&self, key: K) -> Option<OsString>
where where
K: AsRef<OsStr> K: AsRef<OsStr>,
{ {
env::var_os(key) env::var_os(key)
} }
@ -56,8 +60,11 @@ impl Environment for Prod {
K: AsRef<OsStr>, K: AsRef<OsStr>,
V: AsRef<OsStr>, V: AsRef<OsStr>,
{ {
unsafe {
//multi-ssh is single threaded
env::set_var(key, value); env::set_var(key, value);
} }
}
fn get_home_directory(&self) -> Option<PathBuf> { fn get_home_directory(&self) -> Option<PathBuf> {
homedir::my_home().ok().flatten() homedir::my_home().ok().flatten()
@ -68,6 +75,14 @@ impl Environment for Prod {
io::stdin().read_line(&mut buffer)?; io::stdin().read_line(&mut buffer)?;
Ok(buffer.trim().to_string()) Ok(buffer.trim().to_string())
} }
fn is_ssh_agent_started(&self) -> bool {
self.ssh_agent_started
}
fn set_ssh_agent_started(&mut self, enabled: bool) {
self.ssh_agent_started = enabled;
}
} }
impl ShellInterface for Prod { impl ShellInterface for Prod {

View File

@ -74,6 +74,14 @@ impl Environment for TestEnvironment {
fn read_line(&mut self) -> Result<String, Error> { fn read_line(&mut self) -> Result<String, Error> {
self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared")) self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared"))
} }
fn is_ssh_agent_started(&self) -> bool {
self.ssh_agent_started
}
fn set_ssh_agent_started(&mut self, enabled: bool) {
self.ssh_agent_started = enabled;
}
} }
impl ShellInterface for TestEnvironment { impl ShellInterface for TestEnvironment {

View File

@ -18,7 +18,7 @@ use crate::os_string_builder::ReplaceWithOsStr;
use crate::server::{RelativeLocalPathAnker, ServerAddress}; use crate::server::{RelativeLocalPathAnker, ServerAddress};
use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface}; use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface};
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use lazy_regex::{lazy_regex, Lazy, Regex}; use lazy_regex::{Lazy, Regex, lazy_regex};
use server::{Server, ServerReference}; use server::{Server, ServerReference};
use std::cell::LazyCell; use std::cell::LazyCell;
use std::ffi::OsString; use std::ffi::OsString;
@ -170,6 +170,30 @@ where
}, },
}; };
macro_rules! start_ssh_agent {
() => {
self.start_ssh_agent(&logger)?;
};
}
macro_rules! ssh {
($($field:ident $(: $value:expr)?$(,)? )*) => {{
start_ssh_agent!();
ShellCommand::Ssh {
$( $field $(: $value)? ),*
}
}};
}
macro_rules! scp {
($($field:ident $(: $value:expr)?$(,)? )*) => {{
start_ssh_agent!();
ShellCommand::Scp {
$( $field $(: $value)? ),*
}
}};
}
let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env()); let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env());
let servers = args let servers = args
.servers .servers
@ -223,8 +247,6 @@ where
None => None, None => None,
}; };
self.start_ssh_agent(&logger)?;
//make sure files exist //make sure files exist
match &file_server { match &file_server {
Some(file_server) => match &file_server.address { Some(file_server) => match &file_server.address {
@ -233,7 +255,7 @@ where
files = files files = files
.iter() .iter()
.map(|file| { .map(|file| {
let output = ShellCommand::Ssh { let output = ssh! {
address: ssh_address.to_string(), address: ssh_address.to_string(),
server_command: ServerCommand::Realpath { server_command: ServerCommand::Realpath {
path: file_server.server_directory_path.join(file), path: file_server.server_directory_path.join(file),
@ -313,7 +335,7 @@ where
server, server,
actions: { actions: {
let present_file_names: Vec<OsString> = match &server.address { let present_file_names: Vec<OsString> = match &server.address {
ServerAddress::Ssh { ssh_address } => ShellCommand::Ssh { ServerAddress::Ssh { ssh_address } => ssh! {
address: ssh_address.to_string(), address: ssh_address.to_string(),
server_command: ServerCommand::Ls { server_command: ServerCommand::Ls {
dir: working_directory.clone(), dir: working_directory.clone(),
@ -464,15 +486,27 @@ where
for file_action in server_actions.actions { for file_action in server_actions.actions {
match file_action.kind { match file_action.kind {
Action::Add | Action::Replace => { Action::Add | Action::Replace => {
//don't use scp on localhost
if file_server.is_none() && matches!(server.address, ServerAddress::Localhost) {
let file = file_action.file;
let dest = server_actions.working_directory.join(file_action.file_name);
fs::copy(&file, &dest).map_err(|e| {
format!(
"Failed to copy from {} to {}: {e}",
file.to_string_lossy(),
dest.to_string_lossy()
)
})?;
} else {
let source = match &file_server { let source = match &file_server {
Some(file_server) => ScpParam::from(( Some(file_server) => ScpParam::from((
file_server, file_server,
file_server.server_directory_path.join(&file_action.file), file_server.server_directory_path.join(&file_action.file),
)), )),
None => ScpParam::from(file_action.file.as_path()), None => ScpParam::from(&file_action.file),
}; };
let destination = ScpParam::from((server, &server_actions.working_directory)); let destination = ScpParam::from((server, &server_actions.working_directory));
ShellCommand::Scp { scp! {
source, source,
destination, destination,
} }
@ -482,9 +516,10 @@ where
.into_result_with_error_logging(&logger) .into_result_with_error_logging(&logger)
.map_err(|e| format!("upload failure: {e}"))?; .map_err(|e| format!("upload failure: {e}"))?;
} }
}
Action::Delete => match &server.address { Action::Delete => match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCommand::Ssh { ssh! {
address: ssh_address.to_string(), address: ssh_address.to_string(),
server_command: ServerCommand::Rm { server_command: ServerCommand::Rm {
file: server_actions.working_directory.join(&file_action.file), file: server_actions.working_directory.join(&file_action.file),
@ -503,7 +538,7 @@ where
}, },
Action::Rename { new_name } => match &server.address { Action::Rename { new_name } => match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCommand::Ssh { ssh! {
address: ssh_address.to_string(), address: ssh_address.to_string(),
server_command: ServerCommand::Mv { server_command: ServerCommand::Mv {
source: server_actions.working_directory.join(&file_action.file), source: server_actions.working_directory.join(&file_action.file),
@ -529,13 +564,12 @@ where
log!(logger, "Done!"); log!(logger, "Done!");
} }
Command::Command { command } => { Command::Command { command } => {
self.start_ssh_agent(&logger)?;
Self::require_non_empty_servers(&servers)?; Self::require_non_empty_servers(&servers)?;
for server in servers { for server in servers {
log!(logger, "Running command on '{}'...", server.get_name()); log!(logger, "Running command on '{}'...", server.get_name());
match &server.address { match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCommand::Ssh { ssh! {
address: ssh_address.to_string(), address: ssh_address.to_string(),
server_command: ServerCommand::Execute { server_command: ServerCommand::Execute {
working_directory: server.server_directory_path.clone(), working_directory: server.server_directory_path.clone(),
@ -625,28 +659,37 @@ where
} }
Self::require_non_empty_servers(&servers)?; Self::require_non_empty_servers(&servers)?;
self.start_ssh_agent(&logger)?;
for server in servers { for server in servers {
log!(logger, "Getting file from {}...", server.get_name()); log!(logger, "Getting file from {}...", server.get_name());
let source = ScpParam::from((&server, server.server_directory_path.join(&file))); let file_path = server.server_directory_path.join(&file);
ShellCommand::Scp { let downloaded_file_path = download_directory.join(file_name);
source: source.clone(), if matches!(server.address, ServerAddress::Localhost) {
destination: ScpParam::from(download_directory.as_path()), //no need to use scp on localhost
fs::copy(&file_path, &downloaded_file_path).map_err(|e| {
format!(
"failed to copy {} to {}: {e}",
file_path.to_string_lossy(),
downloaded_file_path.to_string_lossy()
)
})?;
} else {
scp! {
source: ScpParam::from((&server, &file_path)),
destination: ScpParam::from(&download_directory),
} }
.in_env(env!()) .in_env(env!())
.run_logged(&logger) .run_logged(&logger)
.and_expect_success() .and_expect_success()
.into_result_with_error_logging(&logger) .into_result_with_error_logging(&logger)
.map_err(|e| format!("download failure: {e}"))?; .map_err(|e| format!("download failure: {e}"))?;
}
//open file in editor //open file in editor
let editor_command = shell_words::split(&editor) let editor_command = shell_words::split(&editor)
.map_err(|e| format!("failed to parse editor command: {e}"))? .map_err(|e| format!("failed to parse editor command: {e}"))?
.into_iter() .into_iter()
.map(|part| { .map(|part| part.replace_with_os_str(FILE_PLACEHOLDER, &file_path))
part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
ShellCommand::Editor(editor_command) ShellCommand::Editor(editor_command)
@ -656,10 +699,19 @@ where
.into_result_with_error_logging(&logger) .into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to open file in editor: {e}"))?; .map_err(|e| format!("failed to open file in editor: {e}"))?;
//upload file again //upload file again; don't use scp on localhost
ShellCommand::Scp { if matches!(server.address, ServerAddress::Localhost) {
source: ScpParam::from(download_directory.join(file_name).as_path()), fs::copy(&downloaded_file_path, &file_path).map_err(|e| {
destination: source, format!(
"failed to copy {} to {}: {e}",
file_path.to_string_lossy(),
download_directory.to_string_lossy()
)
})?;
} else {
scp! {
source: ScpParam::from(&downloaded_file_path),
destination: ScpParam::from((&server, &file_path)),
} }
.in_env(env!()) .in_env(env!())
.run_logged(&logger) .run_logged(&logger)
@ -667,6 +719,7 @@ where
.into_result_with_error_logging(&logger) .into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to re-upload file: {e}"))?; .map_err(|e| format!("failed to re-upload file: {e}"))?;
} }
}
log!(logger, "Done!"); log!(logger, "Done!");
} }
@ -706,6 +759,10 @@ where
fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> { fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> {
let env = &mut self.environment; let env = &mut self.environment;
if env.is_ssh_agent_started() {
return Ok(());
}
//start the ssh agent //start the ssh agent
let agent_output = ShellCommand::SshAgent let agent_output = ShellCommand::SshAgent
.in_env(env) .in_env(env)
@ -732,6 +789,9 @@ where
.and_expect_success() .and_expect_success()
.into_result_with_error_logging(logger) .into_result_with_error_logging(logger)
.map_err(|e| format!("failed to add ssh-key: {e}"))?; .map_err(|e| format!("failed to add ssh-key: {e}"))?;
env.set_ssh_agent_started(true);
Ok(()) Ok(())
} }

View File

@ -70,7 +70,7 @@ pub enum ShellCommand {
} }
impl ShellCommand { impl ShellCommand {
pub fn in_env<E>(self, environment: &mut E) -> EnvCommand<E> { pub fn in_env<E>(self, environment: &mut E) -> EnvCommand<'_, E> {
EnvCommand { EnvCommand {
command: self, command: self,
environment, environment,
@ -129,6 +129,12 @@ impl From<&Path> for ScpParam {
} }
} }
impl From<&PathBuf> for ScpParam {
fn from(value: &PathBuf) -> Self {
Self::from(value.as_path())
}
}
impl From<&ScpParam> for OsString { impl From<&ScpParam> for OsString {
fn from(value: &ScpParam) -> Self { fn from(value: &ScpParam) -> Self {
let mut builder = osf!(); let mut builder = osf!();