diff --git a/README.md b/README.md index 7437b279..b6f04707 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ ![CI](https://github.com/lablup/bssh/workflows/CI/badge.svg) [![dependency status](https://deps.rs/repo/github/lablup/bssh/status.svg)](https://deps.rs/repo/github/lablup/bssh) -A high-performance parallel SSH command execution tool for cluster management, built with Rust and `russh`. +A high-performance SSH client with **SSH-compatible syntax** for both single-host and parallel cluster operations, built with Rust and `russh`. *Developed and maintained as part of the Backend.AI project.* ## Features +- **SSH Compatibility**: Drop-in replacement for SSH with compatible command-line syntax - **Parallel Execution**: Execute commands across multiple nodes simultaneously - **Cluster Management**: Define and manage node clusters via configuration files - **Progress Tracking**: Real-time progress indicators for each node @@ -78,39 +79,57 @@ sudo cp target/release/bssh /usr/local/bin/ ## Quick Start -### Execute command on multiple hosts +### SSH-Compatible Mode (Single Host) +```bash +# Connect to a host (just like SSH!) +bssh user@hostname + +# Execute a command +bssh user@hostname "uptime" + +# With specific port and key +bssh -p 2222 -i ~/.ssh/key.pem admin@server.com + +# Using SSH options +bssh -o StrictHostKeyChecking=no user@host + +# Query SSH capabilities +bssh -Q cipher +``` + +### Multi-Server Mode (Cluster Operations) ```bash # Using direct host specification bssh -H "user1@host1.com,user2@host2.com:2222" "uptime" # Using cluster from config -bssh -c production "df -h" +bssh -C production "df -h" # With custom SSH key -bssh -c staging -i ~/.ssh/custom_key "systemctl status nginx" +bssh -C staging -i ~/.ssh/custom_key "systemctl status nginx" # Use SSH agent for authentication -bssh --use-agent -c production "systemctl status nginx" +bssh -A -C production "systemctl status nginx" # Use password authentication (will prompt for password) bssh --password -H "user@host.com" "uptime" # Use encrypted SSH key (will prompt for passphrase) -bssh -i ~/.ssh/encrypted_key -c production "df -h" +bssh -i ~/.ssh/encrypted_key -C production "df -h" # Limit parallel connections -bssh -c production --parallel 5 "apt update" +bssh -C production --parallel 5 "apt update" # Set command timeout (10 seconds) -bssh -c production --timeout 10 "quick-check" +bssh -C production --timeout 10 "quick-check" # No timeout (unlimited execution time) -bssh -c staging --timeout 0 "long-running-backup" +bssh -C staging --timeout 0 "long-running-backup" ``` ### Test connectivity ```bash -bssh -c production ping +bssh -C production ping ``` ### List configured clusters @@ -187,7 +206,7 @@ bssh "nvidia-smi" # Check GPU status on all nodes bssh interactive # Opens interactive session with all Backend.AI nodes # You can still override with explicit options if needed: -bssh -c other-cluster "command" # Use a different cluster +bssh -C other-cluster "command" # Use a different cluster bssh -H specific-host "command" # Use specific host ``` @@ -279,7 +298,7 @@ bssh "python train.py --distributed" # Run distributed training ### Run system updates ```bash -bssh -c production "sudo apt update && sudo apt upgrade -y" +bssh -C production "sudo apt update && sudo apt upgrade -y" ``` ### Check disk usage @@ -289,24 +308,24 @@ bssh -H "server1,server2,server3" "df -h | grep -E '^/dev/'" ### Restart services ```bash -bssh -c webservers "sudo systemctl restart nginx" +bssh -C webservers "sudo systemctl restart nginx" ``` ### Collect logs ```bash -bssh -c production --output-dir ./logs "tail -n 100 /var/log/syslog" +bssh -C production --output-dir ./logs "tail -n 100 /var/log/syslog" ``` ### Long-running commands with timeout ```bash # Set 30 minute timeout for backup operations -bssh -c production --timeout 1800 "backup-database.sh" +bssh -C production --timeout 1800 "backup-database.sh" # No timeout for data migration (may take hours) -bssh -c production --timeout 0 "migrate-data.sh" +bssh -C production --timeout 0 "migrate-data.sh" # Quick health check with 5 second timeout -bssh -c monitoring --timeout 5 "health-check.sh" +bssh -C monitoring --timeout 5 "health-check.sh" ``` ### Interactive Mode @@ -315,16 +334,16 @@ Start an interactive shell session on cluster nodes: ```bash # Interactive session on all nodes (multiplex mode - default) -bssh -c production interactive +bssh -C production interactive # Interactive session on a single node -bssh -c production interactive --single-node +bssh -C production interactive --single-node # Custom prompt format bssh -H server1,server2 interactive --prompt-format "{user}@{host}> " # Set initial working directory -bssh -c staging interactive --work-dir /var/www +bssh -C staging interactive --work-dir /var/www ``` #### Interactive Mode Configuration @@ -410,7 +429,7 @@ For large clusters (>10 nodes), the prompt uses a compact format: #### Example Interactive Session ```bash -$ bssh -c production interactive +$ bssh -C production interactive Connected to 3 nodes [● ● ●] bssh> !status @@ -480,13 +499,13 @@ Each output file includes metadata headers: ### Example Usage ```bash # Save outputs to timestamped directory -bssh -c production --output-dir ./results/$(date +%Y%m%d) "ps aux | head -10" +bssh -C production --output-dir ./results/$(date +%Y%m%d) "ps aux | head -10" # Collect system information -bssh -c all-servers --output-dir ./system-info "uname -a; df -h; free -m" +bssh -C all-servers --output-dir ./system-info "uname -a; df -h; free -m" # Debug failed services -bssh -c webservers --output-dir ./debug "systemctl status nginx" +bssh -C webservers --output-dir ./debug "systemctl status nginx" ``` ## Development diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index e51f8b89..f43030cb 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1,45 +1,105 @@ .\" Manpage for bssh .\" Contact the maintainers to correct errors or typos. -.TH BSSH 1 "August 27, 2025" "v0.5.3" "bssh Manual" +.TH BSSH 1 "December 2024" "v0.5.3" "bssh Manual" .SH NAME -bssh \- Backend.AI SSH - Parallel command execution across cluster nodes +bssh \- Backend.AI SSH - SSH-compatible client with parallel execution capabilities .SH SYNOPSIS .B bssh -[\fIOPTIONS\fR] [\fICOMMAND_ARGS\fR...] [\fICOMMAND\fR] +[\fIOPTIONS\fR] [\fIdestination\fR] [\fIcommand\fR [\fIargument\fR...]] +.br +.B bssh +[\fIOPTIONS\fR] \fICOMMAND\fR .SH DESCRIPTION .B bssh -is a high-performance parallel SSH command execution tool for cluster management, built with Rust. -It enables efficient execution of commands across multiple nodes simultaneously with real-time output streaming. -The tool provides secure file transfer capabilities using SFTP protocol for both uploading and downloading files -to/from multiple remote hosts in parallel. It supports multiple authentication methods including SSH keys (with -passphrase support for encrypted keys), SSH agent, and password authentication. It automatically detects Backend.AI -multi-node session environments and supports various configuration methods. +is a high-performance SSH client that can be used as a drop-in replacement for standard SSH while also providing +powerful parallel execution capabilities for cluster management. Built with Rust, it supports both single-host +SSH connections (SSH compatibility mode) and multi-server operations (cluster mode). + +.B SSH Compatibility Mode: +When used with a single destination (e.g., bssh user@host), bssh behaves like standard SSH, supporting +common SSH options and automatically starting an interactive shell when no command is provided. + +.B Multi-Server Mode: +When used with clusters (-C) or multiple hosts (-H), bssh executes commands across multiple nodes +simultaneously with real-time output streaming. + +The tool provides secure file transfer capabilities using SFTP protocol, supports multiple authentication +methods (SSH keys with passphrase support, SSH agent, password), and automatically detects Backend.AI +multi-node session environments. .SH OPTIONS + +.SS SSH-Compatible Options +.TP +.BR \-i " " \fIidentity_file\fR +SSH private key file path (same as ssh -i) + +.TP +.BR \-l " " \fIlogin_name\fR +Specifies the user to log in as on the remote machine (same as ssh -l) + +.TP +.BR \-p " " \fIport\fR +Port to connect to on the remote host (same as ssh -p) + +.TP +.BR \-o " " \fIoption\fR +SSH options in key=value format (e.g., -o StrictHostKeyChecking=no) +Can be specified multiple times + +.TP +.BR \-F " " \fIconfigfile\fR +Specifies an alternative SSH configuration file + +.TP +.BR \-q +Quiet mode (suppress non-error messages) + +.TP +.BR \-t +Force pseudo-terminal allocation + +.TP +.BR \-T +Disable pseudo-terminal allocation + +.TP +.BR \-J " " \fIdestination\fR +Connect via jump host(s) (ProxyJump) [planned feature] + +.TP +.BR \-Q " " \fIquery_option\fR +Query SSH configuration options (cipher, kex, mac, key, protocol-version, help) + +.TP +.BR \-4 +Force use of IPv4 addresses only + +.TP +.BR \-6 +Force use of IPv6 addresses only + +.TP +.BR \-x +Disable X11 forwarding + +.SS Multi-Server Options .TP .BR \-H ", " \-\-hosts " " \fIHOSTS\fR Comma-separated list of hosts in [user@]hostname[:port] format. Example: user1@host1:2222,user2@host2 .TP -.BR \-c ", " \-\-cluster " " \fICLUSTER\fR -Cluster name from configuration file +.BR \-C ", " \-\-cluster " " \fICLUSTER\fR +Cluster name from configuration file (uppercase C for multi-server mode) .TP .BR \-\-config " " \fICONFIG\fR Configuration file path (default: ~/.config/bssh/config.yaml) -.TP -.BR \-u ", " \-\-user " " \fIUSER\fR -Default username for SSH connections - -.TP -.BR \-i ", " \-\-identity " " \fIIDENTITY\fR -SSH private key file path. If the key is encrypted, bssh will -automatically prompt for the passphrase. .TP .BR \-A ", " \-\-use\-agent @@ -49,14 +109,15 @@ for authentication. Falls back to key file authentication if the agent is not available or authentication fails. .TP -.BR \-P ", " \-\-password +.BR \-\-password 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 \-p ", " \-\-parallel " " \fIPARALLEL\fR -Maximum parallel connections (default: 10) +.BR \-\-parallel " " \fIPARALLEL\fR +Maximum parallel connections for multi-server mode (default: 10) +Note: -p is now used for port (SSH compatibility) .TP .BR \-\-output\-dir " " \fIOUTPUT_DIR\fR @@ -213,25 +274,48 @@ Current node's role (main or sub) Note: Backend.AI multi-node clusters use SSH port 2200 by default, which is automatically configured. .SH EXAMPLES + +.SS SSH Compatibility Mode (Single Host) +.TP +Connect to a host (interactive shell): +.B bssh user@hostname + +.TP +Execute a command: +.B bssh user@hostname "uptime" + +.TP +Specify port and key: +.B bssh -p 2222 -i ~/.ssh/key.pem admin@server.com + +.TP +Use SSH options: +.B bssh -o StrictHostKeyChecking=no user@host + +.TP +Query SSH capabilities: +.B bssh -Q cipher + +.SS Multi-Server Mode .TP Execute command on multiple hosts: .B bssh -H "user1@host1,user2@host2" "uptime" .TP Use cluster from configuration: -.B bssh -c production "df -h" +.B bssh -C production "df -h" .TP Test connectivity: -.B bssh -c production ping +.B bssh -C production ping .TP Upload file to remote hosts (SFTP): -.B bssh -c production upload local_file.txt /tmp/remote_file.txt +.B bssh -C production upload local_file.txt /tmp/remote_file.txt .TP Download file from remote hosts (SFTP): -.B bssh -c production download /etc/passwd ./downloads/ +.B bssh -C production download /etc/passwd ./downloads/ .RS Downloads /etc/passwd from each host to ./downloads/ directory. Files are saved as hostname_passwd (e.g., web1_passwd, web2_passwd) @@ -247,11 +331,11 @@ Increase verbosity for debugging: .TP Use custom SSH key: -.B bssh -i ~/.ssh/custom_key -c staging "systemctl status" +.B bssh -i ~/.ssh/custom_key -C staging "systemctl status" .TP Use SSH agent for authentication: -.B bssh -A -c production "systemctl status" +.B bssh -A -C production "systemctl status" .TP Use password authentication: @@ -262,14 +346,14 @@ Prompts for password interactively .TP Use encrypted SSH key: -.B bssh -i ~/.ssh/encrypted_key -c production "df -h" +.B bssh -i ~/.ssh/encrypted_key -C production "df -h" .RS Automatically detects encrypted key and prompts for passphrase .RE .TP Save output to files: -.B bssh --output-dir ./results -c production "ps aux" +.B bssh --output-dir ./results -C production "ps aux" .RS Creates timestamped files per node: .br @@ -288,39 +372,39 @@ Upload configuration file to all nodes: .TP Download logs from all web servers: -.B bssh -c webservers download /var/log/nginx/access.log ./logs/ +.B bssh -C webservers download /var/log/nginx/access.log ./logs/ .RS Each file is saved as hostname_access.log in the ./logs/ directory .RE .TP Upload with custom SSH key and increased parallelism: -.B bssh -i ~/.ssh/deploy_key -p 20 -c production upload deploy.tar.gz /tmp/ +.B bssh -i ~/.ssh/deploy_key --parallel 20 -C production upload deploy.tar.gz /tmp/ .TP Upload multiple files with glob pattern: -.B bssh -c production upload "*.log" /var/backups/logs/ +.B bssh -C production upload "*.log" /var/backups/logs/ .RS Uploads all .log files from current directory to /var/backups/logs/ on all nodes .RE .TP Download logs with wildcard pattern: -.B bssh -c production download "/var/log/app*.log" ./collected_logs/ +.B bssh -C production download "/var/log/app*.log" ./collected_logs/ .RS Downloads all files matching app*.log from /var/log/ on each node .RE .TP Start interactive mode with all nodes: -.B bssh -c production interactive +.B bssh -C production interactive .RS Opens an interactive shell session with all nodes in multiplex mode .RE .TP Start interactive mode with single node: -.B bssh -c production interactive --single-node +.B bssh -C production interactive --single-node .RS Prompts to select one node for interactive session .RE @@ -331,7 +415,7 @@ Interactive mode with custom prompt: .TP Interactive mode with initial working directory: -.B bssh -c staging interactive --work-dir /var/www +.B bssh -C staging interactive --work-dir /var/www .RS Sets initial working directory to /var/www on all nodes .RE @@ -460,6 +544,16 @@ Licensed under the Apache License, Version 2.0 .BR ssh-keygen (1) .SH NOTES +.SS Breaking Changes (v0.5.3+) +.TP +.B Cluster option changed: +The cluster option has changed from lowercase -c to uppercase -C to avoid conflicts +with SSH's -c (cipher) option. Update your scripts accordingly. +.TP +.B Parallel option changed: +The -p option now specifies port (SSH compatibility). For parallel connections, +use --parallel instead. + .SS SFTP Requirements The upload and download commands require SFTP subsystem to be enabled on the remote SSH servers. Most SSH servers have SFTP enabled by default with a configuration line like: diff --git a/src/cli.rs b/src/cli.rs index 113ceff2..c398c039 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,11 +20,16 @@ use std::path::PathBuf; name = "bssh", version, before_help = "", - about = "Backend.AI SSH - Parallel command execution across cluster nodes", - long_about = "bssh is a high-performance parallel SSH command execution tool for cluster management.\nIt enables efficient execution of commands across multiple nodes simultaneously with real-time output streaming.\nThe tool provides secure file transfer capabilities using SFTP protocol and supports multiple authentication\nmethods including SSH keys (with passphrase support), SSH agent, and password authentication.\nIt automatically detects Backend.AI multi-node session environments.", - after_help = "EXAMPLES:\n Execute command on hosts: bssh -H \"user@host1,host2\" \"uptime\"\n Use cluster configuration: bssh -c production \"df -h\"\n Upload files with glob: bssh -c staging upload \"*.log\" /tmp/\n Download from all nodes: bssh -c web download /var/log/app.log ./logs/\n Interactive mode (multiplex): bssh -c production interactive\n Test connectivity: bssh -c staging ping\n\nDeveloped and maintained as part of the Backend.AI project.\nFor more examples and documentation, visit: https://github.com/lablup/bssh" + about = "Backend.AI 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\nSSH Compatibility Mode:\n bssh user@host # Interactive shell\n bssh user@host command # Execute command\n bssh -p 2222 user@host # Custom port\n bssh -i key.pem user@host # Custom key\n\nMulti-Server Mode:\n bssh -C production \"uptime\" # Execute on cluster\n bssh -H \"host1,host2\" \"df -h\" # Execute on 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.", + 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\n Multi-Server Mode:\n bssh -C production \"systemctl status\" # Use cluster config\n bssh -H \"web1,web2,web3\" \"df -h\" # Direct hosts\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\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"])] + pub destination: Option, + #[command(subcommand)] pub command: Option, @@ -36,7 +41,11 @@ pub struct Cli { )] pub hosts: Option>, - #[arg(short = 'c', long, help = "Cluster name from configuration file")] + #[arg( + short = 'C', + long = "cluster", + help = "Cluster name from configuration file (multi-server mode)" + )] pub cluster: Option, #[arg( @@ -46,7 +55,11 @@ pub struct Cli { )] pub config: PathBuf, - #[arg(short = 'u', long, help = "Default username for SSH connections")] + #[arg( + short = 'l', + long = "login", + help = "Specifies the user to log in as on the remote machine (SSH-compatible)" + )] pub user: Option, #[arg( @@ -64,20 +77,26 @@ pub struct Cli { pub use_agent: bool, #[arg( - short = 'P', - long, + long = "password", help = "Use password authentication (will prompt for password)" )] pub password: bool, #[arg( - short = 'p', - long, + long = "parallel", default_value = "10", - help = "Maximum parallel connections" + help = "Maximum parallel connections (multi-server mode)" )] pub parallel: usize, + #[arg( + short = 'p', + long = "port", + value_name = "port", + help = "Port to connect to on the remote host (SSH-compatible)" + )] + pub port: Option, + #[arg( long, help = "Output directory for per-node command results\nCreates timestamped files:\n - hostname_TIMESTAMP.stdout (command output)\n - hostname_TIMESTAMP.stderr (error output)\n - hostname_TIMESTAMP.error (connection failures)\n - summary_TIMESTAMP.txt (execution summary)" @@ -108,6 +127,73 @@ pub struct Cli { #[arg(trailing_var_arg = true, help = "Command to execute on remote hosts")] pub command_args: Vec, + + // SSH-compatible options + #[arg(short = 'o', long = "option", value_name = "option", action = clap::ArgAction::Append, + help = "SSH options (e.g., -o StrictHostKeyChecking=no)")] + pub ssh_options: Vec, + + #[arg( + short = 'F', + long = "ssh-config", + value_name = "configfile", + help = "Specifies an alternative SSH configuration file" + )] + pub ssh_config: Option, + + #[arg( + short = 'q', + long = "quiet", + conflicts_with = "verbose", + help = "Quiet mode (suppress non-error messages)" + )] + pub quiet: bool, + + #[arg(short = 't', long = "tty", help = "Force pseudo-terminal allocation")] + pub force_tty: bool, + + #[arg( + short = 'T', + long = "no-tty", + conflicts_with = "force_tty", + help = "Disable pseudo-terminal allocation" + )] + pub no_tty: bool, + + #[arg( + short = 'J', + long = "jump", + value_name = "destination", + help = "Connect via jump host(s) (ProxyJump)" + )] + pub jump_hosts: Option, + + #[arg(short = 'x', long = "no-x11", help = "Disable X11 forwarding")] + pub no_x11: bool, + + #[arg( + short = '4', + long = "ipv4", + conflicts_with = "ipv6", + help = "Force use of IPv4 addresses only" + )] + pub ipv4: bool, + + #[arg( + short = '6', + long = "ipv6", + conflicts_with = "ipv4", + help = "Force use of IPv6 addresses only" + )] + pub ipv6: bool, + + #[arg( + short = 'Q', + long = "query", + value_name = "query_option", + help = "Query SSH configuration options" + )] + pub query: Option, } #[derive(Subcommand, Debug)] @@ -225,4 +311,96 @@ impl Cli { String::new() } } + + /// Check if running in SSH compatibility mode (single host) + pub fn is_ssh_mode(&self) -> bool { + self.destination.is_some() && self.cluster.is_none() && self.hosts.is_none() + } + + /// Check if running in multi-server mode + pub fn is_multi_server_mode(&self) -> bool { + self.cluster.is_some() || self.hosts.is_some() + } + + /// Parse destination string into components (user, host, port) + pub fn parse_destination(&self) -> Option<(Option, String, Option)> { + self.destination.as_ref().map(|dest| { + // Handle ssh:// prefix + let dest = dest.strip_prefix("ssh://").unwrap_or(dest); + + // Parse [user@]hostname[:port] + let parts: Vec<&str> = dest.splitn(2, '@').collect(); + let (user, host_port) = if parts.len() == 2 { + (Some(parts[0].to_string()), parts[1]) + } else { + (None, parts[0]) + }; + + // Parse hostname[:port] + if let Some(idx) = host_port.rfind(':') { + // Check if this is actually a port number (not IPv6 address) + if let Ok(port) = host_port[idx + 1..].parse::() { + let host = host_port[..idx].to_string(); + (user, host, Some(port)) + } else { + // Not a valid port, treat entire string as hostname + (user, host_port.to_string(), None) + } + } else { + (user, host_port.to_string(), None) + } + }) + } + + /// Get effective username (from -l option, destination, or environment) + pub fn get_effective_user(&self) -> Option { + // Priority: -l option > destination > config + if let Some(ref login) = self.user { + return Some(login.clone()); + } + + if let Some((user, _, _)) = self.parse_destination() { + return user; + } + + None + } + + /// Get effective port (from -p option, destination, SSH options, or default) + pub fn get_effective_port(&self) -> Option { + // Priority: -p option > destination > -o Port= > default + if let Some(port) = self.port { + return Some(port); + } + + if let Some((_, _, Some(port))) = self.parse_destination() { + return Some(port); + } + + // Check SSH options for Port= + for opt in &self.ssh_options { + if let Some(port_str) = opt.strip_prefix("Port=") { + if let Ok(port) = port_str.parse::() { + return Some(port); + } + } + } + + None + } + + /// Parse SSH options into a map + pub fn parse_ssh_options(&self) -> std::collections::HashMap { + let mut options = std::collections::HashMap::new(); + + for opt in &self.ssh_options { + if let Some(eq_idx) = opt.find('=') { + let key = opt[..eq_idx].to_string(); + let value = opt[eq_idx + 1..].to_string(); + options.insert(key, value); + } + } + + options + } } diff --git a/src/commands/interactive.rs b/src/commands/interactive.rs index 4eb55b88..e0cb5e06 100644 --- a/src/commands/interactive.rs +++ b/src/commands/interactive.rs @@ -314,9 +314,11 @@ impl InteractiveCommand { } // If no explicit key path, try SSH agent if available (auto-detect) + // Note: We skip auto-detection to avoid failures with empty SSH agents + // Only use agent if explicitly requested with use_agent flag #[cfg(not(target_os = "windows"))] - if !self.use_agent && std::env::var("SSH_AUTH_SOCK").is_ok() { - tracing::debug!("SSH agent detected, attempting agent authentication"); + if self.use_agent && std::env::var("SSH_AUTH_SOCK").is_ok() { + tracing::debug!("SSH agent explicitly requested and available"); return Ok(AuthMethod::Agent); } diff --git a/src/main.rs b/src/main.rs index 58d3cae0..49922992 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,17 +78,20 @@ async fn main() -> Result<()> { let cli = Cli::parse(); + // Handle SSH query option (-Q) + if let Some(ref query) = cli.query { + handle_query(query); + return Ok(()); + } + // Initialize logging init_logging(cli.verbose); // Check if user explicitly specified options let has_explicit_config = args.iter().any(|arg| arg == "--config"); - let has_explicit_parallel = args.iter().any(|arg| { - arg == "-p" - || arg == "--parallel" - || arg.starts_with("-p=") - || arg.starts_with("--parallel=") - }); + let has_explicit_parallel = args + .iter() + .any(|arg| arg == "--parallel" || arg.starts_with("--parallel=")); // If user explicitly specified --config, ensure the file exists if has_explicit_config { @@ -121,7 +124,10 @@ async fn main() -> Result<()> { let (nodes, actual_cluster_name) = resolve_nodes(&cli, &config).await?; // Determine max_parallel: CLI argument takes precedence over config - let max_parallel = if has_explicit_parallel { + // For SSH mode (single host), parallel is always 1 + let max_parallel = if cli.is_ssh_mode() { + 1 + } else if has_explicit_parallel { cli.parallel } else { config @@ -143,8 +149,10 @@ async fn main() -> Result<()> { let command = cli.get_command(); // Check if command is required (not for subcommands like ping, copy) - let needs_command = matches!(cli.command, None | Some(Commands::Exec { .. })); - if command.is_empty() && needs_command { + // In SSH mode without a command, we start an interactive session + let needs_command = + matches!(cli.command, None | Some(Commands::Exec { .. })) && !cli.is_ssh_mode(); + if command.is_empty() && needs_command && !cli.force_tty { anyhow::bail!( "No command specified. Please provide a command to execute.\nExample: bssh -H host1,host2 'ls -la'" ); @@ -302,36 +310,75 @@ async fn main() -> Result<()> { Ok(()) } _ => { - // Execute command (default or Exec subcommand) - // Determine timeout: CLI argument takes precedence over config - let timeout = if cli.timeout > 0 { - Some(cli.timeout) - } else { - config.get_timeout(actual_cluster_name.as_deref().or(cli.cluster.as_deref())) - }; - - // Determine SSH key path: CLI argument takes precedence over config - let key_path = if let Some(identity) = &cli.identity { - Some(identity.clone()) + // Execute command (default or Exec subcommand) or interactive shell + // In SSH mode without command, start interactive session + if cli.is_ssh_mode() && command.is_empty() { + // SSH mode interactive session (like ssh user@host) + tracing::info!("Starting SSH interactive session to {}", nodes[0].host); + + // Determine SSH key path + let key_path = if let Some(identity) = &cli.identity { + Some(identity.clone()) + } else { + config + .get_ssh_key(actual_cluster_name.as_deref().or(cli.cluster.as_deref())) + .map(|ssh_key| bssh::config::expand_tilde(Path::new(&ssh_key))) + }; + + // Use interactive mode for single host SSH connections + let interactive_cmd = InteractiveCommand { + single_node: true, // Always single node for SSH mode + multiplex: false, // No multiplexing for SSH mode + prompt_format: "[{user}@{host}:{pwd}]$ ".to_string(), + history_file: PathBuf::from("~/.bssh_history"), + work_dir: None, + nodes, + config: config.clone(), + interactive_config: config.get_interactive_config(None), + cluster_name: None, + key_path, + use_agent: cli.use_agent, + use_password: cli.password, + strict_mode, + }; + let result = interactive_cmd.execute().await?; + println!("\nSession ended."); + if cli.verbose > 0 { + println!("Duration: {}", format_duration(result.duration)); + println!("Commands executed: {}", result.commands_executed); + } + Ok(()) } else { - config - .get_ssh_key(actual_cluster_name.as_deref().or(cli.cluster.as_deref())) - .map(|ssh_key| bssh::config::expand_tilde(Path::new(&ssh_key))) - }; - - let params = ExecuteCommandParams { - nodes, - command: &command, - max_parallel, - key_path: key_path.as_deref(), - verbose: cli.verbose > 0, - strict_mode, - use_agent: cli.use_agent, - use_password: cli.password, - output_dir: cli.output_dir.as_deref(), - timeout, - }; - execute_command(params).await + // Determine timeout: CLI argument takes precedence over config + let timeout = if cli.timeout > 0 { + Some(cli.timeout) + } else { + config.get_timeout(actual_cluster_name.as_deref().or(cli.cluster.as_deref())) + }; + + // Determine SSH key path: CLI argument takes precedence over config + let key_path = if let Some(identity) = &cli.identity { + Some(identity.clone()) + } else { + config + .get_ssh_key(actual_cluster_name.as_deref().or(cli.cluster.as_deref())) + .map(|ssh_key| bssh::config::expand_tilde(Path::new(&ssh_key))) + }; + + let params = ExecuteCommandParams { + nodes, + command: &command, + max_parallel, + key_path: key_path.as_deref(), + verbose: cli.verbose > 0, + strict_mode, + use_agent: cli.use_agent, + use_password: cli.password, + output_dir: cli.output_dir.as_deref(), + timeout, + }; + execute_command(params).await + } } } } @@ -340,7 +387,24 @@ async fn resolve_nodes(cli: &Cli, config: &Config) -> Result<(Vec, Option< let mut nodes = Vec::new(); let mut cluster_name = None; - if let Some(hosts) = &cli.hosts { + // Handle SSH compatibility mode (single host) + if cli.is_ssh_mode() { + let (user, host, port) = cli + .parse_destination() + .ok_or_else(|| anyhow::anyhow!("Invalid destination format"))?; + + // Get effective username + let username = user + .or_else(|| cli.get_effective_user()) + .or_else(|| std::env::var("USER").ok()) + .unwrap_or_else(|| "root".to_string()); + + // Get effective port + let port = port.or_else(|| cli.get_effective_port()).unwrap_or(22); + + let node = Node::new(host, port, username); + nodes.push(node); + } else if let Some(hosts) = &cli.hosts { // Parse hosts from CLI for host_str in hosts { // Split by comma if a single argument contains multiple hosts @@ -364,3 +428,46 @@ async fn resolve_nodes(cli: &Cli, config: &Config) -> Result<(Vec, Option< Ok((nodes, cluster_name)) } + +/// Handle SSH query options (-Q) +fn handle_query(query: &str) { + match query { + "cipher" => { + println!("aes128-ctr\naes192-ctr\naes256-ctr"); + println!("aes128-gcm@openssh.com\naes256-gcm@openssh.com"); + println!("chacha20-poly1305@openssh.com"); + } + "cipher-auth" => { + println!("aes128-gcm@openssh.com\naes256-gcm@openssh.com"); + println!("chacha20-poly1305@openssh.com"); + } + "mac" => { + println!("hmac-sha2-256\nhmac-sha2-512\nhmac-sha1"); + } + "kex" => { + println!("curve25519-sha256\ncurve25519-sha256@libssh.org"); + println!("ecdh-sha2-nistp256\necdh-sha2-nistp384\necdh-sha2-nistp521"); + } + "key" | "key-plain" | "key-cert" | "key-sig" => { + println!("ssh-rsa\nssh-ed25519"); + println!("ecdsa-sha2-nistp256\necdsa-sha2-nistp384\necdsa-sha2-nistp521"); + } + "protocol-version" => { + println!("2"); + } + "help" => { + println!("Available query options:"); + println!(" cipher - Supported ciphers"); + println!(" cipher-auth - Authenticated encryption ciphers"); + println!(" mac - Supported MAC algorithms"); + println!(" kex - Supported key exchange algorithms"); + println!(" key - Supported key types"); + println!(" protocol-version - SSH protocol version"); + } + _ => { + eprintln!("Unknown query option: {query}"); + eprintln!("Use 'bssh -Q help' to see available options"); + std::process::exit(1); + } + } +} diff --git a/src/ssh/client.rs b/src/ssh/client.rs index 24515bc2..f8eeb934 100644 --- a/src/ssh/client.rs +++ b/src/ssh/client.rs @@ -496,10 +496,11 @@ impl SshClient { return Ok(AuthMethod::with_key_file(key_path, passphrase.as_deref())); } - // If no explicit key path, try SSH agent if available (auto-detect) + // Skip SSH agent auto-detection to avoid failures with empty agents + // Only use agent if explicitly requested #[cfg(not(target_os = "windows"))] - if !use_agent && std::env::var("SSH_AUTH_SOCK").is_ok() { - tracing::debug!("SSH agent detected, attempting agent authentication"); + if use_agent && std::env::var("SSH_AUTH_SOCK").is_ok() { + tracing::debug!("SSH agent explicitly requested and available"); return Ok(AuthMethod::Agent); } diff --git a/tests/download_test.rs b/tests/download_test.rs index 51820006..5ea70a43 100644 --- a/tests/download_test.rs +++ b/tests/download_test.rs @@ -53,7 +53,7 @@ fn test_download_command_parsing() { fn test_download_command_with_cluster() { let args = vec![ "bssh", - "-c", + "-C", "staging", "download", "/var/log/app.log", @@ -105,7 +105,7 @@ fn test_download_command_with_options() { "node1,node2", "-i", "~/.ssh/id_ed25519", - "-p", + "--parallel", "20", "--use-agent", "download", diff --git a/tests/upload_test.rs b/tests/upload_test.rs index c402fb2d..4e7f8127 100644 --- a/tests/upload_test.rs +++ b/tests/upload_test.rs @@ -53,7 +53,7 @@ fn test_upload_command_parsing() { fn test_upload_command_with_cluster() { let args = vec![ "bssh", - "-c", + "-C", "production", "upload", "./local.conf", @@ -81,7 +81,7 @@ fn test_upload_command_with_options() { "server1", "-i", "~/.ssh/custom_key", - "-p", + "--parallel", "5", "upload", "data.csv",