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
10 changes: 6 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,17 +773,19 @@ as exec and ping. `ForwardingConfig` carries its own copy of the resolved
address family for the forwarding-target filter, since forwarders run detached
from the connect config.

**Scope.** The constraint is a hard filter where bssh opens the socket, and a
hint where the remote server does:
**Scope.** The constraint is a hard filter where bssh opens the socket. For
forwarding targets the unforced path preserves server-side resolution, while a
forced family switches to locally resolved numeric addresses so the family
request has an observable effect:

| Path | Behavior |
| --- | --- |
| Direct connect (exec, interactive, ping, SFTP) | Hard filter in `connect_with_config_inner` |
| First jump hop | Hard filter (shares the direct connect path) |
| `-L` / `-D` listener | Selects the implicit bind address (`::1` / `::` under `-6`); an explicit bind address wins |
| `-L` / SOCKS5 `-D` target | Filters the `direct-tcpip` candidate list; the remote sshd still performs the connect, so this is advisory |
| `-L` / SOCKS5 `-D` target | With `Any`, sends the requested hostname in `direct-tcpip` and lets the remote sshd resolve it. With `V4` / `V6`, resolves locally, filters the candidate list, and sends the matching numeric address |
| SOCKS4 `-D` target | Unfiltered; SOCKS4 carries a literal IPv4 destination by protocol definition |
| Jump hops past the first, and the destination behind a chain | Filters the `direct-tcpip` candidate list through the same advisory mechanism as `-L`/SOCKS5 `-D` targets. The family also selects the address recorded for host key verification, so known_hosts diagnostics stay consistent |
| Jump hops past the first, and the destination behind a chain | Uses the same `direct-tcpip` model as `-L`/SOCKS5 `-D` targets: hostname with `Any`, locally filtered numeric address with `V4` / `V6`. The family also selects the best-effort address recorded for host key verification diagnostics |
| `-R` listener | Not constrained; the server binds it |
| `bssh-server` | Out of scope; separate CLI |

Expand Down
34 changes: 24 additions & 10 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -1178,20 +1178,34 @@ a specification that does not name a bind address listens on ::1 instead of
wildcard form listens on :: instead of 0.0.0.0. A specification that names a
bind address explicitly always overrides the flag.

.SS What the constraint only hints at
.SS Forwarding target resolution
.IP \[bu] 2
Forwarding targets and tunneled jump-chain targets. bssh resolves the target of a
Forwarding targets and tunneled jump-chain targets. Without
.B \-4
or
.BR \-6 ,
bssh sends the target hostname from a
.B \-L
forward or a SOCKS5
forward, SOCKS5
.B \-D
request, later jump-chain hop, or final destination behind a jump chain locally
to decide which address to name in the
domain request, later jump-chain hop, or final destination behind a jump chain
as written in the
.I direct-tcpip
channel request. The remote SSH server resolves that name and performs the
actual connection, matching OpenSSH and allowing names that exist only from the
server's network position.
.IP \[bu] 2
When a family is forced with
.B \-4
or
.BR \-6 ,
bssh resolves the forwarding target locally, filters the candidate list to the
requested family, and sends the matching numeric address in the
.I direct-tcpip
channel request, and the address family filters that candidate list. The remote
SSH server performs the actual connection and may resolve the name differently,
so this is a best-effort hint rather than a guarantee. SOCKS4 requests carry a
literal IPv4 destination by protocol definition and are passed through
unfiltered.
request. This gives the family request a concrete effect on the server-side
connection, but it necessarily uses the client's resolver view for that forced
path. SOCKS4 requests carry a literal IPv4 destination by protocol definition and
are passed through unfiltered.

.SS What the constraint does not cover
The remote listener created by
Expand Down
6 changes: 3 additions & 3 deletions src/forwarding/dynamic/socks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ pub async fn handle_socks5_connection(

debug!("SOCKS5 CONNECT to {} from {}", destination, peer_addr);

// Create SSH channel to destination. A SOCKS5 request may name a domain,
// so the forced address family narrows which resolved address is offered
// to the server.
// Create SSH channel to destination. Domain requests stay as names unless
// an address family is forced, in which case the channel manager resolves
// and sends a matching numeric address.
let ssh_channel = match ssh_client
.open_direct_tcpip_channel_with_family(destination.as_str(), None, address_family)
.await
Expand Down
131 changes: 116 additions & 15 deletions src/ssh/tokio_client/channel_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ const MAX_SUDO_PROMPT_BUFFER_SIZE: usize = 64 * 1024;
/// Set to 10 to support reasonable multi-sudo command chains.
const MAX_SUDO_PASSWORD_SENDS: u32 = 10;

#[derive(Debug, Clone, PartialEq, Eq)]
struct DirectTcpipRequestTarget {
host: String,
port: u32,
}

/// Command output variants for streaming
#[derive(Debug, Clone)]
pub enum CommandOutput {
Expand Down Expand Up @@ -166,6 +172,29 @@ impl Client {
Ok(targets)
}

fn direct_tcpip_request_targets<T: ToSocketAddrsWithHostname>(
target: &T,
address_family: AddressFamily,
) -> Result<Vec<DirectTcpipRequestTarget>, super::Error> {
if !address_family.is_forced() {
let (host, port) = target.host_port().map_err(super::Error::AddressInvalid)?;
return Ok(vec![DirectTcpipRequestTarget {
host,
port: port.into(),
}]);
}

Self::direct_tcpip_targets(target, address_family).map(|targets| {
targets
.into_iter()
.map(|target| DirectTcpipRequestTarget {
host: target.ip().to_string(),
port: target.port().into(),
})
.collect()
})
}

/// Get a new SSH channel for communication.
pub async fn get_channel(&self) -> Result<Channel<Msg>, super::Error> {
self.connection_handle
Expand All @@ -192,14 +221,15 @@ impl Client {
.await
}

/// Open a `direct-tcpip` channel, restricting the candidate target
/// addresses to `address_family`.
/// Open a `direct-tcpip` channel, optionally restricting the candidate
/// target addresses to `address_family`.
///
/// The target address is resolved locally only to pick which address to
/// name in the channel-open request; the remote sshd performs the actual
/// connect and may resolve the name differently. Filtering here is
/// therefore a best-effort hint, not a guarantee, and it is why port
/// forwarding documents `-4` / `-6` as advisory for the far end.
/// With [`AddressFamily::Any`] bssh sends the hostname exactly as supplied,
/// and the remote sshd resolves and connects it. With a forced family
/// (`-4`, `-6`, or ssh_config `AddressFamily inet|inet6`), bssh resolves
/// locally, filters to the requested family, and sends the matching numeric
/// address so the family request has a concrete effect on the server-side
/// connection.
pub async fn open_direct_tcpip_channel_with_family<
T: ToSocketAddrsWithHostname,
S: Into<Option<SocketAddr>>,
Expand All @@ -209,7 +239,7 @@ impl Client {
src: S,
address_family: AddressFamily,
) -> Result<Channel<Msg>, super::Error> {
let targets = Self::direct_tcpip_targets(&target, address_family)?;
let targets = Self::direct_tcpip_request_targets(&target, address_family)?;

let src = src
.into()
Expand All @@ -220,15 +250,10 @@ impl Client {
io::ErrorKind::InvalidInput,
"could not resolve to any addresses",
));
for target in targets {
for DirectTcpipRequestTarget { host, port } in targets {
match self
.connection_handle
.channel_open_direct_tcpip(
target.ip().to_string(),
target.port().into(),
src.0.clone(),
src.1,
)
.channel_open_direct_tcpip(host, port, src.0.clone(), src.1)
.await
{
Ok(channel) => return Ok(channel),
Expand Down Expand Up @@ -717,4 +742,80 @@ mod tests {
vec![v6("[2001:db8::10]:22"), v6("[2001:db8::11]:22")]
);
}

#[test]
fn direct_tcpip_request_targets_send_unforced_hostname_without_resolution() {
let targets =
Client::direct_tcpip_request_targets(&"server-only.internal:5432", AddressFamily::Any)
.expect("unforced direct-tcpip targets must not require local DNS");

assert_eq!(
targets,
vec![DirectTcpipRequestTarget {
host: "server-only.internal".to_string(),
port: 5432,
}]
);
}

#[test]
fn direct_tcpip_request_targets_send_unforced_tuple_hostname_for_jump_hops() {
let targets = Client::direct_tcpip_request_targets(
&("jump-private.internal", 2222),
AddressFamily::Any,
)
.expect("unforced jump-hop targets must not require local DNS");

assert_eq!(
targets,
vec![DirectTcpipRequestTarget {
host: "jump-private.internal".to_string(),
port: 2222,
}]
);
}

#[test]
fn direct_tcpip_request_targets_send_forced_ipv4_address() {
let candidates = multi_hop_candidates();
let targets =
Client::direct_tcpip_request_targets(&candidates.as_slice(), AddressFamily::V4)
.expect("forced IPv4 direct-tcpip targets must resolve to numeric addresses");

assert_eq!(
targets,
vec![
DirectTcpipRequestTarget {
host: "192.0.2.10".to_string(),
port: 22,
},
DirectTcpipRequestTarget {
host: "192.0.2.11".to_string(),
port: 22,
},
]
);
}

#[test]
fn direct_tcpip_request_targets_send_forced_ipv6_address() {
let candidates = multi_hop_candidates();
let targets =
Client::direct_tcpip_request_targets(&candidates.as_slice(), AddressFamily::V6)
.expect("forced IPv6 direct-tcpip targets must resolve to numeric addresses");

assert_eq!(
targets,
vec![
DirectTcpipRequestTarget {
host: "2001:db8::10".to_string(),
port: 22,
},
DirectTcpipRequestTarget {
host: "2001:db8::11".to_string(),
port: 22,
},
]
);
}
}
Loading