Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/interactive_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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...");
Expand Down
97 changes: 60 additions & 37 deletions src/app/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -296,6 +346,7 @@ async fn handle_interactive_command(
jump_hosts,
pty_config,
use_pty,
ssh_connection_config,
};

let result = interactive_cmd.execute().await?;
Expand Down Expand Up @@ -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,
Expand All @@ -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?;
Expand Down Expand Up @@ -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(),
Expand Down
24 changes: 19 additions & 5 deletions src/commands/interactive/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand All @@ -45,6 +48,7 @@ impl InteractiveCommand {
host: &str,
port: u16,
allow_password_fallback: bool,
ssh_config: &SshConnectionConfig,
) -> Result<Client> {
const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
let connect_timeout = Duration::from_secs(SSH_CONNECT_TIMEOUT_SECS);
Expand All @@ -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(|| {
Expand Down Expand Up @@ -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(|| {
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -308,6 +317,7 @@ impl InteractiveCommand {
&node.host,
node.port,
!self.use_password, // Allow fallback unless explicit password mode
&self.ssh_connection_config,
)
.await?
};
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -448,6 +461,7 @@ impl InteractiveCommand {
&node.host,
node.port,
!self.use_password, // Allow fallback unless explicit password mode
&self.ssh_connection_config,
)
.await?
};
Expand Down
4 changes: 3 additions & 1 deletion src/commands/interactive/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +61,8 @@ pub struct InteractiveCommand {
// PTY configuration
pub pty_config: PtyConfig,
pub use_pty: Option<bool>, // None = auto-detect, Some(true) = force, Some(false) = disable
// SSH connection configuration (keepalive settings)
pub ssh_connection_config: SshConnectionConfig,
}

/// Result of an interactive session
Expand Down
3 changes: 3 additions & 0 deletions src/commands/interactive/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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");
Expand Down Expand Up @@ -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"));
Expand Down
13 changes: 13 additions & 0 deletions src/pty/session/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading