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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.1.5] - Unreleased

### Fixed
- **`--password` prompt is now collected once up-front and shared across all parallel connection tasks** (#201, closes #200). Previously each per-node SSH task prompted for the password independently, racing each other for stdin and interleaving with the progress UI. The dispatcher now collects the password before any executor or `indicatif` UI is initialized and threads an `Arc<Password>` to every per-node auth task — matching the existing `-S` (`SudoPassword`) pattern. The `BSSH_PASSWORD` environment variable is supported for automation scenarios (discouraged; see security notes below).
- **`-S` / `--sudo-password` warning now routes to stderr** when passed to subcommands where it has no effect (`ping`, `upload`, `download`, `list`), keeping stdout clean for scripts that consume bssh output (#201).

## [2.1.4] - 2026-05-10

### Performance
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,8 +574,10 @@ bssh supports multiple authentication methods:
- **Explicit**: Use `-A` flag to force SSH agent authentication

### Password Authentication
- Use `-P` flag to enable password authentication
- Use `-P` / `--password` flag to enable password authentication
- The password is prompted **once up-front**, before any parallel connection tasks start, and is shared securely across all nodes — the prompt appears exactly once regardless of how many hosts are targeted
- Password is prompted securely without echo
- For automation, set `BSSH_PASSWORD` in the environment (not recommended; see security notes in the Sudo Password section)

### Examples
```bash
Expand Down Expand Up @@ -656,6 +658,15 @@ bssh supports configuration via environment variables:

- **`SSH_AUTH_SOCK`**: SSH agent socket path (Unix-like systems)

### SSH Password Variable

- **`BSSH_PASSWORD`**: SSH password for automated password authentication
- Used when `--password` / `-P` is set and `BSSH_PASSWORD` is non-empty; skips the interactive prompt
- **WARNING**: Not recommended for security reasons
- Environment variables may be visible in process listings and shell history
- Use the interactive `-P` prompt instead for security-sensitive operations
- Example: `BSSH_PASSWORD=secret bssh -P -H "user@host" "uptime"`

### Sudo Password Variable

- **`BSSH_SUDO_PASSWORD`**: Sudo password for automated sudo authentication
Expand Down
27 changes: 25 additions & 2 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,14 @@ is not available or authentication fails.

.TP
.BR \-\-password
Use password authentication. When this option is specified, bssh will
prompt for the password securely without echoing it to the terminal.
Use password authentication. The password is collected once up-front,
before any parallel connection tasks are started, and is shared securely
across all target nodes. The prompt appears exactly once regardless of
how many hosts are targeted. If the
.B BSSH_PASSWORD
environment variable is set and non-empty, it is used instead of
prompting interactively (not recommended; see
.BR ENVIRONMENT ).
This is useful for systems that don't have SSH keys configured.

.TP
Expand Down Expand Up @@ -1796,6 +1802,23 @@ attacks while allowing flexible jump host configurations.
.br
Example: BSSH_MAX_JUMP_HOSTS=20 bssh -J host1,host2,...,host20 target

.TP
.B BSSH_PASSWORD
SSH password for automated password authentication. When set along with the
.B \-\-password
flag and the variable is non-empty, bssh uses this value instead of
prompting interactively. The password is still collected once up-front and
shared across all parallel connection tasks.
.br
.B WARNING:
Using environment variables for passwords is not recommended for production
use as they may be visible in process listings, shell history, or logs.
Prefer the interactive
.B \-\-password
prompt for security-sensitive operations.
.br
Example: BSSH_PASSWORD=mysecret bssh --password -H "user@host" "uptime"

.TP
.B BSSH_SUDO_PASSWORD
Sudo password for automated sudo authentication. When set along with the
Expand Down
1 change: 1 addition & 0 deletions examples/interactive_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async fn main() -> anyhow::Result<()> {
key_path: None,
use_agent: false,
use_password: false,
ssh_password: None,
#[cfg(target_os = "macos")]
use_keychain: false,
strict_mode: StrictHostKeyChecking::AcceptNew,
Expand Down
82 changes: 79 additions & 3 deletions src/app/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use bssh::{
},
config::InteractiveMode,
pty::PtyConfig,
security::get_sudo_password,
security::{Password, get_password, get_sudo_password},
ssh::tokio_client::{DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_KEEPALIVE_MAX, SshConnectionConfig},
};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -83,6 +83,38 @@ fn build_ssh_connection_config(
ssh_connection_config
}

/// Decide whether `-S` (sudo-password) is meaningful for the given subcommand.
///
/// `exec` and the interactive paths legitimately need a sudo password; the
/// other subcommands run either no command (`ping` just runs `true`) or operate
/// over SFTP (`upload`, `download`, `list`), so sudo is never relevant.
fn sudo_password_is_applicable(command: &Option<Commands>) -> bool {
match command {
Some(Commands::Ping)
| Some(Commands::Upload { .. })
| Some(Commands::Download { .. })
| Some(Commands::List)
| Some(Commands::CacheStats { .. }) => false,
// exec (None), Interactive, and unknown future subcommands default to
// "applicable" — better to over-accept than silently strip a flag the
// user explicitly passed.
_ => true,
}
}

/// Human-readable name of the currently-dispatched subcommand for diagnostics.
fn subcommand_name(command: &Option<Commands>) -> &'static str {
match command {
Some(Commands::List) => "list",
Some(Commands::Ping) => "ping",
Some(Commands::Upload { .. }) => "upload",
Some(Commands::Download { .. }) => "download",
Some(Commands::Interactive { .. }) => "interactive",
Some(Commands::CacheStats { .. }) => "cache-stats",
None => "exec",
}
}

/// Dispatch commands to their appropriate handlers
pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
// Get command to execute
Expand All @@ -100,6 +132,36 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
);
}

// Warn if -S is passed to subcommands where it has no effect. We choose
// a warning (not a hard reject) to match typical CLI UX: existing scripts
// that happen to pass -S to `ping` or `upload` keep working, but the user
// gets visible feedback that the flag is being ignored.
if cli.sudo_password && !sudo_password_is_applicable(&cli.command) {
eprintln!(
"Warning: --sudo-password (-S) has no effect for the `{}` subcommand and will be ignored",
subcommand_name(&cli.command)
);
}

// Hoist `--password` collection: prompt ONCE here, before any per-node
// SSH connection task is spawned and before the indicatif progress UI is
// rendered. Previously this prompt was issued inside each parallel auth
// task (see `ssh/auth.rs::password_auth()`), which interleaved with the
// progress bar and could fan out N concurrent stdin reads. Collecting
// here mirrors what `-S` (`SudoPassword`) does and is the entire point
// of issue #200.
//
// The returned value is wrapped in `Arc<Password>` so cloning across
// per-node tasks does not duplicate the underlying secret; on drop, the
// memory is zeroized by `secrecy::SecretString`.
let ssh_password: Option<Arc<Password>> = if cli.password {
Some(Arc::new(get_password(true).map_err(|e| {
anyhow::anyhow!("Failed to collect SSH password: {e}")
})?))
} else {
None
};

// Calculate hostname for SSH config integration
let hostname_for_ssh_config = if cli.is_ssh_mode() {
cli.parse_destination().map(|(_, host, _)| host)
Expand Down Expand Up @@ -143,6 +205,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
cli.timeout,
Some(cli.connect_timeout),
jump_hosts,
ssh_password.clone(),
)
.await
}
Expand Down Expand Up @@ -172,6 +235,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
strict_mode: ctx.strict_mode,
use_agent: cli.use_agent,
use_password: cli.password,
ssh_password: ssh_password.clone(),
recursive: *recursive,
ssh_config: Some(&ctx.ssh_config),
jump_hosts,
Expand Down Expand Up @@ -204,6 +268,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
strict_mode: ctx.strict_mode,
use_agent: cli.use_agent,
use_password: cli.password,
ssh_password: ssh_password.clone(),
recursive: *recursive,
ssh_config: Some(&ctx.ssh_config),
jump_hosts,
Expand All @@ -225,6 +290,7 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
prompt_format,
history_file,
work_dir.as_deref(),
ssh_password.clone(),
)
.await
}
Expand All @@ -234,12 +300,13 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
}
None => {
// Execute command (auto-exec or interactive shell)
handle_exec_command(cli, ctx, &command).await
handle_exec_command(cli, ctx, &command, ssh_password.clone()).await
}
}
}

/// Handle interactive command execution
#[allow(clippy::too_many_arguments)]
async fn handle_interactive_command(
cli: &Cli,
ctx: &AppContext,
Expand All @@ -248,6 +315,7 @@ async fn handle_interactive_command(
prompt_format: &str,
history_file: &Path,
work_dir: Option<&str>,
ssh_password: Option<Arc<Password>>,
) -> Result<()> {
// Get interactive config from configuration file (with cluster-specific overrides)
let cluster_name = cli.cluster.as_deref();
Expand Down Expand Up @@ -340,6 +408,7 @@ async fn handle_interactive_command(
key_path,
use_agent: cli.use_agent,
use_password: cli.password,
ssh_password,
#[cfg(target_os = "macos")]
use_keychain,
strict_mode: ctx.strict_mode,
Expand All @@ -358,7 +427,12 @@ async fn handle_interactive_command(
}

/// Handle exec command or SSH mode interactive session
async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Result<()> {
async fn handle_exec_command(
cli: &Cli,
ctx: &AppContext,
command: &str,
ssh_password: Option<Arc<Password>>,
) -> Result<()> {
// In SSH mode without command, start interactive session
if cli.is_ssh_mode() && command.is_empty() {
// SSH mode interactive session (like ssh user@host)
Expand Down Expand Up @@ -414,6 +488,7 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu
key_path,
use_agent: cli.use_agent,
use_password: cli.password,
ssh_password,
#[cfg(target_os = "macos")]
use_keychain,
strict_mode: ctx.strict_mode,
Expand Down Expand Up @@ -504,6 +579,7 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu
strict_mode: ctx.strict_mode,
use_agent: cli.use_agent,
use_password: cli.password,
ssh_password,
#[cfg(target_os = "macos")]
use_keychain,
output_dir: cli.output_dir.as_deref(),
Expand Down
8 changes: 7 additions & 1 deletion src/commands/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ pub async fn download_file(
params.use_agent,
params.use_password,
)
.with_jump_hosts(params.jump_hosts.clone());
.with_jump_hosts(params.jump_hosts.clone())
.with_ssh_password(params.ssh_password.clone());
if let Some(ssh_config) = params.ssh_config {
executor = executor.with_ssh_config(Some(ssh_config.clone()));
}
Expand Down Expand Up @@ -121,6 +122,7 @@ pub async fn download_file(
params.jump_hosts.as_deref(), // Pass jump hosts from params
None, // Use default connect timeout
params.ssh_config, // Pass ssh_config for ProxyJump resolution
params.ssh_password.clone(),
)
.await;

Expand Down Expand Up @@ -185,6 +187,10 @@ pub async fn download_file(
params.use_agent,
params.use_password,
None, // Use default timeout for ls command
// Issue #200 (C2): forward the dispatcher's pre-collected
// password so this glob-resolution step does NOT re-prompt
// after the dispatcher already asked once.
params.ssh_password.clone(),
)
.await?;

Expand Down
26 changes: 21 additions & 5 deletions src/commands/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::sync::Arc;
use crate::executor::{ExitCodeStrategy, OutputMode, ParallelExecutor, RankDetector};
use crate::forwarding::ForwardingType;
use crate::node::Node;
use crate::security::SudoPassword;
use crate::security::{Password, SudoPassword};
use crate::ssh::SshConfig;
use crate::ssh::known_hosts::StrictHostKeyChecking;
use crate::ssh::tokio_client::SshConnectionConfig;
Expand All @@ -35,6 +35,10 @@ pub struct ExecuteCommandParams<'a> {
pub strict_mode: StrictHostKeyChecking,
pub use_agent: bool,
pub use_password: bool,
/// Pre-collected SSH password collected once by the dispatcher and shared
/// (via `Arc::clone`) with every per-node SSH connection task. When
/// `use_password` is `true`, this should be `Some(_)`.
pub ssh_password: Option<Arc<Password>>,
#[cfg(target_os = "macos")]
pub use_keychain: bool,
pub output_dir: Option<&'a Path>,
Expand Down Expand Up @@ -109,10 +113,21 @@ async fn execute_command_with_forwarding(params: ExecuteCommandParams<'_>) -> Re
return Err(anyhow::anyhow!("SSH agent not supported on Windows"));
}
} else if params.use_password {
// For password auth, we'd need to prompt - for now return error
return Err(anyhow::anyhow!(
"Password authentication not yet supported with port forwarding"
));
// Issue #200 (M1): consume the dispatcher's pre-collected password
// instead of erroring out. Previously this branch returned
// "Password authentication not yet supported with port forwarding"
// even though the dispatcher had already prompted the user — a
// confusing UX regression. The dispatcher collects unconditionally
// for `exec`, so when `use_password` is true we always have one.
let Some(password) = params.ssh_password.as_ref() else {
anyhow::bail!(
"--password was requested for port-forwarding exec but no \
password was collected up-front (programmer error in dispatcher: \
the `exec` arm must populate `ExecuteCommandParams::ssh_password` \
when `use_password` is true)."
);
};
AuthMethod::with_password(password.as_str())
} else {
// Use default key file authentication
let key_path = params
Expand Down Expand Up @@ -218,6 +233,7 @@ async fn execute_command_without_forwarding(params: ExecuteCommandParams<'_>) ->
.with_connect_timeout(params.connect_timeout)
.with_jump_hosts(params.jump_hosts.map(|s| s.to_string()))
.with_sudo_password(params.sudo_password)
.with_ssh_password(params.ssh_password)
.with_batch_mode(params.batch)
.with_fail_fast(params.fail_fast)
.with_ssh_config(params.ssh_config.cloned())
Expand Down
17 changes: 12 additions & 5 deletions src/commands/interactive/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ impl InteractiveCommand {
auth_ctx = auth_ctx
.with_agent(self.use_agent)
.with_password(self.use_password)
.with_password_fallback(!self.use_password); // Enable fallback only if not using explicit password
.with_password_fallback(!self.use_password) // Enable fallback only if not using explicit password
.with_pre_collected_password(self.ssh_password.clone());

// Set macOS Keychain integration if available
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -262,11 +263,14 @@ impl InteractiveCommand {
.min(MAX_TIMEOUT_SECS),
);

// Pass SSH connection config to jump host chain for keepalive settings
// Pass SSH connection config to jump host chain for keepalive settings.
// Also pass the dispatcher's pre-collected password so jump-host
// authentication consumes it instead of re-prompting per call. See #200.
let chain = JumpHostChain::new(jump_hosts)
.with_connect_timeout(adjusted_timeout)
.with_command_timeout(Duration::from_secs(300))
.with_ssh_connection_config(self.ssh_connection_config.clone());
.with_ssh_connection_config(self.ssh_connection_config.clone())
.with_ssh_password(self.ssh_password.clone());

// Connect through the chain
let connection = timeout(
Expand Down Expand Up @@ -406,11 +410,14 @@ impl InteractiveCommand {
.min(MAX_TIMEOUT_SECS),
);

// Pass SSH connection config to jump host chain for keepalive settings
// Pass SSH connection config to jump host chain for keepalive settings.
// Also pass the dispatcher's pre-collected password so jump-host
// authentication consumes it instead of re-prompting per call. See #200.
let chain = JumpHostChain::new(jump_hosts)
.with_connect_timeout(adjusted_timeout)
.with_command_timeout(Duration::from_secs(300))
.with_ssh_connection_config(self.ssh_connection_config.clone());
.with_ssh_connection_config(self.ssh_connection_config.clone())
.with_ssh_password(self.ssh_password.clone());

// Connect through the chain
let connection = timeout(
Expand Down
Loading