bssh 3.0.1

Parallel SSH command execution tool for cluster management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::io::IsTerminal;
use std::process::ExitCode;

use anyhow::Result;
use bssh::cli::{
    Cli, Commands, PdshCli, has_pdsh_compat_flag, is_pdsh_compat_mode, remove_pdsh_compat_flag,
};
use bssh::commands::ping::PING_SSH_LEVEL_FAILURE;
use bssh::hostlist;
use clap::Parser;
use glob::Pattern;

mod app;

#[cfg(unix)]
use app::background;
#[cfg(unix)]
use app::dispatcher::requires_background_supervision;
use app::{
    background::BackgroundWorker,
    cache::handle_cache_stats,
    config_dump::handle_config_dump,
    dispatcher::dispatch_command_with_background,
    initialization::{AppContext, initialize_app},
    query::{handle_query, is_supported_query},
    utils::show_usage,
};

/// Main entry point for bssh
///
/// Supports three modes of operation:
/// 1. Standard bssh CLI mode
/// 2. pdsh compatibility mode (via symlink, env var, or --pdsh-compat flag)
/// 3. SSH compatibility mode (single host)
#[tokio::main]
async fn main() -> ExitCode {
    match run().await {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            bssh::diagnosticln!("Error: {error:?}");
            ExitCode::FAILURE
        }
    }
}

async fn run() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();

    // Check for pdsh compatibility mode
    // Priority: env var / binary name > --pdsh-compat flag
    let pdsh_mode = is_pdsh_compat_mode() || has_pdsh_compat_flag(&args);

    if pdsh_mode {
        return run_pdsh_mode(&args).await;
    }

    // Raw dispatch is required before Clap so SSH destinations named like
    // bssh subcommands remain destinations. It also guarantees SSH-style
    // error status and diagnostic routing for all `-G` parse failures.
    if bssh::cli::SshDumpInvocation::requests_config_dump(&args) {
        if let Some(path) = bssh::cli::SshDumpInvocation::diagnostic_file(&args)
            && let Err(error) = bssh::utils::diagnostics::set_log_file(&path)
        {
            bssh::diagnosticln!("Error: {error:?}");
            std::process::exit(255);
        }
        let invocation = match bssh::cli::SshDumpInvocation::from_argv(&args) {
            Ok(invocation) => invocation,
            Err(error) => {
                bssh::diagnosticln!("Error: {error:?}");
                std::process::exit(255);
            }
        };
        if invocation.version {
            eprintln!("bssh_{}", env!("CARGO_PKG_VERSION"));
            return Ok(());
        }
        if let Some(query) = invocation.query.as_deref() {
            if !is_supported_query(query) {
                bssh::diagnosticln!("Unsupported query \"{query}\"");
                std::process::exit(255);
            }
            handle_query(query);
            return Ok(());
        }
        if let Err(error) = handle_config_dump(&invocation).await {
            bssh::diagnosticln!("Error: {error:?}");
            std::process::exit(255);
        }
        return Ok(());
    }

    // Standard bssh mode
    run_bssh_mode(&args).await
}

/// Run the dispatcher and translate its result into the process exit status.
///
/// This is the single place where a command-level exit code becomes a process
/// exit code. `dispatch_command` returns the code instead of exiting itself, so
/// the mapping stays in one place instead of being scattered across command
/// implementations.
async fn dispatch_and_exit(
    cli: &Cli,
    ctx: &AppContext,
    background_worker: Option<&BackgroundWorker>,
) -> Result<()> {
    match dispatch_command_with_background(cli, ctx, background_worker).await {
        Ok(0) => Ok(()),
        Ok(exit_code) => std::process::exit(exit_code),
        Err(e) => Err(map_hard_failure(&cli.command, cli.is_ssh_mode(), e)),
    }
}

/// Apply the `ping` exit code contract to a hard failure.
///
/// `ping` reports 255 whenever bssh itself failed rather than a remote host: a
/// configuration file that could not be loaded, a host list that resolved to
/// nothing, or any error raised before the connectivity check could produce a
/// per-host tally. That is OpenSSH's convention for "ssh encountered an error",
/// and it keeps the pre-connection case distinct from the exit code 1 that means
/// "some hosts answered and some did not".
///
/// Typed SSH client failures use OpenSSH's exit status 255. Local validation,
/// configuration, and other subcommand failures retain the generic exit status 1.
fn is_ssh_client_failure(error: &anyhow::Error) -> bool {
    error.chain().any(|cause| {
        cause
            .downcast_ref::<bssh::ssh::tokio_client::Error>()
            .is_some_and(bssh::ssh::tokio_client::Error::is_ssh_client_failure)
    })
}

fn map_hard_failure(
    command: &Option<Commands>,
    ssh_mode: bool,
    error: anyhow::Error,
) -> anyhow::Error {
    if matches!(command, Some(Commands::Ping)) {
        // Match the format anyhow's `Termination` impl uses, since this path
        // replaces it.
        bssh::diagnosticln!("Error: {error:?}");
        std::process::exit(PING_SSH_LEVEL_FAILURE);
    }

    if ssh_mode && is_ssh_client_failure(&error) {
        bssh::diagnosticln!("{error:#}");
        std::process::exit(255);
    }

    error
}

/// Run in pdsh compatibility mode
///
/// Parses pdsh-style arguments and converts them to bssh CLI options.
async fn run_pdsh_mode(args: &[String]) -> Result<()> {
    // Remove --pdsh-compat flag if present (pdsh parser doesn't know it)
    let filtered_args = if has_pdsh_compat_flag(args) {
        remove_pdsh_compat_flag(args)
    } else {
        args.to_vec()
    };

    // Parse pdsh-style arguments
    let pdsh_cli = PdshCli::parse_from(filtered_args.iter());

    // Handle query mode (-q): show hosts and exit
    if pdsh_cli.is_query_mode() {
        return handle_pdsh_query_mode(&pdsh_cli).await;
    }

    // Convert to bssh CLI
    let mut cli = pdsh_cli.to_bssh_cli();
    bssh::ui::configure_color(cli.color);

    // Check if we have hosts
    if cli.hosts.is_none() {
        bssh::diagnosticln!("Error: No hosts specified. Use -w to specify target hosts.");
        bssh::diagnosticln!("Usage: pdsh -w hosts command");
        std::process::exit(1);
    }

    // Check if we have a command (unless in query mode)
    if cli.command_args.is_empty() {
        bssh::diagnosticln!("Error: No command specified.");
        bssh::diagnosticln!("Usage: pdsh -w hosts command");
        std::process::exit(1);
    }

    // Initialize and run
    let ctx = initialize_app(&mut cli, args).await?;
    dispatch_and_exit(&cli, &ctx, None).await
}

/// Handle pdsh query mode (-q)
///
/// Shows the list of hosts that would be targeted and exits.
/// Supports hostlist expression expansion (e.g., node[1-5], rack[1-2]-node[1-3])
/// Uses the same glob pattern matching as the standard --exclude option
/// for consistency.
async fn handle_pdsh_query_mode(pdsh_cli: &PdshCli) -> Result<()> {
    if let Some(ref hosts_str) = pdsh_cli.hosts {
        // Expand hostlist expressions (e.g., node[1-5], rack[1-2]-node[1-3])
        let hosts: Vec<String> = hostlist::expand_host_specs(hosts_str)
            .map_err(|e| anyhow::anyhow!("Failed to expand host expression: {e}"))?;

        // Process exclusion patterns (supports both glob patterns and hostlist expressions)
        let (expanded_exclusions, glob_exclusions): (Vec<String>, Vec<Pattern>) = if let Some(
            ref exclude_str,
        ) =
            pdsh_cli.exclude
        {
            let mut expanded = Vec::new();
            let mut globs = Vec::new();

            for pattern in exclude_str.split(',').map(|s| s.trim()) {
                // Security: Validate pattern length
                const MAX_PATTERN_LENGTH: usize = 256;
                if pattern.len() > MAX_PATTERN_LENGTH {
                    anyhow::bail!(
                        "Exclusion pattern too long (max {MAX_PATTERN_LENGTH} characters)"
                    );
                }

                // Security: Skip empty patterns
                if pattern.is_empty() {
                    continue;
                }

                // Check if it's a hostlist expression (contains numeric range brackets)
                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}"))?;
                    expanded.extend(expanded_hosts);
                } else {
                    // Security: Prevent excessive wildcards for glob patterns
                    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})"
                        );
                    }

                    // Compile the glob pattern
                    match Pattern::new(pattern) {
                        Ok(p) => globs.push(p),
                        Err(_) => {
                            anyhow::bail!("Invalid exclusion pattern: {pattern}");
                        }
                    }
                }
            }
            (expanded, globs)
        } else {
            (Vec::new(), Vec::new())
        };

        // Create a set for O(1) lookup of expanded exclusions
        let exclusion_set: std::collections::HashSet<&str> =
            expanded_exclusions.iter().map(|s| s.as_str()).collect();

        // Filter and display hosts
        for host in &hosts {
            // Check if host is in the expanded exclusion set
            let is_excluded_by_hostlist = exclusion_set.contains(host.as_str());

            // Check if host matches any glob exclusion pattern
            let is_excluded_by_glob = glob_exclusions.iter().any(|pattern| {
                // For patterns without wildcards, also do exact/contains matching
                // (consistent with exclude_nodes in app/nodes.rs)
                let pattern_str = pattern.as_str();
                if !pattern_str.contains('*')
                    && !pattern_str.contains('?')
                    && !pattern_str.contains('[')
                {
                    host == pattern_str || host.contains(pattern_str)
                } else {
                    pattern.matches(host)
                }
            });

            if !is_excluded_by_hostlist && !is_excluded_by_glob {
                println!("{host}");
            }
        }
    } else {
        bssh::diagnosticln!("Error: No hosts specified for query mode.");
        bssh::diagnosticln!("Usage: pdsh -w hosts -q");
        std::process::exit(1);
    }

    Ok(())
}

/// Run in standard bssh mode
async fn run_bssh_mode(args: &[String]) -> Result<()> {
    // Check if no arguments were provided
    if args.len() == 1 {
        // Show concise usage when no arguments provided (like SSH)
        show_usage();
        std::process::exit(0);
    }

    let mut cli = Cli::parse_from(args);
    let effective_args = if cli.is_ssh_mode() {
        bssh::cli::normalize_ssh_option_pass(
            args,
            cli.destination.as_deref().unwrap_or_default(),
            cli.command_args.len(),
        )
    } else {
        args.to_vec()
    };
    if effective_args != args {
        cli = Cli::parse_from(&effective_args);
    }
    bssh::utils::diagnostics::set_quiet_warnings(cli.quiet);
    let background_worker = BackgroundWorker::from_environment()?;
    // Migration notices are human-facing. Keep redirected stderr and `-E`
    // logs byte-transparent for OpenSSH-compatible scripts and protocols.
    if background_worker.is_none() && cli.log_file.is_none() && std::io::stderr().is_terminal() {
        for warning in cli.short_flag_migration_warnings(&effective_args) {
            bssh::warningln!("{warning}");
        }
    }
    bssh::ui::configure_color(cli.color);

    if cli.version {
        eprintln!("bssh_{}", env!("CARGO_PKG_VERSION"));
        return Ok(());
    }

    if let Some(path) = &cli.log_file {
        bssh::utils::diagnostics::set_log_file(path)?;
    }

    // Handle SSH query option (-Q)
    if let Some(ref query) = cli.query {
        handle_query(query);
        return Ok(());
    }

    // Handle list command first (doesn't need initialization)
    if matches!(cli.command, Some(Commands::List))
        || (cli.is_multi_server_mode() && cli.destination.as_deref() == Some("list"))
    {
        // Load minimal config just for listing
        let config = bssh::config::Config::load_with_priority(&cli.config).await?;
        bssh::commands::list::list_clusters(&config);
        return Ok(());
    }

    // Handle cache-stats command (doesn't need full initialization)
    if let Some(Commands::CacheStats {
        detailed,
        clear,
        maintain,
    }) = &cli.command
    {
        handle_cache_stats(*detailed, *clear, *maintain).await;
        return Ok(());
    }

    #[cfg(not(unix))]
    if cli.is_ssh_mode() && cli.fork_after_authentication {
        anyhow::bail!("-f background-after-authentication currently requires Unix");
    }

    // Initialize the application and load all configurations. A failure here is
    // a pre-connection failure, which `ping` reports as 255.
    let init_result = initialize_app(&mut cli, &effective_args).await;
    let ctx = match init_result {
        Ok(ctx) => ctx,
        Err(e) => return Err(map_hard_failure(&cli.command, cli.is_ssh_mode(), e)),
    };

    // Re-execute only invocations that may actually detach. This decision is
    // made after effective ssh_config resolution so config-only
    // ForkAfterAuthentication and ControlPersist remain supported, while
    // ordinary commands and subsystem transports retain their original
    // single-process stdio and latency characteristics.
    #[cfg(unix)]
    if background_worker.is_none() && requires_background_supervision(&cli, &ctx)? {
        let exit_code = background::supervise(&effective_args)
            .await
            .map_err(|error| map_hard_failure(&cli.command, true, error))?;
        if exit_code == 0 {
            return Ok(());
        }
        std::process::exit(exit_code);
    }

    // Dispatch to the appropriate command handler
    dispatch_and_exit(&cli, &ctx, background_worker.as_ref()).await
}