Skip to content

Commit fa4c857

Browse files
authored
fix: execute ProxyCommand transport (#293)
## Summary - Execute resolved `ProxyCommand` values as managed shell child transports instead of silently opening a direct target socket. - Match OpenSSH proxy selection semantics across `ProxyCommand`, `ProxyJump`, command-line `-J`, explicit `none`, and effective `%h`/`%k`/`%n` token identities. - Report bounded proxy stderr and exit status as actionable connection errors, and reject unsupported `ProxyUseFdpass` before spawning a process. ## Implementation - Add a duplex async transport backed by child stdin/stdout with bounded stderr draining, exit monitoring, deterministic shutdown, and kill-on-drop lifecycle handling. - Expand `%%`, `%h`, `%k`, `%n`, `%p`, and `%r`, reject malformed or unsupported tokens, and preserve shell pipes and quoting from trusted SSH configuration. - Preserve the first-obtained SSH configuration semantics introduced by the related proxy and host-key changes, with command-line `-J` taking precedence. - Silence only the official but unimplemented `SecurityKeyProvider` option while retaining diagnostics for genuinely unknown keywords. - Preserve raw command exit codes after stdout and stderr and keep draining the SSH channel when the local output receiver closes, fixing integration failures exposed by the real proxy transport. - Update user and manual documentation to describe the supported behavior and the explicit `ProxyUseFdpass` limitation. ## Security and quality review - Reviewed shell-boundary validation, token expansion, direct-connection avoidance, child cleanup, stderr memory bounds, failure mapping, host-key alias identity, and early receiver-drop behavior; no unresolved CRITICAL or HIGH findings remain. - Proxy commands remain an explicitly trusted SSH configuration shell boundary; target-derived tokens retain existing host and username validation, while NUL and newline injection and unsupported expansion tokens are rejected. ## Validation - `cargo fmt --all -- --check` - `cargo test --lib proxy` (29 passed) - `cargo test --lib resolve_effective_jump_hosts` (11 passed) - `cargo test --lib stream_exit` (2 passed) - `cargo test --lib nonzero_stream_completion_preserves_remote_status` (1 passed) - `cargo test --lib closed_output_receiver_is_not_a_command_error` (1 passed) - `cargo test --test ssh_compat_output_test deprecated_alias_is_silent_and_unknown_keyword_uses_log_file -- --exact` (1 passed) - `cargo check --lib --tests` - `cargo clippy --lib --tests -- -D warnings` - `cargo build --bin bssh` - `cargo test --bin bssh` (57 passed) - `TEST_SSH_UNSAFE_PERMISSIONS=1 python3 tests/openssh-regress/run.py --bssh target/debug/bssh --test proxy-connect --jobs 1 --timeout 120` (PASS, 1/1) Local CI-equivalent note: `cargo test --tests -- --skip integration_test` passed the library tests (1420 passed, 9 ignored, 13 filtered), the bssh binary tests (57 passed), and the subsequent keygen/server and independent test targets until `tests/integration_test.rs`; three unchanged localhost SSH-dependent cases failed in the local harness (`test_localhost_multiple_file_upload`, `test_parallel_execution_with_multiple_nodes`, and `test_download_with_unique_filenames`). The PR's exact-head GitHub checks are the authoritative CI verdict for those environment-dependent cases. Closes #280
1 parent 4900966 commit fa4c857

28 files changed

Lines changed: 1169 additions & 234 deletions

README.md

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,12 @@ These options control SSH proxy connection behavior:
874874

875875
| Option | Description | Example |
876876
|--------|-------------|---------|
877-
| **ProxyUseFdpass** | Pass connected file descriptor from ProxyCommand to ssh(1) instead of continuing execution (yes/no, default: no, OpenSSH 6.5+) | `ProxyUseFdpass yes` |
877+
| **ProxyCommand** | Run a shell command whose stdin/stdout carry the SSH transport; supports `%%`, `%h`, `%k`, `%n`, `%p`, and `%r` | `ProxyCommand nc %h %p` |
878+
| **ProxyUseFdpass** | Request descriptor passing from ProxyCommand (recognized but not supported by bssh) | `ProxyUseFdpass no` |
878879

879-
**ProxyUseFdpass** optimizes ProxyCommand usage by eliminating an unnecessary lingering process and reducing I/O overhead. When enabled, the proxy command passes the established connection file descriptor directly to ssh and exits, rather than remaining active to relay data throughout the session. This is particularly useful with proxy commands like netcat that support file descriptor passing (nc -F).
880+
**ProxyCommand** uses the user's shell so OpenSSH-style quoting, redirection, and pipelines work. `ProxyCommand none` explicitly selects a direct connection. `ProxyCommand` and `ProxyJump` follow OpenSSH's first-obtained ssh_config rule, while command-line `-J` takes precedence over both.
880881

881-
*Note: This option is currently parsed from SSH configuration files for compatibility but is not yet utilized in bssh's SSH client implementation, as proxy connections are not yet supported.*
882+
*Note: `ProxyUseFdpass yes` is rejected with an actionable error before the command starts. Use a streaming ProxyCommand without `-F`, or set `ProxyUseFdpass no`.*
882883

883884
### Command Execution and Automation Options
884885

@@ -1007,20 +1008,13 @@ Host *.secure.prod.example.com
10071008
#### Proxy Connection Optimization
10081009

10091010
```ssh-config
1010-
# Optimized proxy connection with file descriptor passing
1011+
# HTTP CONNECT proxy
10111012
Host internal-server
1012-
ProxyCommand nc -X connect -x proxy.example.com:1080 -F %h %p
1013-
ProxyUseFdpass yes
1013+
ProxyCommand nc -X connect -x proxy.example.com:1080 %h %p
10141014
1015-
# SOCKS proxy with netcat (reduces overhead)
1015+
# SOCKS proxy with netcat
10161016
Host *.internal.example.com
1017-
ProxyCommand nc -x socks.example.com:1080 -F %h %p
1018-
ProxyUseFdpass yes
1019-
1020-
# Jump host with ProxyCommand and fd passing
1021-
Host bastion-optimized
1022-
ProxyCommand ssh -W %h:%p jump.example.com
1023-
ProxyUseFdpass yes
1017+
ProxyCommand nc -x socks.example.com:1080 %h %p
10241018
```
10251019

10261020
#### Command Execution and Automation

docs/man/bssh.1

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -639,27 +639,30 @@ Example:
639639

640640
.SS Proxy Options
641641
.TP
642-
.B ProxyUseFdpass
643-
Specifies that ProxyCommand will pass a connected file descriptor back to ssh(1)
644-
instead of continuing to execute and relay data.
645-
This reduces overhead by avoiding an unnecessary lingering process and extra I/O operations.
646-
When enabled, the proxy command establishes a connection, passes the file descriptor
647-
to ssh via the fdpass mechanism, and exits. The ssh process then communicates directly
648-
with the connection, eliminating the intermediate proxy process.
649-
.br
650-
Default is no.
642+
.B ProxyCommand
643+
Executes a local command whose standard input and output carry the SSH transport.
644+
The command is run by the user's shell, preserving OpenSSH-style quoting,
645+
redirection, and pipelines.
651646
.br
652-
Introduced in OpenSSH 6.5 (January 2014).
647+
Supported tokens are %%, %h, %k, %n, %p, and %r.
653648
.br
654-
This is particularly useful with proxy commands like netcat that support file descriptor
655-
passing via the -F option (nc.openbsd).
649+
.I ProxyCommand none
650+
explicitly selects a direct connection. When ProxyCommand and ProxyJump both
651+
appear in ssh_config, the first obtained value is used; command-line -J takes
652+
precedence over both.
656653
.br
657654
Example:
658-
.I ProxyUseFdpass yes
655+
.I ProxyCommand nc %h %p
656+
657+
.TP
658+
.B ProxyUseFdpass
659+
Requests that ProxyCommand pass a connected file descriptor instead of relaying
660+
the SSH transport over standard input and output.
659661
.br
660-
Note: This option is currently parsed from SSH configuration files for compatibility
661-
but is not yet utilized in bssh's SSH client implementation, as proxy connections
662-
are not yet supported.
662+
Default is no. bssh does not support descriptor passing and rejects
663+
.I ProxyUseFdpass yes
664+
with an actionable error before starting the proxy command. Use a streaming
665+
ProxyCommand without netcat's -F option instead.
663666

664667
.SS Command Execution and Automation Options
665668
.TP
@@ -1048,15 +1051,13 @@ Host *.secure.prod.example.com
10481051
PermitRemoteOpen localhost:8080
10491052
PermitRemoteOpen db.internal:5432
10501053

1051-
# Optimized proxy connection with file descriptor passing
1054+
# HTTP CONNECT proxy
10521055
Host internal-server
1053-
ProxyCommand nc -X connect -x proxy.example.com:1080 -F %h %p
1054-
ProxyUseFdpass yes
1056+
ProxyCommand nc -X connect -x proxy.example.com:1080 %h %p
10551057

1056-
# SOCKS proxy with netcat (reduces overhead)
1058+
# SOCKS proxy with netcat
10571059
Host *.internal.example.com
1058-
ProxyCommand nc -x socks.example.com:1080 -F %h %p
1059-
ProxyUseFdpass yes
1060+
ProxyCommand nc -x socks.example.com:1080 %h %p
10601061

10611062
# Development server with automatic file sync
10621063
Host dev-server

src/app/dispatcher.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ fn build_ssh_connection_config_resolver(
6262
.with_yaml_keepalive_max(ctx.config.get_server_alive_count_max(cluster_name))
6363
.with_cli_address_family(AddressFamily::from_flags(cli.ipv4, cli.ipv6))
6464
.with_cli_host_key_alias(cli.get_ssh_option("HostKeyAlias"))
65+
.with_cli_proxy_jump(cli.jump_hosts.clone())
66+
.with_yaml_proxy_jump(ctx.config.get_cluster_jump_host(cluster_name))
6567
}
6668

6769
/// Decide whether `-S` (sudo-password) is meaningful for the given dispatch path.

src/app/initialization.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,15 +424,15 @@ Host example.com
424424
#[test]
425425
fn test_determine_effective_jump_hosts_wildcard_pattern() {
426426
let ssh_config_content = r#"
427-
Host *.internal
428-
ProxyJump gateway.company.com
429-
430427
Host db.internal
431428
ProxyJump db-gateway.company.com
429+
430+
Host *.internal
431+
ProxyJump gateway.company.com
432432
"#;
433433
let ssh_config = SshConfig::parse(ssh_config_content).unwrap();
434434

435-
// Should match the most specific pattern
435+
// OpenSSH uses the first value obtained, so put the exact host first.
436436
let result = determine_effective_jump_hosts(None, &ssh_config, "db.internal");
437437
assert_eq!(result, Some("db-gateway.company.com".to_string()));
438438

src/app/nodes.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,10 @@ pub fn parse_node_with_ssh_config(node_str: &str, ssh_config: &SshConfig) -> Res
7575
};
7676
let effective_port = ssh_config.get_effective_port(raw_host, cli_port);
7777

78-
Ok(Node::new(
79-
effective_hostname,
80-
effective_port,
81-
effective_user,
82-
))
78+
Ok(
79+
Node::new(effective_hostname, effective_port, effective_user)
80+
.with_original_host(validated_host),
81+
)
8382
}
8483

8584
#[cfg(test)]
@@ -212,7 +211,8 @@ pub async fn resolve_nodes(
212211
let effective_port =
213212
ssh_config.get_effective_port(host, port.or_else(|| cli.get_effective_port()));
214213

215-
let node = Node::new(effective_hostname, effective_port, effective_user);
214+
let node = Node::new(effective_hostname, effective_port, effective_user)
215+
.with_original_host(host.to_string());
216216
nodes.push(node);
217217
} else if let Some(hosts) = &cli.hosts {
218218
// Parse hosts from CLI with hostlist expression expansion

src/commands/download.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub async fn download_file(
104104
let node_dir = validated_destination.join(node.to_string());
105105
let ssh_connection_config = params
106106
.ssh_connection_config_resolver
107-
.resolve_for_host(&node.host);
107+
.resolve_for_host(node.config_host());
108108

109109
println!(
110110
"\n{} {} {} {} {:?}",

src/commands/exec.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async fn execute_command_with_forwarding(params: ExecuteCommandParams<'_>) -> Re
106106
// listener side was already constrained when the spec was parsed.
107107
let ssh_connection_config = params
108108
.ssh_connection_config_resolver
109-
.resolve_for_host(&node.host);
109+
.resolve_for_host(node.config_host());
110110
let forwarding_config = ForwardingConfig {
111111
address_family: ssh_connection_config.address_family,
112112
..ForwardingConfig::default()

src/executor/connection_manager.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,15 +415,15 @@ Host internal.example.com
415415
#[test]
416416
fn test_resolve_effective_jump_hosts_wildcard() {
417417
let ssh_config_content = r#"
418-
Host *.internal.example.com
419-
ProxyJump gateway.example.com
420-
421418
Host db.internal.example.com
422419
ProxyJump db-gateway.example.com
420+
421+
Host *.internal.example.com
422+
ProxyJump gateway.example.com
423423
"#;
424424
let ssh_config = SshConfig::parse(ssh_config_content).unwrap();
425425

426-
// Should match db.internal.example.com specifically
426+
// OpenSSH uses the first value obtained, so put the exact host first.
427427
let result =
428428
resolve_effective_jump_hosts(None, Some(&ssh_config), "db.internal.example.com");
429429
assert_eq!(result, Some("db-gateway.example.com".to_string()));
@@ -452,15 +452,15 @@ Host *.internal.example.com
452452
#[test]
453453
fn test_resolve_effective_jump_hosts_none_value() {
454454
let ssh_config_content = r#"
455-
Host *.example.com
456-
ProxyJump gateway.example.com
457-
458455
Host direct.example.com
459456
ProxyJump none
457+
458+
Host *.example.com
459+
ProxyJump gateway.example.com
460460
"#;
461461
let ssh_config = SshConfig::parse(ssh_config_content).unwrap();
462462

463-
// direct.example.com should have ProxyJump explicitly set to "none"
463+
// The explicit disable must be obtained before the wildcard fallback.
464464
// Note: The actual handling of "none" as special value would be
465465
// done by the connection layer, but the config should return it
466466
let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "direct.example.com");

src/executor/parallel.rs

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ use crate::node::Node;
2626
use crate::security::{Password, SudoPassword};
2727
use crate::ssh::SshConfig;
2828
use crate::ssh::known_hosts::StrictHostKeyChecking;
29-
use crate::ssh::tokio_client::{AddressFamily, SshConnectionConfig, SshConnectionConfigResolver};
29+
use crate::ssh::tokio_client::{
30+
AddressFamily, CommandOutput, SshConnectionConfig, SshConnectionConfigResolver,
31+
};
3032

3133
use super::connection_manager::{ExecutionConfig, download_from_node};
3234
use super::execution_strategy::{
@@ -62,6 +64,39 @@ pub struct ParallelExecutor {
6264
pub(crate) ssh_connection_config_resolver: SshConnectionConfigResolver,
6365
}
6466

67+
async fn report_stream_exit_status(
68+
sender: &tokio::sync::mpsc::Sender<CommandOutput>,
69+
result: &Result<u32>,
70+
) {
71+
if let Ok(exit_status) = result {
72+
let _ = sender.send(CommandOutput::ExitCode(*exit_status)).await;
73+
}
74+
}
75+
76+
fn completed_stream_result(
77+
stream: &super::stream_manager::NodeStream,
78+
) -> Result<crate::ssh::client::CommandResult> {
79+
if let Some(exit_status) = stream.exit_code() {
80+
return Ok(crate::ssh::client::CommandResult {
81+
host: stream.node.host.clone(),
82+
output: Vec::new(),
83+
stderr: Vec::new(),
84+
exit_status,
85+
});
86+
}
87+
88+
if let super::stream_manager::ExecutionStatus::Failed(error) = stream.status() {
89+
return Err(anyhow::anyhow!("{error}"));
90+
}
91+
92+
Ok(crate::ssh::client::CommandResult {
93+
host: stream.node.host.clone(),
94+
output: Vec::new(),
95+
stderr: Vec::new(),
96+
exit_status: 1,
97+
})
98+
}
99+
65100
impl ParallelExecutor {
66101
/// Create a new parallel executor with default strict mode.
67102
pub fn new(nodes: Vec<Node>, max_parallel: usize, key_path: Option<String>) -> Self {
@@ -283,7 +318,7 @@ impl ParallelExecutor {
283318

284319
pub(crate) fn connection_config_for_node(&self, node: &Node) -> SshConnectionConfig {
285320
self.ssh_connection_config_resolver
286-
.resolve_for_host(&node.host)
321+
.resolve_for_host(node.config_host())
287322
}
288323

289324
/// Execute a command on all nodes in parallel.
@@ -1300,6 +1335,7 @@ impl ParallelExecutor {
13001335
}
13011336
};
13021337

1338+
report_stream_exit_status(&tx, &result.1).await;
13031339
// Explicitly drop the channel to signal completion
13041340
drop(tx);
13051341
result
@@ -1504,19 +1540,7 @@ impl ParallelExecutor {
15041540
}
15051541
// Collect final results from all streams
15061542
for stream in manager.streams() {
1507-
use crate::ssh::client::CommandResult;
1508-
1509-
let result =
1510-
if let super::stream_manager::ExecutionStatus::Failed(err) = stream.status() {
1511-
Err(anyhow::anyhow!("{err}"))
1512-
} else {
1513-
Ok(CommandResult {
1514-
host: stream.node.host.clone(),
1515-
output: Vec::new(), // stdout already printed
1516-
stderr: Vec::new(), // stderr already printed
1517-
exit_status: stream.exit_code().unwrap_or(1),
1518-
})
1519-
};
1543+
let result = completed_stream_result(stream);
15201544

15211545
results.push(ExecutionResult {
15221546
node: stream.node.clone(),
@@ -1852,6 +1876,63 @@ mod tests {
18521876
use super::*;
18531877
use crate::ssh::SshConfig;
18541878

1879+
async fn assert_stream_exit_follows_output(exit_status: u32) {
1880+
let (sender, mut receiver) = tokio::sync::mpsc::channel(3);
1881+
sender
1882+
.send(CommandOutput::StdOut(bytes::Bytes::from_static(b"stdout")))
1883+
.await
1884+
.expect("stdout receiver open");
1885+
sender
1886+
.send(CommandOutput::StdErr(bytes::Bytes::from_static(b"stderr")))
1887+
.await
1888+
.expect("stderr receiver open");
1889+
1890+
let result = Ok(exit_status);
1891+
report_stream_exit_status(&sender, &result).await;
1892+
drop(sender);
1893+
1894+
match receiver.recv().await {
1895+
Some(CommandOutput::StdOut(data)) => assert_eq!(data, b"stdout"[..]),
1896+
other => panic!("stdout must be first, got {other:?}"),
1897+
}
1898+
match receiver.recv().await {
1899+
Some(CommandOutput::StdErr(data)) => assert_eq!(data, b"stderr"[..]),
1900+
other => panic!("stderr must be second, got {other:?}"),
1901+
}
1902+
match receiver.recv().await {
1903+
Some(CommandOutput::ExitCode(actual)) => assert_eq!(actual, exit_status),
1904+
other => panic!("exit status must follow output, got {other:?}"),
1905+
}
1906+
assert!(receiver.recv().await.is_none());
1907+
}
1908+
1909+
#[tokio::test]
1910+
async fn successful_stream_exit_follows_stdout_and_stderr() {
1911+
assert_stream_exit_follows_output(0).await;
1912+
}
1913+
1914+
#[tokio::test]
1915+
async fn nonzero_stream_exit_follows_stdout_and_stderr() {
1916+
assert_stream_exit_follows_output(23).await;
1917+
}
1918+
1919+
#[tokio::test]
1920+
async fn nonzero_stream_completion_preserves_remote_status() {
1921+
let node = Node::new("target".to_string(), 22, "user".to_string());
1922+
let (sender, receiver) = tokio::sync::mpsc::channel(1);
1923+
let mut stream = super::super::stream_manager::NodeStream::new(node, receiver);
1924+
sender
1925+
.send(CommandOutput::ExitCode(23))
1926+
.await
1927+
.expect("exit receiver open");
1928+
drop(sender);
1929+
stream.poll();
1930+
1931+
let result = completed_stream_result(&stream).expect("remote command result");
1932+
1933+
assert_eq!(result.exit_status, 23);
1934+
}
1935+
18551936
#[test]
18561937
fn connection_config_for_node_resolves_each_node_host_block() {
18571938
let ssh_config = SshConfig::parse(

0 commit comments

Comments
 (0)