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
12 changes: 10 additions & 2 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -1204,8 +1204,16 @@ requested family, and sends the matching numeric address in the
.I direct-tcpip
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.
path. SOCKS4 requests carry a literal IPv4 destination by protocol definition, so
they still work under
.B any
and
.BR \-4 ,
but are rejected under
.B \-6
or
.I AddressFamily inet6
with the usual forced-family error instead of silently tunneling IPv4.

.SS What the constraint does not cover
The remote listener created by
Expand Down
2 changes: 1 addition & 1 deletion src/cli/bssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ pub struct Cli {
long = "dynamic-forward",
value_name = "dynamic_forward_spec",
action = clap::ArgAction::Append,
help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)"
help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nSOCKS4 destinations are IPv4-only by protocol and are rejected when -6 or AddressFamily inet6 is forced.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)"
)]
pub dynamic_forwards: Vec<String>,
}
Expand Down
12 changes: 8 additions & 4 deletions src/forwarding/dynamic/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,15 @@ impl ConnectionHandler {
address_family: AddressFamily,
) -> anyhow::Result<crate::forwarding::tunnel::TunnelStats> {
match socks_version {
// SOCKS4 carries a literal IPv4 destination by protocol
// definition, so there is no candidate list an address family
// could narrow. The request is passed through unfiltered.
SocksVersion::V4 => {
handle_socks4_connection(tcp_stream, peer_addr, ssh_client, cancel_token).await
handle_socks4_connection(
tcp_stream,
peer_addr,
ssh_client,
cancel_token,
address_family,
)
.await
}
SocksVersion::V5 => {
handle_socks5_connection(
Expand Down
230 changes: 219 additions & 11 deletions src/forwarding/dynamic/socks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,64 @@

use crate::{
forwarding::tunnel::Tunnel,
ssh::tokio_client::{AddressFamily, Client},
ssh::tokio_client::{AddressFamily, Client, Error as SshError},
};
use anyhow::Result;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
use tracing::debug;

/// Handle SOCKS4 connection protocol
pub async fn handle_socks4_connection(
mut tcp_stream: TcpStream,
tcp_stream: TcpStream,
peer_addr: SocketAddr,
ssh_client: &Client,
cancel_token: CancellationToken,
address_family: AddressFamily,
) -> Result<super::super::tunnel::TunnelStats> {
handle_socks4_connection_with(
tcp_stream,
peer_addr,
cancel_token,
address_family,
|destination| async move {
ssh_client
.open_direct_tcpip_channel(destination.as_str(), None)
.await
.map_err(anyhow::Error::from)
},
|tcp_stream, ssh_channel, cancel_token| async move {
Tunnel::run(tcp_stream, ssh_channel, cancel_token).await
},
)
.await
}

async fn handle_socks4_connection_with<
IoStream,
OpenChannel,
OpenFuture,
ChannelTarget,
RunTunnel,
RunFuture,
>(
mut tcp_stream: IoStream,
peer_addr: SocketAddr,
cancel_token: CancellationToken,
address_family: AddressFamily,
open_channel: OpenChannel,
run_tunnel: RunTunnel,
) -> Result<super::super::tunnel::TunnelStats>
where
IoStream: AsyncRead + AsyncWrite + Unpin,
OpenChannel: FnOnce(String) -> OpenFuture,
OpenFuture: Future<Output = Result<ChannelTarget>>,
RunTunnel: FnOnce(IoStream, ChannelTarget, CancellationToken) -> RunFuture,
RunFuture: Future<Output = Result<super::super::tunnel::TunnelStats>>,
{
debug!("Handling SOCKS4 connection from {}", peer_addr);

// Read SOCKS4 request: VER(1) + CMD(1) + DSTPORT(2) + DSTIP(4) + USERID(variable) + NULL(1)
Expand Down Expand Up @@ -68,21 +110,29 @@ pub async fn handle_socks4_connection(
}
}

let destination = format!("{dest_ip}:{dest_port}");
let destination = match socks4_destination_for_family(dest_ip, dest_port, address_family) {
Ok(destination) => destination,
Err(e) => {
debug!(
"Rejected SOCKS4 CONNECT to {}:{} for forced {} from {}: {}",
dest_ip, dest_port, address_family, peer_addr, e
);
let response = [0, 0x5B, 0, 0, 0, 0, 0, 0]; // Request rejected
tcp_stream.write_all(&response).await?;
return Err(e.into());
}
};
debug!("SOCKS4 CONNECT to {} from {}", destination, peer_addr);

// Create SSH channel to destination
let ssh_channel = match ssh_client
.open_direct_tcpip_channel(destination.as_str(), None)
.await
{
let ssh_channel = match open_channel(destination.clone()).await {
Ok(channel) => channel,
Err(e) => {
debug!("Failed to create SSH channel to {}: {}", destination, e);
// Send failure response
let response = [0, 0x5B, 0, 0, 0, 0, 0, 0]; // Request rejected
tcp_stream.write_all(&response).await?;
return Err(e.into());
return Err(e);
}
};

Expand All @@ -102,7 +152,23 @@ pub async fn handle_socks4_connection(
debug!("SOCKS4 tunnel established: {} ↔ {}", peer_addr, destination);

// Start bidirectional tunnel
Tunnel::run(tcp_stream, ssh_channel, cancel_token).await
run_tunnel(tcp_stream, ssh_channel, cancel_token).await
}

fn socks4_destination_for_family(
dest_ip: Ipv4Addr,
dest_port: u16,
address_family: AddressFamily,
) -> Result<String, SshError> {
let destination = SocketAddr::new(IpAddr::V4(dest_ip), dest_port);
if address_family.is_forced() && !address_family.matches(&destination) {
return Err(SshError::NoAddressForFamily {
host: dest_ip.to_string(),
family: address_family,
});
}

Ok(format!("{dest_ip}:{dest_port}"))
}

/// Handle SOCKS5 connection protocol
Expand Down Expand Up @@ -261,3 +327,145 @@ pub async fn handle_socks5_connection(
// - No authentication (method 0x00)
// - Username/password authentication (method 0x02)
// - Future: GSSAPI authentication (method 0x01)

#[cfg(test)]
mod tests {
use super::*;
use crate::forwarding::tunnel::TunnelStats;
use anyhow::anyhow;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

fn socks4_request(dest_ip: Ipv4Addr, dest_port: u16, userid: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(8 + userid.len() + 1);
frame.push(0x04);
frame.push(0x01);
frame.extend_from_slice(&dest_port.to_be_bytes());
frame.extend_from_slice(&dest_ip.octets());
frame.extend_from_slice(userid);
frame.push(0x00);
frame
}

async fn run_socks4_protocol_case(
address_family: AddressFamily,
) -> Result<([u8; 8], usize, anyhow::Error)> {
let open_count = Arc::new(AtomicUsize::new(0));
let open_count_for_task = Arc::clone(&open_count);
let peer_addr: SocketAddr = "127.0.0.1:4242".parse().expect("peer address parses");
let (mut client_stream, server_stream) = tokio::io::duplex(128);

let server = tokio::spawn(async move {
handle_socks4_connection_with(
server_stream,
peer_addr,
CancellationToken::new(),
address_family,
move |destination| {
let open_count = Arc::clone(&open_count_for_task);
async move {
open_count.fetch_add(1, Ordering::Relaxed);
assert_eq!(destination, "192.0.2.25:8080");
Err(anyhow!("synthetic channel-open stop"))
}
},
|_tcp_stream, _channel_target: (), _cancel_token| async move {
Ok(TunnelStats::default())
},
)
.await
.expect_err("the synthetic seam stop must surface as an error")
});

client_stream
.write_all(&socks4_request(
Ipv4Addr::new(192, 0, 2, 25),
8080,
b"acceptance-user",
))
.await
.expect("client sends SOCKS4 request");

let mut response = [0u8; 8];
client_stream
.read_exact(&mut response)
.await
.expect("client reads SOCKS4 response");

let err = server.await.expect("server task joins");
Ok((response, open_count.load(Ordering::Relaxed), err))
}

#[test]
fn socks4_destination_accepts_ipv4_when_unforced_or_ipv4_forced() {
let dest_ip = Ipv4Addr::new(192, 0, 2, 25);
let dest_port = 8080;

assert_eq!(
socks4_destination_for_family(dest_ip, dest_port, AddressFamily::Any)
.expect("unforced SOCKS4 must preserve the IPv4 destination"),
"192.0.2.25:8080"
);
assert_eq!(
socks4_destination_for_family(dest_ip, dest_port, AddressFamily::V4)
.expect("forced IPv4 must still allow the SOCKS4 IPv4 destination"),
"192.0.2.25:8080"
);
}

#[test]
fn socks4_destination_rejects_forced_ipv6() {
let err =
socks4_destination_for_family(Ipv4Addr::new(192, 0, 2, 25), 8080, AddressFamily::V6)
.expect_err("forced IPv6 must reject the SOCKS4 IPv4 literal");

assert!(matches!(
err,
SshError::NoAddressForFamily {
ref host,
family: AddressFamily::V6,
} if host == "192.0.2.25"
));
assert_eq!(err.to_string(), "no IPv6 address found for 192.0.2.25");
}

#[tokio::test]
async fn socks4_protocol_rejects_forced_ipv6_before_channel_open() {
let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::V6)
.await
.expect("protocol case completes");

assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]);
assert_eq!(open_count, 0, "forced IPv6 must reject before channel open");
assert_eq!(err.to_string(), "no IPv6 address found for 192.0.2.25");
}

#[tokio::test]
async fn socks4_protocol_any_reaches_channel_open_seam() {
let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::Any)
.await
.expect("protocol case completes");

assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]);
assert_eq!(open_count, 1, "unforced SOCKS4 must reach channel open");
assert!(
err.to_string().contains("synthetic channel-open stop"),
"the injected channel-open seam error must surface"
);
}

#[tokio::test]
async fn socks4_protocol_ipv4_reaches_channel_open_seam() {
let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::V4)
.await
.expect("protocol case completes");

assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]);
assert_eq!(open_count, 1, "forced IPv4 must reach channel open");
assert!(
err.to_string().contains("synthetic channel-open stop"),
"the injected channel-open seam error must surface"
);
}
}