diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 94ca1c3e..3683898b 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -113,7 +113,29 @@ Example: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all in .TP .BR \-H ", " \-\-hosts " " \fIHOSTS\fR Comma-separated list of hosts in [user@]hostname[:port] format. -Example: user1@host1:2222,user2@host2 +Supports pdsh-style hostlist expressions for range expansion. +.RS +.PP +Simple host list: +.IP \[bu] 2 +-H "user1@host1:2222,user2@host2" +.PP +Hostlist expressions (range expansion): +.IP \[bu] 2 +-H "node[1-5]" \[->] node1, node2, node3, node4, node5 +.IP \[bu] 2 +-H "node[01-05]" \[->] node01, node02, ... (zero-padded) +.IP \[bu] 2 +-H "node[1,3,5]" \[->] node1, node3, node5 (specific values) +.IP \[bu] 2 +-H "rack[1-2]-node[1-3]" \[->] 6 hosts (cartesian product) +.IP \[bu] 2 +-H "web[1-3].example.com" \[->] web1.example.com, web2.example.com, ... +.IP \[bu] 2 +-H "admin@web[1-3]:22" \[->] expands with user and port preserved +.IP \[bu] 2 +-H "^/path/to/hostfile" \[->] read hosts from file +.RE .TP .BR \-C ", " \-\-cluster " " \fICLUSTER\fR @@ -166,29 +188,47 @@ Password is never logged or printed in any output .TP .BR \-f ", " \-\-filter " " \fIPATTERN\fR -Filter hosts by pattern (supports wildcards like 'web*'). +Filter hosts by pattern. Supports both wildcards and hostlist expressions. Use with -H or -C to execute on a subset of hosts. -Example: -f "web*" matches web01, web02, etc. +.RS +.PP +Examples: +.IP \[bu] 2 +-f "web*" \[->] matches web01, web02, etc. (glob pattern) +.IP \[bu] 2 +-f "node[1-5]" \[->] matches node1 through node5 (hostlist expression) +.IP \[bu] 2 +-f "node[1,3,5]" \[->] matches node1, node3, node5 (specific values) +.RE .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. +Supports wildcards, glob patterns, and hostlist expressions. Applied after --filter option. .RS .PP -Examples: +Glob patterns: +.IP \[bu] 2 +--exclude "db*" \[->] exclude hosts starting with 'db' .IP \[bu] 2 ---exclude "node2" - Exclude single host +--exclude "*-backup" \[->] exclude backup nodes .IP \[bu] 2 ---exclude "web1,web2" - Exclude multiple hosts +--exclude "web[12]" \[->] exclude web1 and web2 (glob character class) +.PP +Hostlist expressions: +.IP \[bu] 2 +--exclude "node[3-5]" \[->] exclude node3, node4, node5 (range) .IP \[bu] 2 ---exclude "db*" - Exclude hosts starting with 'db' +--exclude "node[1,3,5]" \[->] exclude node1, node3, node5 (specific values) .IP \[bu] 2 ---exclude "*-backup" - Exclude backup nodes +--exclude "rack[1-2]-node[1-3]" \[->] exclude 6 hosts (cartesian product) +.PP +Simple patterns: .IP \[bu] 2 ---exclude "web[12]" - Exclude web1 and web2 +--exclude "node2" \[->] exclude single host +.IP \[bu] 2 +--exclude "web1,web2" \[->] exclude multiple hosts .RE .TP @@ -1094,6 +1134,79 @@ Current node's role (main or sub) Note: Backend.AI multi-node clusters use SSH port 2200 by default, which is automatically configured. +.SH HOSTLIST EXPRESSIONS +Hostlist expressions provide a compact way to specify multiple hosts using range notation, +compatible with pdsh syntax. This allows efficient targeting of large numbers of hosts +without listing each one individually. + +.SS Basic Syntax +.TP +.B Simple range +.B node[1-5] +expands to node1, node2, node3, node4, node5 +.TP +.B Zero-padded range +.B node[01-05] +expands to node01, node02, node03, node04, node05 +.TP +.B Comma-separated values +.B node[1,3,5] +expands to node1, node3, node5 +.TP +.B Mixed ranges and values +.B node[1-3,7,9-10] +expands to node1, node2, node3, node7, node9, node10 + +.SS Advanced Patterns +.TP +.B Multiple ranges (cartesian product) +.B rack[1-2]-node[1-3] +expands to rack1-node1, rack1-node2, rack1-node3, rack2-node1, rack2-node2, rack2-node3 +.TP +.B Domain suffix +.B web[1-3].example.com +expands to web1.example.com, web2.example.com, web3.example.com +.TP +.B With user and port +.B admin@server[1-3]:2222 +expands to admin@server1:2222, admin@server2:2222, admin@server3:2222 + +.SS File Input +.TP +.B ^/path/to/hostfile +Reads hosts from file, one per line. Lines starting with # are comments. +Maximum file size: 1MB, maximum lines: 100,000. + +.SS Using with Options +Hostlist expressions can be used with: +.TP +.B -H, --hosts +Specify target hosts: +.B bssh -H "node[1-10]" "uptime" +.TP +.B --filter +Include only matching hosts: +.B bssh -c cluster --filter "web[1-5]" "systemctl status nginx" +.TP +.B --exclude +Exclude matching hosts: +.B bssh -c cluster --exclude "node[1,3,5]" "df -h" + +.SS Examples +.nf +# Execute on 100 nodes +bssh -H "compute[001-100]" "hostname" + +# Target specific racks +bssh -H "rack[A-C]-node[1-8]" "uptime" + +# Use hosts from file +bssh -H "^/etc/cluster/hosts" "date" + +# Combine with exclusions +bssh -H "node[1-20]" --exclude "node[5,10,15]" "ps aux" +.fi + .SH EXAMPLES .SS SSH Compatibility Mode (Single Host) diff --git a/src/app/nodes.rs b/src/app/nodes.rs index 9d3e1900..7e77b1da 100644 --- a/src/app/nodes.rs +++ b/src/app/nodes.rs @@ -349,79 +349,6 @@ pub fn filter_nodes(nodes: Vec, pattern: &str) -> Result> { } } -/// Check if a pattern is a hostlist expression (contains range brackets) -fn is_hostlist_expression(pattern: &str) -> bool { - // A hostlist expression has [...] with numbers/ranges inside - // But we need to distinguish from glob patterns like [abc] - if !pattern.contains('[') || !pattern.contains(']') { - return false; - } - - // Find bracket content and check if it looks like a hostlist range - let mut in_bracket = false; - let mut bracket_content = String::new(); - - for ch in pattern.chars() { - match ch { - '[' if !in_bracket => { - in_bracket = true; - bracket_content.clear(); - } - ']' if in_bracket => { - // Check if bracket content looks like a hostlist range - // Hostlist: [1-5], [01-05], [1,2,3], [1-3,5-7] - // Glob: [abc], [!xyz], [a-z] - if looks_like_hostlist_range(&bracket_content) { - return true; - } - in_bracket = false; - } - _ if in_bracket => { - bracket_content.push(ch); - } - _ => {} - } - } - - false -} - -/// Check if bracket content looks like a hostlist numeric range -fn looks_like_hostlist_range(content: &str) -> bool { - if content.is_empty() { - return false; - } - - // Hostlist ranges are numeric: 1-5, 01-05, 1,2,3, 1-3,5-7 - // Glob patterns have letters: abc, a-z, !xyz - for part in content.split(',') { - let part = part.trim(); - if part.is_empty() { - continue; - } - - // Check if it's a range (contains -) - if part.contains('-') { - let parts: Vec<&str> = part.splitn(2, '-').collect(); - if parts.len() == 2 { - // Both parts should be numeric for hostlist - if parts[0].chars().all(|c| c.is_ascii_digit()) - && parts[1].chars().all(|c| c.is_ascii_digit()) - { - return true; - } - } - } else { - // Single value should be numeric for hostlist - if part.chars().all(|c| c.is_ascii_digit()) { - return true; - } - } - } - - false -} - /// Filter nodes with hostlist expression support /// /// If the pattern contains hostlist expressions (e.g., node[1-5]), @@ -434,7 +361,7 @@ pub fn filter_nodes_with_hostlist(nodes: Vec, pattern: &str) -> Result, patterns: &[String]) -> Res let mut glob_patterns = Vec::new(); for pattern in patterns { - if is_hostlist_expression(pattern) { + if hostlist::is_hostlist_expression(pattern) { // Expand hostlist expression let expanded = hostlist::expander::expand_host_specs(pattern) .with_context(|| format!("Failed to expand exclusion pattern: {pattern}"))?; @@ -507,6 +434,7 @@ pub fn exclude_nodes_with_hostlist(nodes: Vec, patterns: &[String]) -> Res #[cfg(test)] mod tests { use super::*; + use bssh::hostlist::is_hostlist_expression; fn create_test_nodes() -> Vec { vec![ diff --git a/src/hostlist/expander.rs b/src/hostlist/expander.rs index 18cf5df1..ffc0c6fe 100644 --- a/src/hostlist/expander.rs +++ b/src/hostlist/expander.rs @@ -108,7 +108,25 @@ fn expand_segments(segments: &[PatternSegment]) -> Result, HostlistE PatternSegment::Range(range_expr) => { // Expand with all values from the range let values = range_expr.values(); - let mut new_results = Vec::with_capacity(results.len() * values.len()); + + // Use checked multiplication to prevent integer overflow + let new_capacity = results.len().checked_mul(values.len()).ok_or_else(|| { + HostlistError::RangeTooLarge { + expression: "cartesian product".to_string(), + count: usize::MAX, + limit: MAX_EXPANSION_SIZE, + } + })?; + + if new_capacity > MAX_EXPANSION_SIZE { + return Err(HostlistError::RangeTooLarge { + expression: "cartesian product".to_string(), + count: new_capacity, + limit: MAX_EXPANSION_SIZE, + }); + } + + let mut new_results = Vec::with_capacity(new_capacity); for result in &results { for value in &values { diff --git a/src/hostlist/mod.rs b/src/hostlist/mod.rs index f53c7097..c530bd61 100644 --- a/src/hostlist/mod.rs +++ b/src/hostlist/mod.rs @@ -54,6 +54,79 @@ pub use error::HostlistError; pub use expander::{expand_host_spec, expand_host_specs, expand_hostlist}; pub use parser::{parse_host_pattern, parse_hostfile, HostPattern}; +/// Check if a pattern is a hostlist expression (contains numeric range brackets) +/// +/// Hostlist expressions have brackets containing numeric ranges like [1-5], [01-05], [1,2,3] +/// Glob patterns have brackets containing characters like [abc], [a-z], [!xyz] +pub fn is_hostlist_expression(pattern: &str) -> bool { + // A hostlist expression has [...] with numbers/ranges inside + if !pattern.contains('[') || !pattern.contains(']') { + return false; + } + + // Find bracket content and check if it looks like a hostlist range + let mut in_bracket = false; + let mut bracket_content = String::new(); + + for ch in pattern.chars() { + match ch { + '[' if !in_bracket => { + in_bracket = true; + bracket_content.clear(); + } + ']' if in_bracket => { + // Check if bracket content looks like a hostlist range + if looks_like_hostlist_range(&bracket_content) { + return true; + } + in_bracket = false; + } + _ if in_bracket => { + bracket_content.push(ch); + } + _ => {} + } + } + + false +} + +/// Check if bracket content looks like a hostlist numeric range +pub fn looks_like_hostlist_range(content: &str) -> bool { + if content.is_empty() { + return false; + } + + // Hostlist ranges are numeric: 1-5, 01-05, 1,2,3, 1-3,5-7 + // Glob patterns have letters: abc, a-z, !xyz + for part in content.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + // Check if it's a range (contains -) + if part.contains('-') { + let parts: Vec<&str> = part.splitn(2, '-').collect(); + if parts.len() == 2 { + // Both parts should be numeric for hostlist + if parts[0].chars().all(|c| c.is_ascii_digit()) + && parts[1].chars().all(|c| c.is_ascii_digit()) + { + return true; + } + } + } else { + // Single value should be numeric for hostlist + if part.chars().all(|c| c.is_ascii_digit()) { + return true; + } + } + } + + false +} + /// Expand a comma-separated list of host patterns /// /// This function handles multiple patterns separated by commas, diff --git a/src/hostlist/parser.rs b/src/hostlist/parser.rs index db5b090b..364acce9 100644 --- a/src/hostlist/parser.rs +++ b/src/hostlist/parser.rs @@ -352,10 +352,10 @@ fn parse_number(s: &str, pattern: &str) -> Result<(i64, usize), HostlistError> { } // Determine padding from leading zeros - let (sign, digits) = if let Some(rest) = s.strip_prefix('-') { - (-1, rest) + let digits = if let Some(rest) = s.strip_prefix('-') { + rest } else { - (1, s) + s }; // Count padding (leading zeros) @@ -365,17 +365,21 @@ fn parse_number(s: &str, pattern: &str) -> Result<(i64, usize), HostlistError> { 0 }; - // Parse the number + // Parse the number (includes sign if present) let value: i64 = s.parse().map_err(|_| HostlistError::InvalidNumber { expression: pattern.to_string(), value: s.to_string(), })?; - let _ = sign; // value already includes sign from parse - Ok((value, padding)) } +/// Maximum file size for hostfile (1 MB) +const MAX_HOSTFILE_SIZE: u64 = 1024 * 1024; + +/// Maximum number of lines in a hostfile +const MAX_HOSTFILE_LINES: usize = 100_000; + /// Parse hosts from a file (one per line) /// /// # Arguments @@ -385,8 +389,15 @@ fn parse_number(s: &str, pattern: &str) -> Result<(i64, usize), HostlistError> { /// # Returns /// /// A vector of hostnames read from the file. +/// +/// # Security +/// +/// This function implements resource limits to prevent DoS attacks: +/// - Maximum file size: 1 MB +/// - Maximum number of lines: 100,000 pub fn parse_hostfile(path: &Path) -> Result, HostlistError> { - let content = std::fs::read_to_string(path).map_err(|e| { + // Check file size before reading to prevent resource exhaustion + let metadata = std::fs::metadata(path).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { HostlistError::FileNotFound { path: path.display().to_string(), @@ -399,13 +410,41 @@ pub fn parse_hostfile(path: &Path) -> Result, HostlistError> { } })?; + let file_size = metadata.len(); + if file_size > MAX_HOSTFILE_SIZE { + return Err(HostlistError::FileReadError { + path: path.display().to_string(), + reason: format!( + "file size {} bytes exceeds maximum allowed size of {} bytes", + file_size, MAX_HOSTFILE_SIZE + ), + }); + } + + let content = std::fs::read_to_string(path).map_err(|e| HostlistError::FileReadError { + path: path.display().to_string(), + reason: e.to_string(), + })?; + let hosts: Vec = content .lines() + .take(MAX_HOSTFILE_LINES) .map(|line| line.trim()) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(String::from) .collect(); + // Check if we hit the line limit + if content.lines().count() > MAX_HOSTFILE_LINES { + return Err(HostlistError::FileReadError { + path: path.display().to_string(), + reason: format!( + "file contains more than {} lines (limit exceeded)", + MAX_HOSTFILE_LINES + ), + }); + } + Ok(hosts) } diff --git a/src/main.rs b/src/main.rs index 45f8e23e..59804814 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,7 +123,7 @@ async fn handle_pdsh_query_mode(pdsh_cli: &PdshCli) -> Result<()> { } // Check if it's a hostlist expression (contains numeric range brackets) - if is_hostlist_expression(pattern) { + if hostlist::is_hostlist_expression(pattern) { // Expand hostlist expression let expanded_hosts = hostlist::expand_host_specs(pattern).map_err(|e| { anyhow::anyhow!("Failed to expand exclusion pattern: {e}") @@ -235,76 +235,3 @@ async fn run_bssh_mode(args: &[String]) -> Result<()> { // Dispatch to the appropriate command handler dispatch_command(&cli, &ctx).await } - -/// Check if a pattern is a hostlist expression (contains numeric range brackets) -/// -/// Hostlist expressions have brackets containing numeric ranges like [1-5], [01-05], [1,2,3] -/// Glob patterns have brackets containing characters like [abc], [a-z], [!xyz] -fn is_hostlist_expression(pattern: &str) -> bool { - // A hostlist expression has [...] with numbers/ranges inside - if !pattern.contains('[') || !pattern.contains(']') { - return false; - } - - // Find bracket content and check if it looks like a hostlist range - let mut in_bracket = false; - let mut bracket_content = String::new(); - - for ch in pattern.chars() { - match ch { - '[' if !in_bracket => { - in_bracket = true; - bracket_content.clear(); - } - ']' if in_bracket => { - // Check if bracket content looks like a hostlist range - if looks_like_hostlist_range(&bracket_content) { - return true; - } - in_bracket = false; - } - _ if in_bracket => { - bracket_content.push(ch); - } - _ => {} - } - } - - false -} - -/// Check if bracket content looks like a hostlist numeric range -fn looks_like_hostlist_range(content: &str) -> bool { - if content.is_empty() { - return false; - } - - // Hostlist ranges are numeric: 1-5, 01-05, 1,2,3, 1-3,5-7 - // Glob patterns have letters: abc, a-z, !xyz - for part in content.split(',') { - let part = part.trim(); - if part.is_empty() { - continue; - } - - // Check if it's a range (contains -) - if part.contains('-') { - let parts: Vec<&str> = part.splitn(2, '-').collect(); - if parts.len() == 2 { - // Both parts should be numeric for hostlist - if parts[0].chars().all(|c| c.is_ascii_digit()) - && parts[1].chars().all(|c| c.is_ascii_digit()) - { - return true; - } - } - } else { - // Single value should be numeric for hostlist - if part.chars().all(|c| c.is_ascii_digit()) { - return true; - } - } - } - - false -}