From 587a07d25a241cfba3b96fabce603aa40bf53ccb Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 16 Dec 2025 23:32:40 +0900 Subject: [PATCH 1/3] feat: Add --exclude option for host exclusion (pdsh -x compatibility) Add a new --exclude option to bssh that allows users to exclude specific hosts from the target list. This is part of pdsh compatibility mode (Phase 1 of #91). Features: - --exclude option accepts comma-separated list of hosts to exclude - Supports wildcard patterns using glob matching (e.g., 'db*', '*-backup') - Works with both -H (direct hosts) and -C (cluster) modes - Applied after --filter option for precise control - Comprehensive error handling for excluding all hosts Note: Short option -x is not used due to conflict with existing --no-x11 option. The -x short form will be available in pdsh compatibility mode. Closes #93 --- src/app/nodes.rs | 298 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 14 ++- 2 files changed, 311 insertions(+), 1 deletion(-) diff --git a/src/app/nodes.rs b/src/app/nodes.rs index 49f4488c..53569fba 100644 --- a/src/app/nodes.rs +++ b/src/app/nodes.rs @@ -158,9 +158,109 @@ pub async fn resolve_nodes( } } + // Apply host exclusion patterns (--exclude option) + if let Some(exclude_patterns) = cli.get_exclude_patterns() { + let node_count_before = nodes.len(); + nodes = exclude_nodes(nodes, exclude_patterns)?; + if nodes.is_empty() { + let patterns_str = exclude_patterns.join(", "); + anyhow::bail!( + "All {node_count_before} hosts were excluded by pattern(s): {patterns_str}" + ); + } + } + Ok((nodes, cluster_name)) } +/// Check if a pattern matches a node (hostname or full node string) +fn pattern_matches_node(pattern: &Pattern, node: &Node) -> bool { + pattern.matches(&node.host) || pattern.matches(&node.to_string()) +} + +/// Exclude nodes based on patterns (supports wildcards) +/// +/// Takes a list of nodes and exclusion patterns, returning nodes that don't match +/// any of the exclusion patterns. Patterns support wildcards like 'db*', '*-backup'. +pub fn exclude_nodes(nodes: Vec, patterns: &[String]) -> Result> { + if patterns.is_empty() { + return Ok(nodes); + } + + // Compile all exclusion patterns + let mut compiled_patterns = Vec::with_capacity(patterns.len()); + for pattern in patterns { + // Security: Validate pattern length to prevent DoS + const MAX_PATTERN_LENGTH: usize = 256; + if pattern.len() > MAX_PATTERN_LENGTH { + anyhow::bail!("Exclusion pattern too long (max {MAX_PATTERN_LENGTH} characters)"); + } + + // Security: Validate pattern for dangerous constructs + if pattern.is_empty() { + anyhow::bail!("Exclusion pattern cannot be empty"); + } + + // Security: Prevent excessive wildcard usage that could cause DoS + let wildcard_count = pattern.chars().filter(|c| *c == '*' || *c == '?').count(); + const MAX_WILDCARDS: usize = 10; + if wildcard_count > MAX_WILDCARDS { + anyhow::bail!("Exclusion pattern contains too many wildcards (max {MAX_WILDCARDS})"); + } + + // Security: Check for potential path traversal attempts + if pattern.contains("..") || pattern.contains("//") { + anyhow::bail!("Exclusion pattern contains invalid sequences"); + } + + // Security: Sanitize pattern - only allow safe characters for hostnames + let valid_chars = pattern.chars().all(|c| { + c.is_ascii_alphanumeric() + || c == '.' + || c == '-' + || c == '_' + || c == '@' + || c == ':' + || c == '*' + || c == '?' + || c == '[' + || c == ']' + }); + + if !valid_chars { + anyhow::bail!("Exclusion pattern contains invalid characters for hostname matching"); + } + + // Compile the pattern + let glob_pattern = Pattern::new(pattern) + .with_context(|| format!("Invalid exclusion pattern: {pattern}"))?; + compiled_patterns.push((pattern.clone(), glob_pattern)); + } + + // Filter out nodes that match any exclusion pattern + let filtered: Vec = nodes + .into_iter() + .filter(|node| { + // Keep node if it doesn't match any exclusion pattern + !compiled_patterns.iter().any(|(raw_pattern, glob_pattern)| { + // For patterns without wildcards, also do exact/contains matching + if !raw_pattern.contains('*') + && !raw_pattern.contains('?') + && !raw_pattern.contains('[') + { + node.host == *raw_pattern + || node.to_string() == *raw_pattern + || node.host.contains(raw_pattern.as_str()) + } else { + pattern_matches_node(glob_pattern, node) + } + }) + }) + .collect(); + + Ok(filtered) +} + /// Filter nodes based on a pattern (supports wildcards) pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { // Security: Validate pattern length to prevent DoS @@ -240,3 +340,201 @@ pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { .collect()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_nodes() -> Vec { + vec![ + Node::new("web1.example.com".to_string(), 22, "admin".to_string()), + Node::new("web2.example.com".to_string(), 22, "admin".to_string()), + Node::new("db1.example.com".to_string(), 22, "admin".to_string()), + Node::new("db2.example.com".to_string(), 22, "admin".to_string()), + Node::new( + "cache-backup.example.com".to_string(), + 22, + "admin".to_string(), + ), + ] + } + + #[test] + fn test_exclude_single_host_exact() { + let nodes = create_test_nodes(); + let patterns = vec!["web1.example.com".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 4); + assert!(!result.iter().any(|n| n.host == "web1.example.com")); + } + + #[test] + fn test_exclude_multiple_hosts() { + let nodes = create_test_nodes(); + let patterns = vec![ + "web1.example.com".to_string(), + "db1.example.com".to_string(), + ]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host == "web1.example.com")); + assert!(!result.iter().any(|n| n.host == "db1.example.com")); + } + + #[test] + fn test_exclude_with_wildcard_prefix() { + let nodes = create_test_nodes(); + let patterns = vec!["db*".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host.starts_with("db"))); + } + + #[test] + fn test_exclude_with_wildcard_suffix() { + let nodes = create_test_nodes(); + let patterns = vec!["*-backup*".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 4); + assert!(!result.iter().any(|n| n.host.contains("-backup"))); + } + + #[test] + fn test_exclude_with_question_mark_wildcard() { + let nodes = create_test_nodes(); + let patterns = vec!["web?.example.com".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host.starts_with("web"))); + } + + #[test] + fn test_exclude_multiple_patterns_with_wildcards() { + let nodes = create_test_nodes(); + let patterns = vec!["web*".to_string(), "db*".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].host, "cache-backup.example.com"); + } + + #[test] + fn test_exclude_empty_patterns() { + let nodes = create_test_nodes(); + let patterns: Vec = vec![]; + let result = exclude_nodes(nodes.clone(), &patterns).unwrap(); + + assert_eq!(result.len(), nodes.len()); + } + + #[test] + fn test_exclude_no_matches() { + let nodes = create_test_nodes(); + let patterns = vec!["nonexistent*".to_string()]; + let result = exclude_nodes(nodes.clone(), &patterns).unwrap(); + + assert_eq!(result.len(), nodes.len()); + } + + #[test] + fn test_exclude_all_hosts_returns_empty() { + let nodes = create_test_nodes(); + let patterns = vec!["*".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert!(result.is_empty()); + } + + #[test] + fn test_exclude_pattern_too_long() { + let nodes = create_test_nodes(); + let long_pattern = "a".repeat(300); + let patterns = vec![long_pattern]; + let result = exclude_nodes(nodes, &patterns); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too long")); + } + + #[test] + fn test_exclude_empty_pattern() { + let nodes = create_test_nodes(); + let patterns = vec!["".to_string()]; + let result = exclude_nodes(nodes, &patterns); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot be empty")); + } + + #[test] + fn test_exclude_too_many_wildcards() { + let nodes = create_test_nodes(); + let patterns = vec!["*a*b*c*d*e*f*g*h*i*j*k*".to_string()]; + let result = exclude_nodes(nodes, &patterns); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("too many wildcards")); + } + + #[test] + fn test_exclude_invalid_characters() { + let nodes = create_test_nodes(); + let patterns = vec!["host;rm -rf /".to_string()]; + let result = exclude_nodes(nodes, &patterns); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid characters")); + } + + #[test] + fn test_exclude_path_traversal_attempt() { + let nodes = create_test_nodes(); + let patterns = vec!["../etc/passwd".to_string()]; + let result = exclude_nodes(nodes, &patterns); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid sequences")); + } + + #[test] + fn test_exclude_partial_hostname_match() { + let nodes = create_test_nodes(); + // "web" should match "web1.example.com" and "web2.example.com" via contains + let patterns = vec!["web".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host.contains("web"))); + } + + #[test] + fn test_filter_and_exclude_combined() { + // Test that filter and exclude work correctly when used together + let nodes = create_test_nodes(); + + // First filter to only web and db nodes + let filtered = filter_nodes(nodes, "*.example.com").unwrap(); + assert_eq!(filtered.len(), 5); + + // Then exclude db nodes + let patterns = vec!["db*".to_string()]; + let result = exclude_nodes(filtered, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host.starts_with("db"))); + } +} diff --git a/src/cli.rs b/src/cli.rs index 8f696aca..82e95659 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,7 +23,7 @@ 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\nOutput Modes:\n- TUI Mode (default): Interactive terminal UI with real-time monitoring (auto-enabled in terminals)\n- Stream Mode (--stream): Real-time output with [node] prefixes\n- File Mode (--output-dir): Save per-node output to timestamped files\n- Normal Mode: Traditional output after all nodes complete\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\" # Execute on cluster (TUI mode auto-enabled)\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 Output Modes:\n bssh -C prod \"apt-get update\" # TUI mode (default, interactive monitoring)\n bssh -C prod --stream \"tail -f log\" # Stream mode (real-time with [node] prefixes)\n bssh -C prod --output-dir ./logs \"ps\" # File mode (save to timestamped files)\n bssh -C prod \"uptime\" | tee log.txt # Normal mode (auto-detected when piped)\n\n TUI Mode Controls (when in TUI):\n 1-9 Jump to node detail view\n s Enter split view (2-4 nodes)\n d Enter diff view (compare nodes)\n f Toggle auto-scroll\n ↑/↓ Scroll output\n ←/→ Switch nodes\n Esc Return to summary\n ? Show help\n q Quit\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" + 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 (TUI mode auto-enabled)\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 Host Exclusion (--exclude):\n bssh -H \"node1,node2,node3\" --exclude \"node2\" \"uptime\" # Exclude single host\n bssh -C production --exclude \"web1,web2\" \"apt update\" # Exclude multiple hosts\n bssh -C production --exclude \"db*\" \"systemctl restart\" # Exclude with wildcard pattern\n bssh -C production --exclude \"*-backup\" \"df -h\" # Exclude backup nodes\n\n Output Modes:\n bssh -C prod \"apt-get update\" # TUI mode (default, interactive monitoring)\n bssh -C prod --stream \"tail -f log\" # Stream mode (real-time with [node] prefixes)\n bssh -C prod --output-dir ./logs \"ps\" # File mode (save to timestamped files)\n bssh -C prod \"uptime\" | tee log.txt # Normal mode (auto-detected when piped)\n\n TUI Mode Controls (when in TUI):\n 1-9 Jump to node detail view\n s Enter split view (2-4 nodes)\n d Enter diff view (compare nodes)\n f Toggle auto-scroll\n ↑/↓ Scroll output\n ←/→ Switch nodes\n Esc Return to summary\n ? Show help\n q Quit\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] @@ -49,6 +49,13 @@ pub struct Cli { )] pub filter: Option, + #[arg( + long = "exclude", + value_delimiter = ',', + help = "Exclude hosts from target list (comma-separated)\nSupports wildcard patterns (e.g., 'db*', '*-backup')\nApplied after --filter option" + )] + pub exclude: Option>, + #[arg( short = 'C', long = "cluster", @@ -433,6 +440,11 @@ impl Cli { self.filter.as_deref() } + /// Get the host exclusion patterns if specified + pub fn get_exclude_patterns(&self) -> Option<&[String]> { + self.exclude.as_deref() + } + /// Parse destination string into components (user, host, port) pub fn parse_destination(&self) -> Option<(Option, String, Option)> { self.destination.as_ref().map(|dest| { From 948c237ed6c18b77e6fbcc76891c1a956a7457fc Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 16 Dec 2025 23:38:46 +0900 Subject: [PATCH 2/3] fix: Address PR review issues for exclude option - Add tests for bracket pattern matching (e.g., [12], [!12]) - Add tests for bracket negation patterns - Update help text to document matching behavior differences - Fix misleading comment about ReDoS protection - Allow '!' character in patterns for negation support --- src/app/nodes.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++-- src/cli.rs | 2 +- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/app/nodes.rs b/src/app/nodes.rs index 53569fba..28548c2e 100644 --- a/src/app/nodes.rs +++ b/src/app/nodes.rs @@ -214,6 +214,7 @@ pub fn exclude_nodes(nodes: Vec, patterns: &[String]) -> Result> } // Security: Sanitize pattern - only allow safe characters for hostnames + // Also allow '!' for negation patterns like [!abc] in glob let valid_chars = pattern.chars().all(|c| { c.is_ascii_alphanumeric() || c == '.' @@ -225,6 +226,7 @@ pub fn exclude_nodes(nodes: Vec, patterns: &[String]) -> Result> || c == '?' || c == '[' || c == ']' + || c == '!' }); if !valid_chars { @@ -287,7 +289,7 @@ pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { } // Security: Sanitize pattern - only allow safe characters for hostnames - // Allow alphanumeric, dots, hyphens, underscores, wildcards, and brackets + // Allow alphanumeric, dots, hyphens, underscores, wildcards, brackets, and '!' for negation let valid_chars = pattern.chars().all(|c| { c.is_ascii_alphanumeric() || c == '.' @@ -299,6 +301,7 @@ pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { || c == '?' || c == '[' || c == ']' + || c == '!' }); if !valid_chars { @@ -307,7 +310,7 @@ pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { // If pattern contains wildcards, use glob matching if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') { - // Security: Compile pattern with timeout to prevent ReDoS attacks + // Compile the glob pattern (DoS protection via length/wildcard limits above) let glob_pattern = Pattern::new(pattern).with_context(|| format!("Invalid filter pattern: {pattern}"))?; @@ -537,4 +540,48 @@ mod tests { assert_eq!(result.len(), 3); assert!(!result.iter().any(|n| n.host.starts_with("db"))); } + + #[test] + fn test_exclude_with_bracket_pattern() { + // Test bracket character range patterns + let nodes = create_test_nodes(); + // [12] should match db1 and db2 but not other nodes + let patterns = vec!["db[12].example.com".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.iter().any(|n| n.host == "db1.example.com")); + assert!(!result.iter().any(|n| n.host == "db2.example.com")); + assert!(result.iter().any(|n| n.host == "web1.example.com")); + } + + #[test] + fn test_filter_with_bracket_pattern() { + // Test bracket patterns work for filter_nodes as well + let nodes = create_test_nodes(); + let result = filter_nodes(nodes, "web[12].example.com").unwrap(); + + assert_eq!(result.len(), 2); + assert!(result.iter().any(|n| n.host == "web1.example.com")); + assert!(result.iter().any(|n| n.host == "web2.example.com")); + } + + #[test] + fn test_exclude_with_bracket_negation_pattern() { + // Test negation bracket patterns [!...] + let nodes = vec![ + Node::new("web1.example.com".to_string(), 22, "admin".to_string()), + Node::new("web2.example.com".to_string(), 22, "admin".to_string()), + Node::new("web3.example.com".to_string(), 22, "admin".to_string()), + Node::new("weba.example.com".to_string(), 22, "admin".to_string()), + ]; + // [!12] should match web3 and weba (anything that is NOT 1 or 2) + let patterns = vec!["web[!12].example.com".to_string()]; + let result = exclude_nodes(nodes, &patterns).unwrap(); + + // Should keep web1 and web2 (they DON'T match the exclusion pattern) + assert_eq!(result.len(), 2); + assert!(result.iter().any(|n| n.host == "web1.example.com")); + assert!(result.iter().any(|n| n.host == "web2.example.com")); + } } diff --git a/src/cli.rs b/src/cli.rs index 82e95659..53358ef0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,7 +52,7 @@ pub struct Cli { #[arg( long = "exclude", value_delimiter = ',', - help = "Exclude hosts from target list (comma-separated)\nSupports wildcard patterns (e.g., 'db*', '*-backup')\nApplied after --filter option" + help = "Exclude hosts from target list (comma-separated)\nSupports wildcard patterns: '*' (any chars), '?' (single char), '[abc]' (char set)\nMatching: patterns with wildcards use glob matching; plain patterns use substring matching\nApplied after --filter option" )] pub exclude: Option>, From 165d08cebb7966acb98884ac09427d0689e51fd1 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 16 Dec 2025 23:53:46 +0900 Subject: [PATCH 3/3] docs: Add --exclude option documentation - Add --exclude examples in README.md Multi-Server Mode section - Add --exclude to Command-Line Options in README.md - Add --exclude option documentation to manpage with examples - Update ARCHITECTURE.md with current nodes.rs line count --- ARCHITECTURE.md | 2 +- README.md | 9 ++++++++- docs/man/bssh.1 | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b084322f..9ea17a34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,7 +88,7 @@ See "Issue #33 Refactoring Details" section below for comprehensive breakdown. - `main.rs` - Clean entry point (69 lines) - `app/dispatcher.rs` - Command routing and dispatch (368 lines) - `app/initialization.rs` - App initialization and config loading (206 lines) -- `app/nodes.rs` - Node resolution and filtering (242 lines) +- `app/nodes.rs` - Node resolution, filtering, and exclusion (587 lines) - `app/cache.rs` - Cache statistics and management (142 lines) - `app/query.rs` - SSH query options handler (58 lines) - `app/utils.rs` - Utility functions (62 lines) diff --git a/README.md b/README.md index 0199777b..cca0ab56 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,11 @@ bssh -C production "df -h" bssh -H "web1,web2,db1,db2" -f "web*" "systemctl status nginx" bssh -C production -f "db*" "pg_dump --version" +# Exclude specific hosts from execution +bssh -H "node1,node2,node3" --exclude "node2" "uptime" +bssh -C production --exclude "db*" "systemctl restart nginx" +bssh -C production --exclude "web1,web2" "apt update" + # With custom SSH key bssh -C staging -i ~/.ssh/custom_key "systemctl status nginx" @@ -915,7 +920,9 @@ bssh -F ~/.ssh/prod-config -C production upload app.tar.gz /opt/ ``` Options: -H, --hosts Comma-separated list of hosts (user@host:port format) - -c, --cluster Cluster name from configuration file + -C, --cluster Cluster name from configuration file + -f, --filter Filter hosts by pattern (supports wildcards like 'web*') + --exclude Exclude hosts from target list (comma-separated, supports wildcards) --config Configuration file path [default: ~/.config/bssh/config.yaml] -u, --user Default username for SSH connections -i, --identity SSH private key file path (prompts for passphrase if encrypted) diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 3fcc793e..95a23f65 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -170,6 +170,27 @@ 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 \-\-exclude " " \fIHOSTS\fR +Exclude hosts from target list (comma-separated). +Supports wildcard patterns: '*' (any chars), '?' (single char), '[abc]' (char set). +Patterns with wildcards use glob matching; plain patterns use substring matching. +Applied after --filter option. +.RS +.PP +Examples: +.IP \[bu] 2 +--exclude "node2" - Exclude single host +.IP \[bu] 2 +--exclude "web1,web2" - Exclude multiple hosts +.IP \[bu] 2 +--exclude "db*" - Exclude hosts starting with 'db' +.IP \[bu] 2 +--exclude "*-backup" - Exclude backup nodes +.IP \[bu] 2 +--exclude "web[12]" - Exclude web1 and web2 +.RE + .TP .BR \-\-parallel " " \fIPARALLEL\fR Maximum parallel connections for multi-server mode (default: 10) @@ -1051,6 +1072,25 @@ Executes command only on hosts matching the pattern 'web*' Executes command only on database nodes in the production cluster .RE +.TP +Exclude specific hosts from execution: +.B bssh -H "node1,node2,node3" --exclude "node2" "uptime" +.RS +Executes command on node1 and node3, excluding node2 +.RE + +.TP +.B bssh -C production --exclude "db*" "systemctl restart nginx" +.RS +Executes command on all production hosts except database servers +.RE + +.TP +.B bssh -C production --exclude "web1,web2" "apt update" +.RS +Excludes specific hosts web1 and web2 from the cluster operation +.RE + .TP Test connectivity: .B bssh -C production ping