Skip to content
Merged
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,16 @@ bssh -J bastion.example.com -C production "uptime"

### Multi-Server Mode (Cluster Operations)
```bash
# Using direct host specification
# Execute commands on multiple hosts (automatic command execution)
bssh -H "user1@host1.com,user2@host2.com:2222" "uptime"

# Using cluster from config
bssh -C production "df -h"

# Filter specific hosts with pattern matching
bssh -H "web1,web2,db1,db2" -f "web*" "systemctl status nginx"
bssh -C production -f "db*" "pg_dump --version"

# With custom SSH key
bssh -C staging -i ~/.ssh/custom_key "systemctl status nginx"

Expand All @@ -180,14 +184,22 @@ bssh -C production --timeout 10 "quick-check"
bssh -C staging --timeout 0 "long-running-backup"
```

### Test connectivity
### Built-in Commands
```bash
# Test connectivity to hosts
bssh -C production ping
```
bssh -H "host1,host2" ping

### List configured clusters
```bash
# List configured clusters
bssh list

# Interactive mode (single or multiplexed)
bssh -C production interactive
bssh -H "host1,host2" interactive

# File transfer operations
bssh -C production upload local.txt /tmp/
bssh -H "host1,host2" download /etc/hosts ./backups/
```

## Authentication
Expand Down
34 changes: 31 additions & 3 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ Use password authentication. When this option is specified, bssh will
prompt for the password securely without echoing it to the terminal.
This is useful for systems that don't have SSH keys configured.

.TP
.BR \-f ", " \-\-filter " " \fIPATTERN\fR
Filter hosts by pattern (supports wildcards like 'web*').
Use with -H or -C to execute on a subset of hosts.
Example: -f "web*" matches web01, web02, etc.

.TP
.BR \-\-parallel " " \fIPARALLEL\fR
Maximum parallel connections for multi-server mode (default: 10)
Expand Down Expand Up @@ -161,9 +167,12 @@ Print help information
Print version information

.SH COMMANDS
In multi-server mode (-H or -C), commands can be executed directly without the 'exec' subcommand.
For example: bssh -H "host1,host2" "uptime" (automatic command execution)

.TP
.B exec
Execute a command on specified hosts
.B Built-in subcommands:
These commands provide special functionality and must be specified explicitly.

.TP
.B list
Expand Down Expand Up @@ -376,16 +385,35 @@ Chain multiple jump hosts for connection

.SS Multi-Server Mode
.TP
Execute command on multiple hosts:
Execute command on multiple hosts (automatic execution):
.B bssh -H "user1@host1,user2@host2" "uptime"
.RS
Commands are executed directly without needing 'exec' subcommand
.RE

.TP
Use cluster from configuration:
.B bssh -C production "df -h"

.TP
Filter hosts with pattern matching:
.B bssh -H "web1,web2,db1,db2" -f "web*" "systemctl status nginx"
.RS
Executes command only on hosts matching the pattern 'web*'
.RE

.TP
.B bssh -C production -f "db*" "pg_dump --version"
.RS
Executes command only on database nodes in the production cluster
.RE

.TP
Test connectivity:
.B bssh -C production ping
.RS
Note: 'ping' is a built-in subcommand, not an automatic execution
.RE

.TP
Upload file to remote hosts (SFTP):
Expand Down
73 changes: 57 additions & 16 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ use std::path::PathBuf;
before_help = "\n\nBroadcast SSH - Parallel command execution across cluster nodes",
about = "Broadcast SSH - SSH-compatible parallel command execution tool",
long_about = "bssh is a high-performance SSH client with parallel execution capabilities.\nIt can be used as a drop-in replacement for SSH (single host) or as a powerful cluster management tool (multiple hosts).\n\nThe tool provides secure file transfer using SFTP and supports SSH keys, SSH agent, and password authentication.\nIt automatically detects Backend.AI multi-node session environments.\n\nSSH Configuration Support:\n- Reads standard SSH config files (defaulting to ~/.ssh/config)\n- Supports Host patterns, HostName, User, Port, IdentityFile, StrictHostKeyChecking\n- ProxyJump, and many other SSH configuration directives\n- CLI arguments override SSH config values following SSH precedence rules",
after_help = "EXAMPLES:\n SSH Mode:\n bssh user@host # Interactive shell\n bssh admin@server.com \"uptime\" # Execute command\n bssh -p 2222 -i ~/.ssh/key user@host # Custom port and key\n bssh -F ~/.ssh/myconfig webserver # Use custom SSH config\n\n Port Forwarding:\n bssh -L 8080:example.com:80 user@host # Local forward: localhost:8080 → example.com:80\n bssh -R 8080:localhost:80 user@host # Remote forward: remote:8080 → localhost:80\n bssh -D 1080 user@host # SOCKS5 proxy on localhost:1080\n bssh -L 3306:db:3306 -R 80:web:80 user@host # Multiple forwards\n bssh -D *:1080/4 user@host # SOCKS4 proxy on all interfaces\n\n Multi-Server Mode:\n bssh -C production \"systemctl status\" # Use cluster config\n bssh -H \"web1,web2,web3\" \"df -h\" # Direct hosts\n bssh -F /etc/ssh/ssh_config -H web* # SSH config with wildcards\n\n File Operations:\n bssh -C staging upload file.txt /tmp/ # Upload to cluster\n bssh -H host1,host2 download /etc/hosts ./backups/\n\n Other Commands:\n bssh list # List configured clusters\n bssh -C production ping # Test connectivity\n\n SSH Config Example (~/.ssh/config):\n Host web*\n HostName web.example.com\n User webuser\n Port 2222\n IdentityFile ~/.ssh/web_key\n StrictHostKeyChecking yes\n\nDeveloped and maintained as part of the Backend.AI project.\nFor more information: https://github.com/lablup/bssh"
after_help = "EXAMPLES:\n SSH Mode:\n bssh user@host # Interactive shell\n bssh admin@server.com \"uptime\" # Execute command\n bssh -p 2222 -i ~/.ssh/key user@host # Custom port and key\n bssh -F ~/.ssh/myconfig webserver # Use custom SSH config\n\n Port Forwarding:\n bssh -L 8080:example.com:80 user@host # Local forward: localhost:8080 → example.com:80\n bssh -R 8080:localhost:80 user@host # Remote forward: remote:8080 → localhost:80\n bssh -D 1080 user@host # SOCKS5 proxy on localhost:1080\n bssh -L 3306:db:3306 -R 80:web:80 user@host # Multiple forwards\n bssh -D *:1080/4 user@host # SOCKS4 proxy on all interfaces\n\n Multi-Server Mode:\n bssh -C production \"systemctl status\" # Execute on cluster\n bssh -H \"web1,web2,web3\" \"df -h\" # Execute on multiple hosts\n bssh -H \"web1,web2,web3\" -f \"web1\" \"df -h\" # Filter to web1 only\n bssh -C production -f \"web*\" \"uptime\" # Filter cluster nodes\n bssh --parallel 20 -H web* \"apt update\" # Increase parallelism\n\n File Operations:\n bssh -C staging upload file.txt /tmp/ # Upload to cluster\n bssh -H host1,host2 download /etc/hosts ./backups/\n\n Other Commands:\n bssh list # List configured clusters\n bssh -C production ping # Test connectivity\n bssh -H hosts interactive # Interactive mode\n\n SSH Config Example (~/.ssh/config):\n Host web*\n HostName web.example.com\n User webuser\n Port 2222\n IdentityFile ~/.ssh/web_key\n StrictHostKeyChecking yes\n\nDeveloped and maintained as part of the Backend.AI project.\nFor more information: https://github.com/lablup/bssh"
)]
pub struct Cli {
/// SSH destination in format: [user@]hostname[:port] or ssh://[user@]hostname[:port]
/// Used for SSH compatibility mode (single host connection)
#[arg(value_name = "destination", conflicts_with_all = ["cluster", "hosts"])]
#[arg(value_name = "destination")]
pub destination: Option<String>,

#[command(subcommand)]
Expand All @@ -42,6 +42,13 @@ pub struct Cli {
)]
pub hosts: Option<Vec<String>>,

#[arg(
short = 'f',
long = "filter",
help = "Filter hosts by pattern (supports wildcards like 'web*')\nUse with -H or -C to execute on a subset of hosts\nExamples: 'web*' matches web01, web02, etc."
)]
pub filter: Option<String>,

#[arg(
short = 'C',
long = "cluster",
Expand Down Expand Up @@ -133,7 +140,11 @@ pub struct Cli {
)]
pub timeout: u64,

#[arg(trailing_var_arg = true, help = "Command to execute on remote hosts")]
#[arg(
trailing_var_arg = true,
help = "Command to execute on remote hosts",
allow_hyphen_values = true
)]
pub command_args: Vec<String>,

// SSH-compatible options
Expand Down Expand Up @@ -226,16 +237,6 @@ pub struct Cli {

#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(
about = "Execute a command on specified hosts",
long_about = "Executes the specified command on all target hosts simultaneously.\nOutput is streamed in real-time with host prefixes for identification.\nSupports command timeout, output redirection, and partial failure handling.\n\nExit codes: 0 (all succeed), 1 (any failures)",
after_help = "Examples:\n bssh exec \"uptime\" # Execute on auto-detected or default hosts\n bssh -c prod exec \"systemctl status\" # Execute on cluster 'prod'\n bssh -p 20 exec \"apt update\" # Increase parallelism to 20\n bssh --output-dir logs exec \"df -h\" # Save outputs to files"
)]
Exec {
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},

#[command(
about = "List available clusters",
long_about = "Displays all clusters defined in configuration files.\nShows cluster names, node counts, and configuration sources.\nIncludes auto-detected Backend.AI clusters if present.\n\nConfiguration sources checked (in order):\n - Backend.AI environment variables\n - Current directory (./config.yaml)\n - User config (~/.config/bssh/config.yaml)"
Expand Down Expand Up @@ -347,17 +348,52 @@ pub enum Commands {

impl Cli {
pub fn get_command(&self) -> String {
if !self.command_args.is_empty() {
// In multi-server mode with destination, treat destination as first command arg
if self.is_multi_server_mode() && self.destination.is_some() {
let mut all_args = vec![self.destination.as_ref().unwrap().clone()];
all_args.extend(self.command_args.clone());
all_args.join(" ")
} else if !self.command_args.is_empty() {
self.command_args.join(" ")
} else if let Some(Commands::Exec { command }) = &self.command {
command.join(" ")
} else {
String::new()
}
}

/// Check if the first command arg is a known subcommand
pub fn is_known_subcommand(arg: &str) -> bool {
matches!(
arg,
"list" | "ping" | "upload" | "download" | "interactive" | "cache-stats"
)
}

/// Determine if we should auto-execute a command
pub fn should_auto_exec(&self) -> bool {
// If in multi-server mode with destination or command_args, treat as exec
if self.is_multi_server_mode() {
// Check if destination is a known subcommand
if let Some(dest) = &self.destination {
if Self::is_known_subcommand(dest) {
return false; // It's a subcommand, not auto-exec
}
return true; // Has destination that's not a subcommand
}
// Check command_args
if !self.command_args.is_empty() {
if Self::is_known_subcommand(&self.command_args[0]) {
return false;
}
return true;
}
}
false
}

/// Check if running in SSH compatibility mode (single host)
pub fn is_ssh_mode(&self) -> bool {
// Only SSH mode if destination is provided and no cluster/hosts
// If hosts/cluster is present, destination should be treated as first command arg
self.destination.is_some() && self.cluster.is_none() && self.hosts.is_none()
}

Expand All @@ -366,6 +402,11 @@ impl Cli {
self.cluster.is_some() || self.hosts.is_some()
}

/// Get the host filter pattern if specified
pub fn get_host_filter(&self) -> Option<&str> {
self.filter.as_deref()
}

/// Parse destination string into components (user, host, port)
pub fn parse_destination(&self) -> Option<(Option<String>, String, Option<u16>)> {
self.destination.as_ref().map(|dest| {
Expand Down
59 changes: 40 additions & 19 deletions src/commands/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,24 @@ pub async fn download_file(
source: &str,
destination: &Path,
) -> Result<()> {
// Security: Validate the remote source path
let validated_source = crate::security::validate_remote_path(source)
.with_context(|| format!("Invalid source path: {}", source))?;

// Security: Validate the local destination path
let validated_destination = crate::security::validate_local_path(destination)
.with_context(|| format!("Invalid destination path: {:?}", destination))?;

// Create destination directory if it doesn't exist
if !destination.exists() {
fs::create_dir_all(destination).await.with_context(|| {
format!(
"Failed to create destination directory: {}",
destination.display()
)
})?;
if !validated_destination.exists() {
fs::create_dir_all(&validated_destination)
.await
.with_context(|| {
format!(
"Failed to create destination directory: {}",
validated_destination.display()
)
})?;
}

let key_path_str = params.key_path.map(|p| p.to_string_lossy().to_string());
Expand All @@ -49,12 +59,17 @@ pub async fn download_file(
);

// Check if source contains glob pattern
let has_glob = source.contains('*') || source.contains('?') || source.contains('[');
let has_glob = validated_source.contains('*')
|| validated_source.contains('?')
|| validated_source.contains('[');

// Check if source is a directory (for recursive download)
let is_directory = if params.recursive && !has_glob {
// Use a test command to check if source is a directory
let test_cmd = format!("test -d '{source}' && echo 'dir' || echo 'file'");
let test_cmd = format!(
"test -d '{}' && echo 'dir' || echo 'file'",
validated_source
);
let test_results = executor.execute(&test_cmd).await?;
test_results.iter().any(|r| {
r.result
Expand All @@ -71,7 +86,7 @@ pub async fn download_file(
"\n{} {} {} {} from {} nodes {}\n",
"▶".cyan(),
"Recursively downloading directory".cyan().bold(),
source.green(),
validated_source.green(),
"from".dimmed(),
params.nodes.len().to_string().yellow(),
"(SFTP)".dimmed()
Expand All @@ -82,7 +97,7 @@ pub async fn download_file(

// Download the entire directory from each node
for node in &params.nodes {
let node_dir = destination.join(node.to_string());
let node_dir = validated_destination.join(node.to_string());

println!(
"\n{} {} {} {} {:?}",
Expand All @@ -96,7 +111,7 @@ pub async fn download_file(
// Use the download_dir_from_node function directly
let result = executor::download_dir_from_node(
node.clone(),
source,
&validated_source,
&node_dir,
key_path_str.as_deref(),
params.strict_mode,
Expand Down Expand Up @@ -150,7 +165,7 @@ pub async fn download_file(
.nodes
.first()
.ok_or_else(|| anyhow::anyhow!("No nodes available"))?;
let glob_command = format!("ls -1 {source} 2>/dev/null || true");
let glob_command = format!("ls -1 {} 2>/dev/null || true", validated_source);

let mut test_client = SshClient::new(
test_node.host.clone(),
Expand All @@ -176,7 +191,7 @@ pub async fn download_file(
.collect();

if remote_files.is_empty() {
anyhow::bail!("No files found matching pattern: {}", source);
anyhow::bail!("No files found matching pattern: {}", validated_source);
}

println!(
Expand All @@ -188,11 +203,15 @@ pub async fn download_file(
for file in &remote_files {
println!(" {} {}", "•".dimmed(), file.cyan());
}
println!("{} {}\n", "Destination:".bold(), destination.display());
println!(
"{} {}\n",
"Destination:".bold(),
validated_destination.display()
);

// Download each file
let results = executor
.download_files(remote_files.clone(), destination)
.download_files(remote_files.clone(), &validated_destination)
.await?;

// Print results
Expand Down Expand Up @@ -226,13 +245,15 @@ pub async fn download_file(
"\n{} {} {} from {} nodes to {} {}\n",
"▶".cyan(),
"Downloading".cyan().bold(),
source.green(),
validated_source.green(),
params.nodes.len().to_string().yellow(),
destination.display(),
validated_destination.display(),
"(SFTP)".dimmed()
);

let results = executor.download_file(source, destination).await?;
let results = executor
.download_file(&validated_source, &validated_destination)
.await?;

// Print results
for result in &results {
Expand Down
11 changes: 10 additions & 1 deletion src/commands/ping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,16 @@ pub async fn ping_nodes(
"Failed".red()
);
if let Err(e) = &result.result {
println!(" {} {}", "└".dimmed(), e.to_string().dimmed());
// Display the full error chain for better debugging
let error_chain = format!("{:#}", e);
// Split by newlines and indent each line
for (i, line) in error_chain.lines().enumerate() {
if i == 0 {
println!(" {} {}", "└".dimmed(), line.dimmed());
} else {
println!(" {}", line.dimmed());
}
}
}
}
}
Expand Down
Loading