diff --git a/docs/architecture/ssh-jump-hosts.md b/docs/architecture/ssh-jump-hosts.md index 1310934e..4474a32a 100644 --- a/docs/architecture/ssh-jump-hosts.md +++ b/docs/architecture/ssh-jump-hosts.md @@ -330,6 +330,64 @@ pub fn get_max_jump_hosts -> usize { - No compilation warnings (after clippy allows) - Successfully handles multi-hop scenarios +### SSH Config ProxyJump Integration (Issue #117 - Implemented) + +**Implementation:** `src/executor/connection_manager.rs`, `src/app/initialization.rs` + +The jump host resolution now integrates with SSH configuration files, automatically using `ProxyJump` directives when no CLI `-J` option is specified: + +**Priority Order:** +1. **CLI `-J` option** (highest priority) - Explicitly specified jump hosts +2. **SSH config `ProxyJump` directive** - Per-host configuration from `~/.ssh/config` +3. **None** - Direct connection (no jump host) + +**Implementation Details:** +```rust +// In connection_manager.rs execute_on_node_with_jump_hosts() +let ssh_config_jump_hosts = config + .ssh_config + .and_then(|ssh_config| ssh_config.get_proxy_jump(&node.host)); + +let effective_jump_hosts = if config.jump_hosts.is_some() { + config.jump_hosts // CLI takes precedence +} else { + ssh_config_jump_hosts.as_deref() // Fall back to SSH config +}; +``` + +**Example SSH Config:** +``` +Host *.internal + ProxyJump bastion.example.com + +Host db.internal + ProxyJump db-gateway.example.com +``` + +**Usage:** +```bash +# Automatically uses bastion.example.com from SSH config +bssh -H web.internal "uptime" + +# CLI option overrides SSH config +bssh -J custom-jump.example.com -H web.internal "uptime" + +# Most specific SSH config pattern wins +bssh -H db.internal "uptime" # Uses db-gateway.example.com +``` + +**Benefits:** +- Seamless integration with existing SSH workflows +- Centralized jump host configuration +- Per-host or wildcard pattern support +- No need to specify `-J` for frequently accessed internal hosts + +**Tests:** +- Added unit tests in `src/app/initialization.rs::tests` +- Tests verify CLI precedence over SSH config +- Tests verify wildcard pattern matching +- Tests verify fallback behavior + ### Known Limitations **Connection Pooling:** @@ -337,14 +395,14 @@ pub fn get_max_jump_hosts -> usize { - Each operation establishes fresh tunnel - **Rationale:** russh session limitations prevent connection reuse -**Configuration File Support:** -- Jump hosts only supported via CLI `-J` flag currently -- Configuration file support for per-cluster jump hosts is not implemented +**YAML Configuration File Support:** +- Jump hosts via YAML cluster config not yet implemented +- Only CLI `-J` and SSH config `ProxyJump` are supported - **Future Enhancement:** Add `jump_hosts` field to cluster configuration ### Future Enhancements -1. **Configuration File Support:** +1. **YAML Configuration File Support:** ```yaml clusters: production: diff --git a/src/app/dispatcher.rs b/src/app/dispatcher.rs index 2a6a349f..3b258e9d 100644 --- a/src/app/dispatcher.rs +++ b/src/app/dispatcher.rs @@ -114,6 +114,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> { use_agent: cli.use_agent, use_password: cli.password, recursive: *recursive, + ssh_config: Some(&ctx.ssh_config), }; upload_file(params, source, destination).await } @@ -138,6 +139,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> { use_agent: cli.use_agent, use_password: cli.password, recursive: *recursive, + ssh_config: Some(&ctx.ssh_config), }; download_file(params, source, destination).await } @@ -407,6 +409,7 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu sudo_password, batch: cli.batch, fail_fast: cli.fail_fast, + ssh_config: Some(&ctx.ssh_config), }; execute_command(params).await } diff --git a/src/app/initialization.rs b/src/app/initialization.rs index 9998c682..e8b006de 100644 --- a/src/app/initialization.rs +++ b/src/app/initialization.rs @@ -345,3 +345,96 @@ pub fn determine_use_keychain(ssh_config: &SshConfig, hostname: Option<&str>) -> pub fn determine_use_keychain(_ssh_config: &SshConfig, _hostname: Option<&str>) -> bool { false } + +/// Determine the effective jump hosts for a connection. +/// +/// Priority order: +/// 1. CLI `-J` option (highest priority) +/// 2. SSH config `ProxyJump` directive for the hostname +/// 3. None (direct connection) +/// +/// # Arguments +/// * `cli_jump_hosts` - Jump hosts specified via CLI `-J` option +/// * `ssh_config` - The loaded SSH configuration +/// * `hostname` - The target hostname to check for ProxyJump config +/// +/// # Returns +/// The effective jump host specification, or None for direct connection +#[allow(dead_code)] // Used for documentation and potential future use +pub fn determine_effective_jump_hosts( + cli_jump_hosts: Option<&str>, + ssh_config: &SshConfig, + hostname: &str, +) -> Option { + // CLI takes precedence + if let Some(jump) = cli_jump_hosts { + return Some(jump.to_string()); + } + + // Fall back to SSH config ProxyJump + ssh_config.get_proxy_jump(hostname) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determine_effective_jump_hosts_cli_takes_precedence() { + let ssh_config_content = r#" +Host example.com + ProxyJump bastion.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // CLI jump host should take precedence over SSH config + let result = determine_effective_jump_hosts( + Some("cli-jump.example.com"), + &ssh_config, + "example.com", + ); + assert_eq!(result, Some("cli-jump.example.com".to_string())); + } + + #[test] + fn test_determine_effective_jump_hosts_falls_back_to_ssh_config() { + let ssh_config_content = r#" +Host example.com + ProxyJump bastion.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // Should use SSH config when CLI jump host is not specified + let result = determine_effective_jump_hosts(None, &ssh_config, "example.com"); + assert_eq!(result, Some("bastion.example.com".to_string())); + } + + #[test] + fn test_determine_effective_jump_hosts_no_jump_host() { + let ssh_config = SshConfig::new(); + + // Should return None when no jump host is configured + let result = determine_effective_jump_hosts(None, &ssh_config, "example.com"); + assert_eq!(result, None); + } + + #[test] + fn test_determine_effective_jump_hosts_wildcard_pattern() { + let ssh_config_content = r#" +Host *.internal + ProxyJump gateway.company.com + +Host db.internal + ProxyJump db-gateway.company.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // Should match the most specific pattern + let result = determine_effective_jump_hosts(None, &ssh_config, "db.internal"); + assert_eq!(result, Some("db-gateway.company.com".to_string())); + + // Should match wildcard pattern + let result = determine_effective_jump_hosts(None, &ssh_config, "web.internal"); + assert_eq!(result, Some("gateway.company.com".to_string())); + } +} diff --git a/src/commands/download.rs b/src/commands/download.rs index 6e400d0c..1f8a8d62 100644 --- a/src/commands/download.rs +++ b/src/commands/download.rs @@ -49,7 +49,7 @@ pub async fn download_file( } let key_path_str = params.key_path.map(|p| p.to_string_lossy().to_string()); - let executor = ParallelExecutor::new_with_all_options( + let mut executor = ParallelExecutor::new_with_all_options( params.nodes.clone(), params.max_parallel, key_path_str.clone(), @@ -57,6 +57,9 @@ pub async fn download_file( params.use_agent, params.use_password, ); + if let Some(ssh_config) = params.ssh_config { + executor = executor.with_ssh_config(Some(ssh_config.clone())); + } // Check if source contains glob pattern let has_glob = validated_source.contains('*') @@ -114,8 +117,9 @@ pub async fn download_file( params.strict_mode, params.use_agent, params.use_password, - None, // No jump hosts from this code path yet + None, // No jump hosts from this code path (ssh_config handles ProxyJump) None, // Use default connect timeout + params.ssh_config, // Pass ssh_config for ProxyJump resolution ) .await; diff --git a/src/commands/exec.rs b/src/commands/exec.rs index afb03621..80ccc724 100644 --- a/src/commands/exec.rs +++ b/src/commands/exec.rs @@ -21,6 +21,7 @@ use crate::forwarding::ForwardingType; use crate::node::Node; use crate::security::SudoPassword; use crate::ssh::known_hosts::StrictHostKeyChecking; +use crate::ssh::SshConfig; use crate::ui::OutputFormatter; use crate::utils::output::save_outputs_to_files; @@ -47,6 +48,7 @@ pub struct ExecuteCommandParams<'a> { pub sudo_password: Option>, pub batch: bool, pub fail_fast: bool, + pub ssh_config: Option<&'a SshConfig>, } pub async fn execute_command(params: ExecuteCommandParams<'_>) -> Result<()> { @@ -214,7 +216,8 @@ async fn execute_command_without_forwarding(params: ExecuteCommandParams<'_>) -> .with_jump_hosts(params.jump_hosts.map(|s| s.to_string())) .with_sudo_password(params.sudo_password) .with_batch_mode(params.batch) - .with_fail_fast(params.fail_fast); + .with_fail_fast(params.fail_fast) + .with_ssh_config(params.ssh_config.cloned()); // Set keychain usage if on macOS #[cfg(target_os = "macos")] diff --git a/src/commands/upload.rs b/src/commands/upload.rs index 77ced743..92871fd2 100644 --- a/src/commands/upload.rs +++ b/src/commands/upload.rs @@ -19,6 +19,7 @@ use std::path::Path; use crate::executor::ParallelExecutor; use crate::node::Node; use crate::ssh::known_hosts::StrictHostKeyChecking; +use crate::ssh::SshConfig; use crate::ui::OutputFormatter; use crate::utils::fs::{format_bytes, resolve_source_files}; @@ -30,6 +31,7 @@ pub struct FileTransferParams<'a> { pub use_agent: bool, pub use_password: bool, pub recursive: bool, + pub ssh_config: Option<&'a SshConfig>, } pub async fn upload_file( @@ -76,7 +78,7 @@ pub async fn upload_file( ); let key_path_str = params.key_path.map(|p| p.to_string_lossy().to_string()); - let executor = ParallelExecutor::new_with_all_options( + let mut executor = ParallelExecutor::new_with_all_options( params.nodes.clone(), params.max_parallel, key_path_str.clone(), @@ -84,6 +86,9 @@ pub async fn upload_file( params.use_agent, params.use_password, ); + if let Some(ssh_config) = params.ssh_config { + executor = executor.with_ssh_config(Some(ssh_config.clone())); + } let mut total_success = 0; let mut total_failed = 0; diff --git a/src/executor/connection_manager.rs b/src/executor/connection_manager.rs index dbb318f5..de1b0551 100644 --- a/src/executor/connection_manager.rs +++ b/src/executor/connection_manager.rs @@ -23,7 +23,7 @@ use crate::security::SudoPassword; use crate::ssh::{ client::{CommandResult, ConnectionConfig}, known_hosts::StrictHostKeyChecking, - SshClient, + SshClient, SshConfig, }; /// Configuration for node execution. @@ -39,6 +39,7 @@ pub(crate) struct ExecutionConfig<'a> { pub connect_timeout: Option, pub jump_hosts: Option<&'a str>, pub sudo_password: Option>, + pub ssh_config: Option<&'a SshConfig>, } /// Execute a command on a node with jump host support. @@ -51,6 +52,20 @@ pub(crate) async fn execute_on_node_with_jump_hosts( let key_path = config.key_path.map(Path::new); + // Determine effective jump hosts: CLI takes precedence, then SSH config + // Store the SSH config jump hosts String to extend its lifetime + let ssh_config_jump_hosts = config + .ssh_config + .and_then(|ssh_config| ssh_config.get_proxy_jump(&node.host)); + + let effective_jump_hosts = if config.jump_hosts.is_some() { + // CLI jump hosts specified + config.jump_hosts + } else { + // Fall back to SSH config ProxyJump for this specific host + ssh_config_jump_hosts.as_deref() + }; + let connection_config = ConnectionConfig { key_path, strict_mode: Some(config.strict_mode), @@ -60,7 +75,7 @@ pub(crate) async fn execute_on_node_with_jump_hosts( use_keychain: config.use_keychain, timeout_seconds: config.timeout, connect_timeout_seconds: config.connect_timeout, - jump_hosts_spec: config.jump_hosts, + jump_hosts_spec: effective_jump_hosts, }; // If sudo password is provided, use streaming execution to handle prompts @@ -112,11 +127,22 @@ pub(crate) async fn upload_to_node( use_password: bool, jump_hosts: Option<&str>, connect_timeout_seconds: Option, + ssh_config: Option<&SshConfig>, ) -> Result<()> { let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone()); let key_path = key_path.map(Path::new); + // Determine effective jump hosts: CLI takes precedence, then SSH config + let ssh_config_jump_hosts = + ssh_config.and_then(|ssh_config| ssh_config.get_proxy_jump(&node.host)); + + let effective_jump_hosts = if jump_hosts.is_some() { + jump_hosts + } else { + ssh_config_jump_hosts.as_deref() + }; + // Check if the local path is a directory if local_path.is_dir() { client @@ -127,7 +153,7 @@ pub(crate) async fn upload_to_node( Some(strict_mode), use_agent, use_password, - jump_hosts, + effective_jump_hosts, connect_timeout_seconds, ) .await @@ -140,7 +166,7 @@ pub(crate) async fn upload_to_node( Some(strict_mode), use_agent, use_password, - jump_hosts, + effective_jump_hosts, connect_timeout_seconds, ) .await @@ -159,11 +185,22 @@ pub(crate) async fn download_from_node( use_password: bool, jump_hosts: Option<&str>, connect_timeout_seconds: Option, + ssh_config: Option<&SshConfig>, ) -> Result { let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone()); let key_path = key_path.map(Path::new); + // Determine effective jump hosts: CLI takes precedence, then SSH config + let ssh_config_jump_hosts = + ssh_config.and_then(|ssh_config| ssh_config.get_proxy_jump(&node.host)); + + let effective_jump_hosts = if jump_hosts.is_some() { + jump_hosts + } else { + ssh_config_jump_hosts.as_deref() + }; + // This function handles both files and directories // The caller should check if it's a directory and use the appropriate method client @@ -174,7 +211,7 @@ pub(crate) async fn download_from_node( Some(strict_mode), use_agent, use_password, - jump_hosts, + effective_jump_hosts, connect_timeout_seconds, ) .await?; @@ -194,11 +231,22 @@ pub async fn download_dir_from_node( use_password: bool, jump_hosts: Option<&str>, connect_timeout_seconds: Option, + ssh_config: Option<&SshConfig>, ) -> Result { let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone()); let key_path = key_path.map(Path::new); + // Determine effective jump hosts: CLI takes precedence, then SSH config + let ssh_config_jump_hosts = + ssh_config.and_then(|ssh_config| ssh_config.get_proxy_jump(&node.host)); + + let effective_jump_hosts = if jump_hosts.is_some() { + jump_hosts + } else { + ssh_config_jump_hosts.as_deref() + }; + client .download_dir_with_jump_hosts( remote_path, @@ -207,10 +255,198 @@ pub async fn download_dir_from_node( Some(strict_mode), use_agent, use_password, - jump_hosts, + effective_jump_hosts, connect_timeout_seconds, ) .await?; Ok(local_path.to_path_buf()) } + +/// Helper function to resolve effective jump hosts with priority: +/// 1. CLI jump hosts (highest priority) +/// 2. SSH config ProxyJump for the specific host +/// 3. None (direct connection) +/// +/// This is extracted for testing purposes and used internally by all connection functions. +#[allow(dead_code)] // Used for testing +#[inline] +fn resolve_effective_jump_hosts( + cli_jump_hosts: Option<&str>, + ssh_config: Option<&SshConfig>, + hostname: &str, +) -> Option { + if cli_jump_hosts.is_some() { + return cli_jump_hosts.map(String::from); + } + ssh_config.and_then(|config| config.get_proxy_jump(hostname)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that CLI jump hosts take precedence over SSH config + #[test] + fn test_resolve_effective_jump_hosts_cli_precedence() { + let ssh_config_content = r#" +Host example.com + ProxyJump bastion.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // CLI should override SSH config + let result = resolve_effective_jump_hosts( + Some("cli-bastion.example.com"), + Some(&ssh_config), + "example.com", + ); + assert_eq!(result, Some("cli-bastion.example.com".to_string())); + } + + /// Test that SSH config ProxyJump is used when no CLI jump hosts + #[test] + fn test_resolve_effective_jump_hosts_ssh_config_fallback() { + let ssh_config_content = r#" +Host example.com + ProxyJump bastion.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "example.com"); + assert_eq!(result, Some("bastion.example.com".to_string())); + } + + /// Test that no jump hosts is returned when neither CLI nor SSH config specifies one + #[test] + fn test_resolve_effective_jump_hosts_none() { + let ssh_config = SshConfig::new(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "example.com"); + assert_eq!(result, None); + } + + /// Test that no jump hosts is returned when SSH config is not provided + #[test] + fn test_resolve_effective_jump_hosts_no_ssh_config() { + let result = resolve_effective_jump_hosts(None, None, "example.com"); + assert_eq!(result, None); + } + + /// Test multi-hop ProxyJump chain from SSH config + #[test] + fn test_resolve_effective_jump_hosts_multi_hop() { + let ssh_config_content = r#" +Host internal.example.com + ProxyJump jump1.example.com,jump2.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "internal.example.com"); + assert_eq!( + result, + Some("jump1.example.com,jump2.example.com".to_string()) + ); + } + + /// Test ProxyJump with port specification + #[test] + fn test_resolve_effective_jump_hosts_with_port() { + let ssh_config_content = r#" +Host internal.example.com + ProxyJump bastion.example.com:2222 +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "internal.example.com"); + assert_eq!(result, Some("bastion.example.com:2222".to_string())); + } + + /// Test ProxyJump with user@host:port format + #[test] + fn test_resolve_effective_jump_hosts_with_user_and_port() { + let ssh_config_content = r#" +Host internal.example.com + ProxyJump admin@bastion.example.com:2222 +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "internal.example.com"); + assert_eq!(result, Some("admin@bastion.example.com:2222".to_string())); + } + + /// Test wildcard pattern matching for ProxyJump + #[test] + fn test_resolve_effective_jump_hosts_wildcard() { + let ssh_config_content = r#" +Host *.internal.example.com + ProxyJump gateway.example.com + +Host db.internal.example.com + ProxyJump db-gateway.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // Should match db.internal.example.com specifically + let result = + resolve_effective_jump_hosts(None, Some(&ssh_config), "db.internal.example.com"); + assert_eq!(result, Some("db-gateway.example.com".to_string())); + + // Should match wildcard pattern + let result = + resolve_effective_jump_hosts(None, Some(&ssh_config), "web.internal.example.com"); + assert_eq!(result, Some("gateway.example.com".to_string())); + } + + /// Test that unmatched hosts get no ProxyJump + #[test] + fn test_resolve_effective_jump_hosts_no_match() { + let ssh_config_content = r#" +Host *.internal.example.com + ProxyJump gateway.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // Should not match - different domain + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "external.example.com"); + assert_eq!(result, None); + } + + /// Test ProxyJump none value (disables jump) + #[test] + fn test_resolve_effective_jump_hosts_none_value() { + let ssh_config_content = r#" +Host *.example.com + ProxyJump gateway.example.com + +Host direct.example.com + ProxyJump none +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + // direct.example.com should have ProxyJump explicitly set to "none" + // Note: The actual handling of "none" as special value would be + // done by the connection layer, but the config should return it + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "direct.example.com"); + assert_eq!(result, Some("none".to_string())); + } + + /// Test complex multi-hop chain with user and ports + #[test] + fn test_resolve_effective_jump_hosts_complex_chain() { + let ssh_config_content = r#" +Host production.internal + ProxyJump user1@jump1.example.com:22,user2@jump2.example.com:2222,jump3.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).unwrap(); + + let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "production.internal"); + assert_eq!( + result, + Some( + "user1@jump1.example.com:22,user2@jump2.example.com:2222,jump3.example.com" + .to_string() + ) + ); + } +} diff --git a/src/executor/execution_strategy.rs b/src/executor/execution_strategy.rs index 47312a4b..41f06b13 100644 --- a/src/executor/execution_strategy.rs +++ b/src/executor/execution_strategy.rs @@ -116,6 +116,7 @@ pub(crate) async fn upload_file_task( use_password: bool, jump_hosts: Option, connect_timeout: Option, + ssh_config: Option, semaphore: Arc, pb: ProgressBar, ) -> UploadResult { @@ -142,6 +143,7 @@ pub(crate) async fn upload_file_task( use_password, jump_hosts.as_deref(), connect_timeout, + ssh_config.as_ref(), ) .await; @@ -176,6 +178,7 @@ pub(crate) async fn download_file_task( use_password: bool, jump_hosts: Option, connect_timeout: Option, + ssh_config: Option, semaphore: Arc, pb: ProgressBar, ) -> DownloadResult { @@ -214,6 +217,7 @@ pub(crate) async fn download_file_task( use_password, jump_hosts.as_deref(), connect_timeout, + ssh_config.as_ref(), ) .await; diff --git a/src/executor/parallel.rs b/src/executor/parallel.rs index 48c1be62..4e0cd7c3 100644 --- a/src/executor/parallel.rs +++ b/src/executor/parallel.rs @@ -24,6 +24,7 @@ use tokio::sync::Semaphore; use crate::node::Node; use crate::security::SudoPassword; use crate::ssh::known_hosts::StrictHostKeyChecking; +use crate::ssh::SshConfig; use super::connection_manager::{download_from_node, ExecutionConfig}; use super::execution_strategy::{ @@ -49,6 +50,7 @@ pub struct ParallelExecutor { pub(crate) sudo_password: Option>, pub(crate) batch: bool, pub(crate) fail_fast: bool, + pub(crate) ssh_config: Option, } impl ParallelExecutor { @@ -84,6 +86,7 @@ impl ParallelExecutor { sudo_password: None, batch: false, fail_fast: false, + ssh_config: None, } } @@ -110,6 +113,7 @@ impl ParallelExecutor { sudo_password: None, batch: false, fail_fast: false, + ssh_config: None, } } @@ -137,6 +141,7 @@ impl ParallelExecutor { sudo_password: None, batch: false, fail_fast: false, + ssh_config: None, } } @@ -158,6 +163,12 @@ impl ParallelExecutor { self } + /// Set SSH config for ProxyJump directive resolution. + pub fn with_ssh_config(mut self, ssh_config: Option) -> Self { + self.ssh_config = ssh_config; + self + } + /// Set whether to use macOS Keychain for passphrase storage/retrieval (macOS only). #[cfg(target_os = "macos")] pub fn with_keychain(mut self, use_keychain: bool) -> Self { @@ -228,6 +239,8 @@ impl ParallelExecutor { let semaphore = Arc::clone(&semaphore); let pb = setup_progress_bar(&multi_progress, &node, style.clone(), "Connecting..."); + let ssh_config_ref = self.ssh_config.clone(); + tokio::spawn(async move { let config = ExecutionConfig { key_path: key_path.as_deref(), @@ -240,6 +253,7 @@ impl ParallelExecutor { connect_timeout, jump_hosts: jump_hosts.as_deref(), sudo_password: sudo_password.clone(), + ssh_config: ssh_config_ref.as_ref(), }; execute_command_task(node, command, config, semaphore, pb).await @@ -331,6 +345,8 @@ impl ParallelExecutor { let mut handles: Vec> = Vec::with_capacity(self.nodes.len()); + let ssh_config_for_tasks = self.ssh_config.clone(); + // Spawn tasks for each node for node in &self.nodes { let node = node.clone(); @@ -348,6 +364,7 @@ impl ParallelExecutor { let semaphore = Arc::clone(&semaphore); let pb = setup_progress_bar(&multi_progress, &node, style.clone(), "Connecting..."); let mut cancel_rx = cancel_rx.clone(); + let ssh_config_ref = ssh_config_for_tasks.clone(); let handle = tokio::spawn(async move { // Check if already cancelled before acquiring semaphore @@ -410,6 +427,7 @@ impl ParallelExecutor { connect_timeout, jump_hosts: jump_hosts.as_deref(), sudo_password: sudo_password.clone(), + ssh_config: ssh_config_ref.as_ref(), }; // Execute the command (keeping the permit alive) @@ -575,6 +593,8 @@ impl ParallelExecutor { let semaphore = Arc::clone(&semaphore); let pb = setup_progress_bar(&multi_progress, &node, style.clone(), "Connecting..."); + let ssh_config_ref = self.ssh_config.clone(); + tokio::spawn(upload_file_task( node, local_path, @@ -585,6 +605,7 @@ impl ParallelExecutor { use_password, jump_hosts, connect_timeout, + ssh_config_ref, semaphore, pb, )) @@ -684,6 +705,7 @@ impl ParallelExecutor { let connect_timeout = self.connect_timeout; let semaphore = Arc::clone(&semaphore); let pb = setup_progress_bar(&multi_progress, &node, style.clone(), "Connecting..."); + let ssh_config_ref = self.ssh_config.clone(); tokio::spawn(download_file_task( node, @@ -695,6 +717,7 @@ impl ParallelExecutor { use_password, jump_hosts, connect_timeout, + ssh_config_ref, semaphore, pb, )) @@ -815,6 +838,7 @@ impl ParallelExecutor { let use_password = self.use_password; let jump_hosts = self.jump_hosts.clone(); let connect_timeout = self.connect_timeout; + let ssh_config_ref = self.ssh_config.clone(); tokio::spawn(async move { let _permit = match semaphore.acquire().await { @@ -840,6 +864,7 @@ impl ParallelExecutor { use_password, jump_hosts.as_deref(), connect_timeout, + ssh_config_ref.as_ref(), ) .await;