diff --git a/README.md b/README.md index 39aa6881..49723df7 100644 --- a/README.md +++ b/README.md @@ -1241,6 +1241,9 @@ bssh -H server1,server2 interactive --prompt-format "{user}@{host}> " # Set initial working directory bssh -C staging interactive --work-dir /var/www + +# Interactive mode with keepalive for long-running sessions (e.g., tmux) +bssh -C production --server-alive-interval 30 --server-alive-count-max 5 interactive ``` #### Interactive Mode Configuration diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 6f2d42f6..5655439e 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1581,6 +1581,14 @@ Interactive mode with initial working directory: Sets initial working directory to /var/www on all nodes .RE +.TP +Interactive mode with keepalive for long-running sessions: +.B bssh -C production --server-alive-interval 30 --server-alive-count-max 5 interactive +.RS +Configure SSH keepalive settings to prevent idle disconnection in long-running sessions (e.g., tmux). +The keepalive settings apply to both the destination host and any jump hosts in the connection chain. +.RE + .SS Exit Code Handling Examples (v1.2.0+) .TP diff --git a/examples/interactive_demo.rs b/examples/interactive_demo.rs index 9aaff8cf..81ec33db 100644 --- a/examples/interactive_demo.rs +++ b/examples/interactive_demo.rs @@ -19,6 +19,7 @@ use bssh::config::{Config, InteractiveConfig}; use bssh::node::Node; use bssh::pty::PtyConfig; use bssh::ssh::known_hosts::StrictHostKeyChecking; +use bssh::ssh::tokio_client::SshConnectionConfig; use std::path::PathBuf; #[tokio::main] @@ -59,6 +60,7 @@ async fn main() -> anyhow::Result<()> { jump_hosts: None, pty_config: PtyConfig::default(), use_pty: None, + ssh_connection_config: SshConnectionConfig::default(), }; println!("Starting interactive session..."); diff --git a/src/app/dispatcher.rs b/src/app/dispatcher.rs index 7dfdf9bf..0475d127 100644 --- a/src/app/dispatcher.rs +++ b/src/app/dispatcher.rs @@ -38,6 +38,51 @@ use super::initialization::determine_use_keychain; use super::initialization::{determine_ssh_key_path, AppContext}; use super::utils::format_duration; +/// Build SSH connection config with keepalive settings. +/// Precedence: CLI > SSH config > YAML config > defaults +fn build_ssh_connection_config( + cli: &Cli, + ctx: &AppContext, + hostname: Option<&str>, + cluster_name: Option<&str>, +) -> SshConnectionConfig { + let keepalive_interval = cli + .server_alive_interval + .or_else(|| { + ctx.ssh_config + .get_int_option(hostname, "serveraliveinterval") + .map(|v| v as u64) + }) + .or_else(|| ctx.config.get_server_alive_interval(cluster_name)) + .unwrap_or(DEFAULT_KEEPALIVE_INTERVAL); + + let keepalive_max = cli + .server_alive_count_max + .or_else(|| { + ctx.ssh_config + .get_int_option(hostname, "serveralivecountmax") + .map(|v| v as usize) + }) + .or_else(|| ctx.config.get_server_alive_count_max(cluster_name)) + .unwrap_or(DEFAULT_KEEPALIVE_MAX); + + let ssh_connection_config = SshConnectionConfig::new() + .with_keepalive_interval(if keepalive_interval == 0 { + None + } else { + Some(keepalive_interval) + }) + .with_keepalive_max(keepalive_max); + + tracing::debug!( + "SSH keepalive config: interval={:?}s, max={}", + ssh_connection_config.keepalive_interval, + ssh_connection_config.keepalive_max + ); + + ssh_connection_config +} + /// Dispatch commands to their appropriate handlers pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> { // Get command to execute @@ -277,6 +322,11 @@ async fn handle_interactive_command( .get_cluster_jump_host(ctx.cluster_name.as_deref().or(cli.cluster.as_deref())) }); + // Build SSH connection config with keepalive settings for interactive mode + let effective_cluster_name = ctx.cluster_name.as_deref().or(cli.cluster.as_deref()); + let ssh_connection_config = + build_ssh_connection_config(cli, ctx, hostname.as_deref(), effective_cluster_name); + let interactive_cmd = InteractiveCommand { single_node: merged_mode.0, multiplex: merged_mode.1, @@ -296,6 +346,7 @@ async fn handle_interactive_command( jump_hosts, pty_config, use_pty, + ssh_connection_config, }; let result = interactive_cmd.execute().await?; @@ -345,6 +396,11 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu .get_cluster_jump_host(ctx.cluster_name.as_deref().or(cli.cluster.as_deref())) }); + // Build SSH connection config with keepalive settings for SSH mode interactive session + let effective_cluster_name = ctx.cluster_name.as_deref().or(cli.cluster.as_deref()); + let ssh_connection_config = + build_ssh_connection_config(cli, ctx, hostname.as_deref(), effective_cluster_name); + let interactive_cmd = InteractiveCommand { single_node: true, multiplex: false, @@ -364,6 +420,7 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu jump_hosts, pty_config, use_pty, + ssh_connection_config, }; let result = interactive_cmd.execute().await?; @@ -434,43 +491,9 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu tracing::info!("Using jump host: {}", jh); } - // Build SSH connection config with precedence: CLI > SSH config > YAML config > defaults - let keepalive_interval = cli - .server_alive_interval - .or_else(|| { - ctx.ssh_config - .get_int_option(hostname.as_deref(), "serveraliveinterval") - .map(|v| v as u64) - }) - .or_else(|| ctx.config.get_server_alive_interval(effective_cluster_name)) - .unwrap_or(DEFAULT_KEEPALIVE_INTERVAL); - - let keepalive_max = cli - .server_alive_count_max - .or_else(|| { - ctx.ssh_config - .get_int_option(hostname.as_deref(), "serveralivecountmax") - .map(|v| v as usize) - }) - .or_else(|| { - ctx.config - .get_server_alive_count_max(effective_cluster_name) - }) - .unwrap_or(DEFAULT_KEEPALIVE_MAX); - - let ssh_connection_config = SshConnectionConfig::new() - .with_keepalive_interval(if keepalive_interval == 0 { - None - } else { - Some(keepalive_interval) - }) - .with_keepalive_max(keepalive_max); - - tracing::debug!( - "SSH keepalive config: interval={:?}s, max={}", - ssh_connection_config.keepalive_interval, - ssh_connection_config.keepalive_max - ); + // Build SSH connection config with keepalive settings for exec mode + let ssh_connection_config = + build_ssh_connection_config(cli, ctx, hostname.as_deref(), effective_cluster_name); let params = ExecuteCommandParams { nodes: ctx.nodes.clone(), diff --git a/src/commands/interactive/connection.rs b/src/commands/interactive/connection.rs index 0884bce8..7b4318c5 100644 --- a/src/commands/interactive/connection.rs +++ b/src/commands/interactive/connection.rs @@ -26,7 +26,7 @@ use crate::jump::{parse_jump_hosts, JumpHostChain}; use crate::node::Node; use crate::ssh::{ known_hosts::get_check_method, - tokio_client::{AuthMethod, Client, Error as SshError, ServerCheckMethod}, + tokio_client::{AuthMethod, Client, Error as SshError, ServerCheckMethod, SshConnectionConfig}, }; use super::types::{InteractiveCommand, NodeSession}; @@ -37,6 +37,9 @@ impl InteractiveCommand { /// /// If `allow_password_fallback` is true and key authentication fails, it will prompt for password /// and retry with password authentication (matching OpenSSH behavior). + /// + /// The `ssh_config` parameter allows configuring SSH connection settings like keepalive intervals. + #[allow(clippy::too_many_arguments)] async fn establish_connection( addr: (&str, u16), username: &str, @@ -45,6 +48,7 @@ impl InteractiveCommand { host: &str, port: u16, allow_password_fallback: bool, + ssh_config: &SshConnectionConfig, ) -> Result { const SSH_CONNECT_TIMEOUT_SECS: u64 = 30; let connect_timeout = Duration::from_secs(SSH_CONNECT_TIMEOUT_SECS); @@ -59,9 +63,10 @@ impl InteractiveCommand { // SECURITY: Capture start time for timing attack mitigation let start_time = std::time::Instant::now(); + // Use connect_with_ssh_config to properly apply keepalive settings let result = timeout( connect_timeout, - Client::connect(addr, username, auth_method, check_method.clone()), + Client::connect_with_ssh_config(addr, username, auth_method, check_method.clone(), ssh_config), ) .await .with_context(|| { @@ -93,9 +98,10 @@ impl InteractiveCommand { // Small delay before retry to prevent rapid attempts tokio::time::sleep(Duration::from_millis(500)).await; + // Use connect_with_ssh_config for password retry as well timeout( connect_timeout, - Client::connect(addr, username, password_auth, check_method), + Client::connect_with_ssh_config(addr, username, password_auth, check_method, ssh_config), ) .await .with_context(|| { @@ -231,6 +237,7 @@ impl InteractiveCommand { &node.host, node.port, !self.use_password, // Allow fallback unless explicit password mode + &self.ssh_connection_config, ) .await? } else { @@ -255,9 +262,11 @@ impl InteractiveCommand { .min(MAX_TIMEOUT_SECS), ); + // Pass SSH connection config to jump host chain for keepalive settings let chain = JumpHostChain::new(jump_hosts) .with_connect_timeout(adjusted_timeout) - .with_command_timeout(Duration::from_secs(300)); + .with_command_timeout(Duration::from_secs(300)) + .with_ssh_connection_config(self.ssh_connection_config.clone()); // Connect through the chain let connection = timeout( @@ -308,6 +317,7 @@ impl InteractiveCommand { &node.host, node.port, !self.use_password, // Allow fallback unless explicit password mode + &self.ssh_connection_config, ) .await? }; @@ -371,6 +381,7 @@ impl InteractiveCommand { &node.host, node.port, !self.use_password, // Allow fallback unless explicit password mode + &self.ssh_connection_config, ) .await? } else { @@ -395,9 +406,11 @@ impl InteractiveCommand { .min(MAX_TIMEOUT_SECS), ); + // Pass SSH connection config to jump host chain for keepalive settings let chain = JumpHostChain::new(jump_hosts) .with_connect_timeout(adjusted_timeout) - .with_command_timeout(Duration::from_secs(300)); + .with_command_timeout(Duration::from_secs(300)) + .with_ssh_connection_config(self.ssh_connection_config.clone()); // Connect through the chain let connection = timeout( @@ -448,6 +461,7 @@ impl InteractiveCommand { &node.host, node.port, !self.use_password, // Allow fallback unless explicit password mode + &self.ssh_connection_config, ) .await? }; diff --git a/src/commands/interactive/types.rs b/src/commands/interactive/types.rs index f016a819..ab2d9a18 100644 --- a/src/commands/interactive/types.rs +++ b/src/commands/interactive/types.rs @@ -24,7 +24,7 @@ use crate::config::{Config, InteractiveConfig}; use crate::node::Node; use crate::pty::PtyConfig; use crate::ssh::known_hosts::StrictHostKeyChecking; -use crate::ssh::tokio_client::Client; +use crate::ssh::tokio_client::{Client, SshConnectionConfig}; /// SSH output polling interval for responsive display /// - 10ms provides very responsive output display @@ -61,6 +61,8 @@ pub struct InteractiveCommand { // PTY configuration pub pty_config: PtyConfig, pub use_pty: Option, // None = auto-detect, Some(true) = force, Some(false) = disable + // SSH connection configuration (keepalive settings) + pub ssh_connection_config: SshConnectionConfig, } /// Result of an interactive session diff --git a/src/commands/interactive/utils.rs b/src/commands/interactive/utils.rs index c8101962..eabe0b75 100644 --- a/src/commands/interactive/utils.rs +++ b/src/commands/interactive/utils.rs @@ -71,6 +71,7 @@ mod tests { use crate::config::{Config, InteractiveConfig}; use crate::pty::PtyConfig; use crate::ssh::known_hosts::StrictHostKeyChecking; + use crate::ssh::tokio_client::SshConnectionConfig; #[test] fn test_expand_path_with_tilde() { @@ -93,6 +94,7 @@ mod tests { jump_hosts: None, pty_config: PtyConfig::default(), use_pty: None, + ssh_connection_config: SshConnectionConfig::default(), }; let path = PathBuf::from("~/test/file.txt"); @@ -126,6 +128,7 @@ mod tests { jump_hosts: None, pty_config: PtyConfig::default(), use_pty: None, + ssh_connection_config: SshConnectionConfig::default(), }; let node = Node::new(String::from("example.com"), 22, String::from("alice")); diff --git a/src/pty/session/constants.rs b/src/pty/session/constants.rs index 0f6c82c5..234c52a8 100644 --- a/src/pty/session/constants.rs +++ b/src/pty/session/constants.rs @@ -61,6 +61,19 @@ pub const INPUT_POLL_TIMEOUT_MS: u64 = 500; /// - Tasks should check cancellation signal frequently (10-50ms intervals) pub const TASK_CLEANUP_TIMEOUT_MS: u64 = 100; +/// Connection health check interval for PTY sessions +/// - 30 seconds provides periodic checks without excessive overhead +/// - Detects dead connections even when SSH keepalive is disabled +/// - Works alongside SSH-level keepalive for defense in depth +/// - Short enough to detect issues before users get frustrated +pub const CONNECTION_HEALTH_CHECK_INTERVAL_SECS: u64 = 30; + +/// Maximum idle time before considering connection potentially dead +/// - 300 seconds (5 minutes) is a reasonable threshold for interactive sessions +/// - If no data received within this time, trigger a health check warning +/// - This is a secondary mechanism to SSH-level keepalive +pub const MAX_IDLE_TIME_BEFORE_WARNING_SECS: u64 = 300; + // Const arrays for frequently used key sequences to avoid repeated allocations. /// Control key sequences - frequently used in terminal input pub const CTRL_C_SEQUENCE: &[u8] = &[0x03]; // Ctrl+C (SIGINT) diff --git a/src/pty/session/session_manager.rs b/src/pty/session/session_manager.rs index b79bdf1a..2b59a9a8 100644 --- a/src/pty/session/session_manager.rs +++ b/src/pty/session/session_manager.rs @@ -299,10 +299,20 @@ impl PtySession { let mut should_terminate = false; let mut cancel_rx = self.cancel_rx.clone(); + // Track last activity time for connection health monitoring + let mut last_activity = std::time::Instant::now(); + let health_check_interval = Duration::from_secs(CONNECTION_HEALTH_CHECK_INTERVAL_SECS); + let max_idle_time = Duration::from_secs(MAX_IDLE_TIME_BEFORE_WARNING_SECS); + let mut idle_warning_shown = false; + while !should_terminate { tokio::select! { // Handle SSH channel messages msg = self.channel.wait() => { + // Reset activity timer on any channel activity + last_activity = std::time::Instant::now(); + idle_warning_shown = false; + match msg { Some(ChannelMsg::Data { ref data }) => { // Filter terminal escape sequence responses before display @@ -342,7 +352,10 @@ impl PtySession { // Handle other channel messages if needed } None => { - // Channel ended + // Channel ended - connection is dead + tracing::warn!( + "SSH channel returned None - connection may have dropped" + ); should_terminate = true; } } @@ -350,10 +363,18 @@ impl PtySession { // Handle local messages (input, resize, etc.) message = msg_rx.recv() => { + // Reset activity timer for local input (user is active) + if matches!(message, Some(PtyMessage::LocalInput(_))) { + last_activity = std::time::Instant::now(); + idle_warning_shown = false; + } + match message { Some(PtyMessage::LocalInput(data)) => { if let Err(e) = self.channel.data(data.as_slice()).await { tracing::error!("Failed to send data to SSH channel: {e}"); + // Connection likely dead - terminate gracefully + eprintln!("\r\n[bssh] Connection lost: failed to send data to remote host\r"); should_terminate = true; } } @@ -400,6 +421,30 @@ impl PtySession { should_terminate = true; } } + + // Periodic health check to detect dead connections + _ = tokio::time::sleep(health_check_interval) => { + let idle_duration = last_activity.elapsed(); + + // Check if the session has been idle for too long + if idle_duration > max_idle_time && !idle_warning_shown { + tracing::debug!( + "PTY session {} idle for {:?}, connection may be stale", + self.session_id, + idle_duration + ); + // Don't terminate, but log for debugging + // SSH keepalive should handle actual connection detection + idle_warning_shown = true; + } + + // Periodic trace logging for debugging long sessions + tracing::trace!( + "PTY session {} health check: idle for {:?}", + self.session_id, + idle_duration + ); + } } } diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index cbb1ebf8..aa6b96e6 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -212,11 +212,10 @@ impl SshConfig { { // Check if there's at least a matching host pattern // If not, this alias doesn't exist in SSH config - let has_matching_pattern = self.hosts.iter().any(|h| { - h.host_patterns - .iter() - .any(|p| p == host_alias || p == "*") - }); + let has_matching_pattern = self + .hosts + .iter() + .any(|h| h.host_patterns.iter().any(|p| p == host_alias || p == "*")); if !has_matching_pattern { return None; diff --git a/tests/interactive_integration_test.rs b/tests/interactive_integration_test.rs index 32d449ec..61b83746 100644 --- a/tests/interactive_integration_test.rs +++ b/tests/interactive_integration_test.rs @@ -19,6 +19,7 @@ use bssh::config::{Config, InteractiveConfig}; use bssh::node::Node; use bssh::pty::PtyConfig; use bssh::ssh::known_hosts::StrictHostKeyChecking; +use bssh::ssh::tokio_client::SshConnectionConfig; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; @@ -52,6 +53,7 @@ fn test_interactive_command_builder() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert!(!cmd.single_node); @@ -86,6 +88,7 @@ fn test_history_file_handling() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert_eq!(cmd.history_file, history_path); @@ -183,6 +186,7 @@ async fn test_interactive_with_unreachable_nodes() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; // This should fail to connect @@ -217,6 +221,7 @@ async fn test_interactive_with_no_nodes() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; let result = cmd.execute().await; @@ -261,6 +266,7 @@ fn test_mode_configuration() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert!(single_cmd.single_node); @@ -286,6 +292,7 @@ fn test_mode_configuration() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert!(!multi_cmd.single_node); @@ -314,6 +321,7 @@ fn test_working_directory_config() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert_eq!(cmd_with_dir.work_dir, Some("/var/www".to_string())); @@ -337,6 +345,7 @@ fn test_working_directory_config() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert_eq!(cmd_without_dir.work_dir, None); @@ -372,6 +381,7 @@ fn test_prompt_format() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert_eq!(cmd.prompt_format, format); diff --git a/tests/interactive_test.rs b/tests/interactive_test.rs index 9fa7b116..e621b72a 100644 --- a/tests/interactive_test.rs +++ b/tests/interactive_test.rs @@ -17,6 +17,7 @@ use bssh::config::{Config, InteractiveConfig}; use bssh::node::Node; use bssh::pty::PtyConfig; use bssh::ssh::known_hosts::StrictHostKeyChecking; +use bssh::ssh::tokio_client::SshConnectionConfig; use std::path::PathBuf; #[tokio::test] @@ -40,6 +41,7 @@ async fn test_interactive_command_creation() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; assert!(!cmd.single_node); @@ -68,6 +70,7 @@ async fn test_interactive_with_no_nodes() { pty_config: PtyConfig::default(), use_pty: None, jump_hosts: None, + ssh_connection_config: SshConnectionConfig::default(), }; let result = cmd.execute().await; diff --git a/tests/jump_host_config_test.rs b/tests/jump_host_config_test.rs index f4f0f973..8b0db156 100644 --- a/tests/jump_host_config_test.rs +++ b/tests/jump_host_config_test.rs @@ -690,7 +690,12 @@ clusters: .unwrap() .is_ssh_config_ref()); assert_eq!( - cluster.defaults.jump_host.as_ref().unwrap().ssh_config_host(), + cluster + .defaults + .jump_host + .as_ref() + .unwrap() + .ssh_config_host(), Some("bastion") ); } diff --git a/tests/ssh_keepalive_test.rs b/tests/ssh_keepalive_test.rs index 03e24535..74764fd7 100644 --- a/tests/ssh_keepalive_test.rs +++ b/tests/ssh_keepalive_test.rs @@ -639,3 +639,200 @@ fn test_parallel_executor_chain_multiple_configs() { // If we got here without panicking, all configs were set correctly } + +// ============================================================================= +// Interactive Mode Keepalive Tests +// ============================================================================= + +#[test] +fn test_interactive_mode_ssh_connection_config_default() { + // Test that InteractiveCommand can be created with default SshConnectionConfig + // This verifies the field was added correctly + use bssh::commands::interactive::InteractiveCommand; + use bssh::config::{Config, InteractiveConfig}; + use bssh::pty::PtyConfig; + use bssh::ssh::known_hosts::StrictHostKeyChecking; + use std::path::PathBuf; + + let cmd = InteractiveCommand { + single_node: true, + multiplex: false, + prompt_format: "[{user}@{host}]$ ".to_string(), + history_file: PathBuf::from("~/.bssh_history"), + work_dir: None, + nodes: vec![], + config: Config::default(), + interactive_config: InteractiveConfig::default(), + cluster_name: None, + key_path: None, + use_agent: false, + use_password: false, + #[cfg(target_os = "macos")] + use_keychain: false, + strict_mode: StrictHostKeyChecking::AcceptNew, + jump_hosts: None, + pty_config: PtyConfig::default(), + use_pty: None, + ssh_connection_config: SshConnectionConfig::default(), + }; + + // Verify default values are applied + assert_eq!( + cmd.ssh_connection_config.keepalive_interval, + Some(DEFAULT_KEEPALIVE_INTERVAL), + "InteractiveCommand should have default keepalive interval" + ); + assert_eq!( + cmd.ssh_connection_config.keepalive_max, DEFAULT_KEEPALIVE_MAX, + "InteractiveCommand should have default keepalive max" + ); +} + +#[test] +fn test_interactive_mode_ssh_connection_config_custom() { + // Test that InteractiveCommand can be created with custom SshConnectionConfig + use bssh::commands::interactive::InteractiveCommand; + use bssh::config::{Config, InteractiveConfig}; + use bssh::pty::PtyConfig; + use bssh::ssh::known_hosts::StrictHostKeyChecking; + use std::path::PathBuf; + + let custom_config = SshConnectionConfig::new() + .with_keepalive_interval(Some(120)) + .with_keepalive_max(10); + + let cmd = InteractiveCommand { + single_node: true, + multiplex: false, + prompt_format: "[{user}@{host}]$ ".to_string(), + history_file: PathBuf::from("~/.bssh_history"), + work_dir: None, + nodes: vec![], + config: Config::default(), + interactive_config: InteractiveConfig::default(), + cluster_name: None, + key_path: None, + use_agent: false, + use_password: false, + #[cfg(target_os = "macos")] + use_keychain: false, + strict_mode: StrictHostKeyChecking::AcceptNew, + jump_hosts: None, + pty_config: PtyConfig::default(), + use_pty: None, + ssh_connection_config: custom_config, + }; + + // Verify custom values are applied + assert_eq!( + cmd.ssh_connection_config.keepalive_interval, + Some(120), + "InteractiveCommand should have custom keepalive interval" + ); + assert_eq!( + cmd.ssh_connection_config.keepalive_max, 10, + "InteractiveCommand should have custom keepalive max" + ); +} + +#[test] +fn test_interactive_mode_ssh_connection_config_disabled_keepalive() { + // Test that InteractiveCommand can be created with disabled keepalive + use bssh::commands::interactive::InteractiveCommand; + use bssh::config::{Config, InteractiveConfig}; + use bssh::pty::PtyConfig; + use bssh::ssh::known_hosts::StrictHostKeyChecking; + use std::path::PathBuf; + + let disabled_config = SshConnectionConfig::new().with_keepalive_interval(None); + + let cmd = InteractiveCommand { + single_node: true, + multiplex: false, + prompt_format: "[{user}@{host}]$ ".to_string(), + history_file: PathBuf::from("~/.bssh_history"), + work_dir: None, + nodes: vec![], + config: Config::default(), + interactive_config: InteractiveConfig::default(), + cluster_name: None, + key_path: None, + use_agent: false, + use_password: false, + #[cfg(target_os = "macos")] + use_keychain: false, + strict_mode: StrictHostKeyChecking::AcceptNew, + jump_hosts: None, + pty_config: PtyConfig::default(), + use_pty: None, + ssh_connection_config: disabled_config, + }; + + // Verify keepalive is disabled + assert_eq!( + cmd.ssh_connection_config.keepalive_interval, None, + "InteractiveCommand should have disabled keepalive" + ); +} + +#[test] +fn test_ssh_connection_config_clone() { + // Test that SshConnectionConfig implements Clone correctly + // This is important for passing config to JumpHostChain + let original = SshConnectionConfig::new() + .with_keepalive_interval(Some(90)) + .with_keepalive_max(6); + + let cloned = original.clone(); + + assert_eq!( + original.keepalive_interval, cloned.keepalive_interval, + "Cloned config should have same keepalive_interval" + ); + assert_eq!( + original.keepalive_max, cloned.keepalive_max, + "Cloned config should have same keepalive_max" + ); +} + +#[test] +fn test_jump_host_chain_with_ssh_connection_config() { + // Test that JumpHostChain accepts and stores SshConnectionConfig + use bssh::jump::JumpHostChain; + + let ssh_config = SshConnectionConfig::new() + .with_keepalive_interval(Some(45)) + .with_keepalive_max(5); + + // JumpHostChain should accept the config via builder pattern + let _chain = JumpHostChain::direct().with_ssh_connection_config(ssh_config); + + // If we got here without panicking, the config was accepted correctly +} + +#[test] +fn test_jump_host_chain_with_custom_keepalive_for_long_running_sessions() { + // Test real-world use case: long-running sessions need longer keepalive + use bssh::jump::parser::JumpHost; + use bssh::jump::JumpHostChain; + use std::time::Duration; + + // For long-running interactive sessions, use longer keepalive intervals + // to reduce network traffic while still detecting dead connections + let long_session_config = SshConnectionConfig::new() + .with_keepalive_interval(Some(120)) // 2 minutes + .with_keepalive_max(5); // 5 attempts = 10 minutes to detect dead connection + + let jump_hosts = vec![JumpHost::new( + "bastion.example.com".to_string(), + Some("admin".to_string()), + Some(22), + )]; + + let _chain = JumpHostChain::new(jump_hosts) + .with_connect_timeout(Duration::from_secs(60)) + .with_command_timeout(Duration::from_secs(600)) + .with_ssh_connection_config(long_session_config); + + // This verifies the chain can be configured for long-running interactive sessions +}