Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/app/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ use bssh::{
pty::PtyConfig,
security::{Password, get_password, get_sudo_password},
ssh::{
CliTtyMode, SessionPolicy, SessionRequest,
CliTtyMode, SessionPolicy, SessionRequest, SshClient,
client::ConnectionConfig,
tokio_client::{AddressFamily, ProxyMode, SshConnectionConfigResolver},
},
};
Expand Down Expand Up @@ -75,6 +76,7 @@ fn build_ssh_connection_config_resolver(
cli.remote_forwards.clone(),
cli.dynamic_forwards.clone(),
)
.with_stdio_forward(cli.stdio_forward.is_some())
}

/// Decide whether `-S` (sudo-password) is meaningful for the given dispatch path.
Expand Down Expand Up @@ -548,7 +550,7 @@ fn resolve_ssh_mode_interactive_policy(
stdin_is_terminal,
jump_spec,
)?;
Ok(matches!(policy.request, SessionRequest::Shell).then_some(policy))
Ok((matches!(policy.request, SessionRequest::Shell) && !policy.stdin_null).then_some(policy))
}

fn session_policy_jump_spec(proxy_mode: Option<&ProxyMode>) -> Option<&str> {
Expand All @@ -568,6 +570,48 @@ async fn handle_exec_command(
command: &str,
ssh_password: Option<Arc<Password>>,
) -> Result<()> {
if let Some(target) = &cli.stdio_forward {
anyhow::ensure!(
cli.is_ssh_mode() && ctx.nodes.len() == 1,
"-W requires exactly one SSH destination"
);
let node = ctx
.nodes
.first()
.context("-W requires an SSH destination node")?;
let effective_cluster_name = ctx.cluster_name.as_deref().or(cli.cluster.as_deref());
let resolver = build_ssh_connection_config_resolver(cli, ctx, effective_cluster_name);
let resolved = resolver.resolve_for_host(node.config_host());
let key_path = determine_ssh_key_path(
cli,
&ctx.config,
&ctx.ssh_config,
Some(node.config_host()),
effective_cluster_name,
);
#[cfg(target_os = "macos")]
let use_keychain = determine_use_keychain(&ctx.ssh_config, Some(node.config_host()));
let config = ConnectionConfig {
key_path: key_path.as_deref(),
strict_mode: Some(ctx.strict_mode),
use_agent: cli.use_agent,
use_password: cli.password,
#[cfg(target_os = "macos")]
use_keychain,
timeout_seconds: None,
connect_timeout_seconds: Some(cli.connect_timeout),
jump_hosts_spec: cli.jump_hosts.as_deref(),
ssh_connection_config: Some(&resolved),
ssh_connection_config_resolver: Some(&resolver),
session_policy: None,
ssh_password,
};
let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone());
return client
.connect_and_forward_stdio((target.host.clone(), target.port), &config)
.await;
}

// Resolve policy even for a plain ssh-compatible shell. Remote/subsystem/
// none requests stay on the command executor; shell requests retain the
// existing interactive stdin, PTY resize, and byte-stream implementation.
Expand Down Expand Up @@ -821,6 +865,22 @@ mod tests {
);
assert_eq!(resolved.local_command.as_deref(), Some("true"));

configured.remote_command = None;
configured.stdin_null = Some(true);
assert!(
resolve_ssh_mode_interactive_policy(
&configured,
&node,
CliTtyMode::Default,
true,
None,
)
.unwrap()
.is_none(),
"StdinNull shells must use the EOF-capable raw executor"
);
configured.stdin_null = None;

configured.remote_command = Some("true".into());
assert!(
resolve_ssh_mode_interactive_policy(
Expand Down
12 changes: 8 additions & 4 deletions src/app/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,20 @@ pub fn is_supported_query(query: &str) -> bool {
pub 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");
println!(
"{}",
bssh::ssh::tokio_client::supported_cipher_names().join("\n")
);
}
"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");
println!(
"{}",
bssh::ssh::tokio_client::supported_mac_names().join("\n")
);
}
"kex" => {
println!("curve25519-sha256\ncurve25519-sha256@libssh.org");
Expand Down
90 changes: 89 additions & 1 deletion src/cli/bssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;

use super::ssh_args::StdioForwardTarget;

#[derive(Parser, Debug)]
#[command(
name = "bssh",
Expand Down Expand Up @@ -271,6 +273,8 @@ pub struct Cli {
short = 'c',
long = "cipher",
value_name = "cipher_spec",
allow_hyphen_values = true,
overrides_with = "cipher",
help = "Select SSH transport ciphers (OpenSSH-compatible -c)"
)]
pub cipher: Option<String>,
Expand All @@ -279,10 +283,35 @@ pub struct Cli {
short = 'm',
long = "macs",
value_name = "mac_spec",
allow_hyphen_values = true,
overrides_with = "macs",
help = "Select SSH MAC algorithms (OpenSSH-compatible -m)"
)]
pub macs: Option<String>,

#[arg(
short = 's',
long = "subsystem",
help = "Invoke the remote command as an SSH subsystem"
)]
pub subsystem: bool,

#[arg(
short = 'n',
long = "stdin-null",
help = "Redirect stdin from /dev/null"
)]
pub stdin_null: bool,

#[arg(
short = 'W',
long = "stdio-forward",
value_name = "host:port",
overrides_with = "stdio_forward",
help = "Forward standard input and output to host:port over SSH"
)]
pub stdio_forward: Option<StdioForwardTarget>,

#[arg(
short = 'F',
long = "ssh-config",
Expand Down Expand Up @@ -646,14 +675,22 @@ impl Cli {
let mut options = Vec::with_capacity(
self.ssh_options.len()
+ usize::from(self.cipher.is_some())
+ usize::from(self.macs.is_some()),
+ usize::from(self.macs.is_some())
+ usize::from(self.subsystem)
+ usize::from(self.stdin_null),
);
if let Some(cipher) = &self.cipher {
options.push(format!("Ciphers={cipher}"));
}
if let Some(macs) = &self.macs {
options.push(format!("MACs={macs}"));
}
if self.subsystem {
options.push("SessionType=subsystem".to_string());
}
if self.stdin_null {
options.push("StdinNull=yes".to_string());
}
options.extend(self.ssh_options.iter().cloned());
options
}
Expand Down Expand Up @@ -908,6 +945,57 @@ mod tests {
]
);
}

#[test]
fn compatibility_flags_parse_repetition_modifiers_and_session_overrides() {
let cli = Cli::try_parse_from([
"bssh",
"-c",
"aes128-ctr",
"-c",
"-aes128-cbc",
"-m",
"hmac-sha1",
"-m",
"+hmac-sha2-256",
"-sn",
"-W[::1]:443",
"target",
"sftp",
])
.unwrap();

assert_eq!(cli.cipher.as_deref(), Some("-aes128-cbc"));
assert_eq!(cli.macs.as_deref(), Some("+hmac-sha2-256"));
assert!(cli.subsystem && cli.stdin_null);
assert_eq!(cli.stdio_forward.as_ref().unwrap().host, "::1");
assert_eq!(
cli.ssh_config_overrides(),
[
"Ciphers=-aes128-cbc",
"MACs=+hmac-sha2-256",
"SessionType=subsystem",
"StdinNull=yes",
]
);
}

#[test]
fn second_option_pass_preserves_hyphen_leading_command_after_double_dash() {
let argv = ["bssh", "host", "-s", "--", "-literal-command"]
.map(str::to_string)
.to_vec();
let first = Cli::try_parse_from(&argv).unwrap();
let normalized = crate::cli::normalize_ssh_option_pass(
&argv,
first.destination.as_deref().unwrap(),
first.command_args.len(),
);
let parsed = Cli::try_parse_from(normalized).unwrap();
assert!(parsed.subsystem);
assert_eq!(parsed.destination.as_deref(), Some("host"));
assert_eq!(parsed.command_args, ["-literal-command"]);
}
#[test]
fn openssh_cipher_flag_rejects_unsupported_and_empty_policies() {
for value in ["not-a-supported-cipher", ""] {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ mod mode_detection_tests;

// Re-export main CLI types from bssh module
pub use bssh::{Cli, Commands};
pub use ssh_args::SshDumpInvocation;
pub use ssh_args::{SshDumpInvocation, StdioForwardTarget, normalize_ssh_option_pass};

// Re-export pdsh compatibility utilities
pub use pdsh::{
Expand Down
3 changes: 3 additions & 0 deletions src/cli/pdsh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ impl PdshCli {
ssh_options: Vec::new(),
cipher: None,
macs: None,
subsystem: false,
stdin_null: false,
stdio_forward: None,
ssh_config: None,
quiet: false,
force_tty: false,
Expand Down
Loading
Loading