From df26eb435f105f8d6abb8c6a6fdc7c5c523ea690 Mon Sep 17 00:00:00 2001 From: Leonard Steppy Date: Thu, 27 Aug 2026 08:27:08 +0200 Subject: [PATCH 1/4] Ensure ssh agent is only started once --- src/environment.rs | 18 +++++++++++++++--- src/integration_test.rs | 8 ++++++++ src/main.rs | 7 +++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/environment.rs b/src/environment.rs index 6328bfd..1a95971 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -27,10 +27,14 @@ pub trait Environment { V: AsRef; fn get_home_directory(&self) -> Option; fn read_line(&mut self) -> Result; + fn is_ssh_agent_started(&self) -> bool; + fn set_ssh_agent_started(&mut self, enabled: bool); } #[derive(Debug, Default)] -pub struct Prod; +pub struct Prod { + ssh_agent_started: bool, +} impl Environment for Prod { fn args_os(&self) -> Vec { @@ -38,8 +42,8 @@ impl Environment for Prod { } fn var_os(&self, key: K) -> Option - where - K: AsRef + where + K: AsRef, { env::var_os(key) } @@ -68,6 +72,14 @@ impl Environment for Prod { io::stdin().read_line(&mut buffer)?; 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 { diff --git a/src/integration_test.rs b/src/integration_test.rs index 46358bf..d66866e 100644 --- a/src/integration_test.rs +++ b/src/integration_test.rs @@ -74,6 +74,14 @@ impl Environment for TestEnvironment { fn read_line(&mut self) -> Result { 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 { diff --git a/src/main.rs b/src/main.rs index 834541b..c42d9df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -706,6 +706,10 @@ where fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> { let env = &mut self.environment; + if env.is_ssh_agent_started() { + return Ok(()); + } + //start the ssh agent let agent_output = ShellCommand::SshAgent .in_env(env) @@ -732,6 +736,9 @@ where .and_expect_success() .into_result_with_error_logging(logger) .map_err(|e| format!("failed to add ssh-key: {e}"))?; + + env.set_ssh_agent_started(true); + Ok(()) } -- 2.34.1 From 952ca847cde9148fecdb44a03f688ee56f19ece1 Mon Sep 17 00:00:00 2001 From: Leonard Steppy Date: Mon, 31 Aug 2026 15:27:10 +0200 Subject: [PATCH 2/4] Start ssh agent only when needed --- src/main.rs | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index c42d9df..96c614c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 servers = args .servers @@ -223,8 +247,6 @@ where None => None, }; - self.start_ssh_agent(&logger)?; - //make sure files exist match &file_server { Some(file_server) => match &file_server.address { @@ -233,7 +255,7 @@ where files = files .iter() .map(|file| { - let output = ShellCommand::Ssh { + let output = ssh! { address: ssh_address.to_string(), server_command: ServerCommand::Realpath { path: file_server.server_directory_path.join(file), @@ -313,7 +335,7 @@ where server, actions: { let present_file_names: Vec = match &server.address { - ServerAddress::Ssh { ssh_address } => ShellCommand::Ssh { + ServerAddress::Ssh { ssh_address } => ssh! { address: ssh_address.to_string(), server_command: ServerCommand::Ls { dir: working_directory.clone(), @@ -472,7 +494,7 @@ where None => ScpParam::from(file_action.file.as_path()), }; let destination = ScpParam::from((server, &server_actions.working_directory)); - ShellCommand::Scp { + scp! { source, destination, } @@ -484,7 +506,7 @@ where } Action::Delete => match &server.address { ServerAddress::Ssh { ssh_address } => { - ShellCommand::Ssh { + ssh! { address: ssh_address.to_string(), server_command: ServerCommand::Rm { file: server_actions.working_directory.join(&file_action.file), @@ -503,7 +525,7 @@ where }, Action::Rename { new_name } => match &server.address { ServerAddress::Ssh { ssh_address } => { - ShellCommand::Ssh { + ssh! { address: ssh_address.to_string(), server_command: ServerCommand::Mv { source: server_actions.working_directory.join(&file_action.file), @@ -529,13 +551,12 @@ where log!(logger, "Done!"); } Command::Command { command } => { - self.start_ssh_agent(&logger)?; Self::require_non_empty_servers(&servers)?; for server in servers { log!(logger, "Running command on '{}'...", server.get_name()); match &server.address { ServerAddress::Ssh { ssh_address } => { - ShellCommand::Ssh { + ssh! { address: ssh_address.to_string(), server_command: ServerCommand::Execute { working_directory: server.server_directory_path.clone(), @@ -625,12 +646,11 @@ where } Self::require_non_empty_servers(&servers)?; - self.start_ssh_agent(&logger)?; for server in servers { log!(logger, "Getting file from {}...", server.get_name()); let source = ScpParam::from((&server, server.server_directory_path.join(&file))); - ShellCommand::Scp { + scp! { source: source.clone(), destination: ScpParam::from(download_directory.as_path()), } @@ -657,7 +677,7 @@ where .map_err(|e| format!("failed to open file in editor: {e}"))?; //upload file again - ShellCommand::Scp { + scp! { source: ScpParam::from(download_directory.join(file_name).as_path()), destination: source, } -- 2.34.1 From a4d7fd7506f6ba06258a91a5827197a736ef3a00 Mon Sep 17 00:00:00 2001 From: Leonard Steppy Date: Mon, 31 Aug 2026 15:40:44 +0200 Subject: [PATCH 3/4] Fix nix build by updating dependencies --- Cargo.toml | 10 +++++----- flake.lock | 14 +++++++------- flake.nix | 2 +- src/environment.rs | 11 +++++++---- src/shell_interface.rs | 2 +- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a9bc08b..f3fb68f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "multi-ssh" version = "0.2.0" -edition = "2021" +edition = "2024" [dependencies] -clap = { version = "4.5.23", features = ["derive"] } -lazy-regex = "3.3.0" -shell-words = "1.1.0" -homedir = "0.3.4" \ No newline at end of file +clap = { version = "4.6.6", features = ["derive"] } +lazy-regex = "3.6.1" +shell-words = "1.1.1" +homedir = "0.3.6" \ No newline at end of file diff --git a/flake.lock b/flake.lock index fd92169..8655acd 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1759580034, - "narHash": "sha256-YWo57PL7mGZU7D4WeKFMiW4ex/O6ZolUS6UNBHTZfkI=", + "lastModified": 1787962033, + "narHash": "sha256-u6z9VTZA4Kf3RkHQo9sQI7NI4Ei/uiU9vrMqOiwWP1Y=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3bcc93c5f7a4b30335d31f21e2f1281cba68c318", + "rev": "c5c4a43b0e8056328ec4529f735cabdb8f1942bb", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } @@ -43,11 +43,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1759631821, - "narHash": "sha256-V8A1L0FaU/aSXZ1QNJScxC12uP4hANeRBgI4YdhHeRM=", + "lastModified": 1788165049, + "narHash": "sha256-en4IoUeCqvq9F66YhwOrUFw1nc70OBhrJwrzfalezvY=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "1d7cbdaad90f8a5255a89a6eddd8af24dc89cafe", + "rev": "d03cd474bd97389dcc2e8cd3b3bb6b8c6e346b1a", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 637c382..2d612de 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; rust-overlay.url = "github:oxalica/rust-overlay"; }; diff --git a/src/environment.rs b/src/environment.rs index 1a95971..a8646e1 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -1,6 +1,6 @@ -use crate::shell_interface::{ - build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand, - ShellInterface, StartError, +use crate::shell_interface::{CommandOutput, CommandResult, ExitStatus, ShellCommand, + ShellInterface, StartError, + build_command_from_shell_command, }; use std::env::VarError; use std::ffi::{OsStr, OsString}; @@ -60,7 +60,10 @@ impl Environment for Prod { K: AsRef, V: AsRef, { - env::set_var(key, value); + unsafe { + //multi-ssh is single threaded + env::set_var(key, value); + } } fn get_home_directory(&self) -> Option { diff --git a/src/shell_interface.rs b/src/shell_interface.rs index 1bf318d..7407a69 100644 --- a/src/shell_interface.rs +++ b/src/shell_interface.rs @@ -70,7 +70,7 @@ pub enum ShellCommand { } impl ShellCommand { - pub fn in_env(self, environment: &mut E) -> EnvCommand { + pub fn in_env(self, environment: &mut E) -> EnvCommand<'_, E> { EnvCommand { command: self, environment, -- 2.34.1 From ffc3c478d9cf2fb19bb7734c15b4f0e4a617e8e5 Mon Sep 17 00:00:00 2001 From: Leonard Steppy Date: Mon, 31 Aug 2026 21:27:06 +0200 Subject: [PATCH 4/4] Don't use scp on localhost --- src/main.rs | 109 +++++++++++++++++++++++++++-------------- src/shell_interface.rs | 6 +++ 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/src/main.rs b/src/main.rs index 96c614c..b2ed947 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use crate::os_string_builder::ReplaceWithOsStr; use crate::server::{RelativeLocalPathAnker, ServerAddress}; use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface}; use clap::{Parser, Subcommand, ValueEnum}; -use lazy_regex::{lazy_regex, Lazy, Regex}; +use lazy_regex::{Lazy, Regex, lazy_regex}; use server::{Server, ServerReference}; use std::cell::LazyCell; use std::ffi::OsString; @@ -486,23 +486,36 @@ where for file_action in server_actions.actions { match file_action.kind { Action::Add | Action::Replace => { - let source = match &file_server { - Some(file_server) => ScpParam::from(( - file_server, - file_server.server_directory_path.join(&file_action.file), - )), - None => ScpParam::from(file_action.file.as_path()), - }; - let destination = ScpParam::from((server, &server_actions.working_directory)); - scp! { - source, - destination, + //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 { + Some(file_server) => ScpParam::from(( + file_server, + file_server.server_directory_path.join(&file_action.file), + )), + None => ScpParam::from(&file_action.file), + }; + let destination = ScpParam::from((server, &server_actions.working_directory)); + scp! { + source, + destination, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("upload failure: {e}"))?; } - .in_env(env!()) - .run_logged(&logger) - .and_expect_success() - .into_result_with_error_logging(&logger) - .map_err(|e| format!("upload failure: {e}"))?; } Action::Delete => match &server.address { ServerAddress::Ssh { ssh_address } => { @@ -649,24 +662,34 @@ where for server in servers { log!(logger, "Getting file from {}...", server.get_name()); - let source = ScpParam::from((&server, server.server_directory_path.join(&file))); - scp! { - source: source.clone(), - destination: ScpParam::from(download_directory.as_path()), + let file_path = server.server_directory_path.join(&file); + let downloaded_file_path = download_directory.join(file_name); + if matches!(server.address, ServerAddress::Localhost) { + //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!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("download failure: {e}"))?; } - .in_env(env!()) - .run_logged(&logger) - .and_expect_success() - .into_result_with_error_logging(&logger) - .map_err(|e| format!("download failure: {e}"))?; //open file in editor let editor_command = shell_words::split(&editor) .map_err(|e| format!("failed to parse editor command: {e}"))? .into_iter() - .map(|part| { - part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name)) - }) + .map(|part| part.replace_with_os_str(FILE_PLACEHOLDER, &file_path)) .collect::>(); ShellCommand::Editor(editor_command) @@ -676,16 +699,26 @@ where .into_result_with_error_logging(&logger) .map_err(|e| format!("failed to open file in editor: {e}"))?; - //upload file again - scp! { - source: ScpParam::from(download_directory.join(file_name).as_path()), - destination: source, + //upload file again; don't use scp on localhost + if matches!(server.address, ServerAddress::Localhost) { + fs::copy(&downloaded_file_path, &file_path).map_err(|e| { + 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!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to re-upload file: {e}"))?; } - .in_env(env!()) - .run_logged(&logger) - .and_expect_success() - .into_result_with_error_logging(&logger) - .map_err(|e| format!("failed to re-upload file: {e}"))?; } log!(logger, "Done!"); diff --git a/src/shell_interface.rs b/src/shell_interface.rs index 7407a69..d2a19c1 100644 --- a/src/shell_interface.rs +++ b/src/shell_interface.rs @@ -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 { fn from(value: &ScpParam) -> Self { let mut builder = osf!(); -- 2.34.1