Companion to #255. Both issues are gaps in the SOCKS handlers left behind by the address family work in #246 and #248.
Problem / Background
bssh -D runs a local SOCKS proxy. The SOCKS5 request parser implements address type 0x01 (IPv4 literal) and 0x03 (domain name), but not 0x04 (IPv6 literal). A SOCKS5 client that sends an IPv6 destination gets back reply code 0x08 (address type not supported) and the connection is refused.
This is a plain functional gap on its own. It also directly contradicts the IPv6 support shipped in v2.4.0: bssh -6 -D 1080 is self-contradictory today, because the user has explicitly forced IPv6 and the SOCKS5 handler then refuses every IPv6 destination literal.
Technical Analysis
The 0x04 branch is a stub
src/forwarding/dynamic/socks.rs, handle_socks5_connection (starts at line 109). The destination is parsed by a match address_type block starting at line 172.
0x01, lines 173 to 183, reads 4 address bytes into std::net::Ipv4Addr, then 2 port bytes, and builds format!("{ip}:{port}").
0x03, lines 184 to 199, reads a 1-byte length, that many domain bytes, then 2 port bytes, and builds format!("{domain}:{port}").
0x04, lines 200 to 205, reads nothing:
0x04 => {
// IPv6 address: 16 bytes + 2 bytes port (not fully implemented)
let response = [5, 0x08, 0, 1, 0, 0, 0, 0, 0, 0]; // Address type not supported
tcp_stream.write_all(&response).await?;
return Err(anyhow::anyhow!("IPv6 address type not yet supported"));
}
It is identical to the _ catch-all arm below it, except for the error message.
The 18 unconsumed bytes
The 0x04 arm returns without consuming the 16 address bytes and 2 port bytes of the request body. In practice this is not a stream desync, because the handler returns Err immediately and the connection is torn down. It becomes a real bug the moment anyone changes this branch to keep the stream alive, so the fix should consume the bytes as part of parsing rather than relying on teardown.
Bracketing is required for the destination string
The parsed destination string is handed to open_direct_tcpip_channel_with_family(destination.as_str(), None, address_family) at line 219, which resolves it and filters the candidates by the forced family. An IPv6 literal must be bracketed to round-trip through to_socket_addrs, so the 0x04 arm must build format!("[{ip}]:{port}"), not format!("{ip}:{port}").
This is the same rule already established elsewhere in the codebase. #251 added bracketed IPv6 literal handling for -H, and src/node.rs lines 28 to 32 document why: an unbracketed trailing numeric segment is indistinguishable from a port.
Documentation currently overstates coverage
Checked, and neither docs/man/bssh.1 nor the -D help text in src/cli/bssh.rs line 328 mentions an IPv6 limitation for SOCKS5. The man page (around line 1186) says bssh resolves the target of a SOCKS5 -D request and filters the candidate list by address family, with no caveat. That description is accurate only for ATYP 0x01 and 0x03 today, so the docs silently overstate what works rather than documenting the gap.
Impact
A SOCKS5 client that resolves the hostname itself and sends the resulting address, which is common (for example curl --socks5 rather than curl --socks5-hostname), cannot reach any IPv6-only destination through a bssh SOCKS proxy.
The failure is confusing to diagnose because the 0x03 domain path is unaffected. The same destination reaches through by name and is refused by literal, and the client reports a generic SOCKS failure either way.
bssh -6 -D 1080 is the worst case: the user has forced IPv6 on every other path in the invocation, and the SOCKS5 handler is the one place that rejects IPv6 outright.
Proposed Solution
Implement the 0x04 arm symmetrically with 0x01: read 16 address bytes into std::net::Ipv6Addr, read the 2 port bytes, and build a bracketed format!("[{ip}]:{port}") destination. It then flows through the existing family-aware channel opener with no further change, and a forced -4 will fail closed through Error::NoAddressForFamily the same way every other path does.
Separately, confirm the reply shape. Every response in this function hardcodes an IPv4 BND.ADDR, [5, rep, 0, 1, 0, 0, 0, 0, 0, 0]. Per RFC 1928 a reply carrying ATYP 0x01 with an all-zero address is accepted by essentially all clients, so keeping the current shape for IPv6 requests is defensible. It should be a deliberate, documented decision rather than an accident of the IPv4-only implementation.
Note that src/forwarding/dynamic/socks.rs has no test module today, so this work adds the first coverage for the request parser.
Acceptance Criteria
Companion to #255. Both issues are gaps in the SOCKS handlers left behind by the address family work in #246 and #248.
Problem / Background
bssh -Druns a local SOCKS proxy. The SOCKS5 request parser implements address type 0x01 (IPv4 literal) and 0x03 (domain name), but not 0x04 (IPv6 literal). A SOCKS5 client that sends an IPv6 destination gets back reply code 0x08 (address type not supported) and the connection is refused.This is a plain functional gap on its own. It also directly contradicts the IPv6 support shipped in v2.4.0:
bssh -6 -D 1080is self-contradictory today, because the user has explicitly forced IPv6 and the SOCKS5 handler then refuses every IPv6 destination literal.Technical Analysis
The 0x04 branch is a stub
src/forwarding/dynamic/socks.rs,handle_socks5_connection(starts at line 109). The destination is parsed by amatch address_typeblock starting at line 172.0x01, lines 173 to 183, reads 4 address bytes intostd::net::Ipv4Addr, then 2 port bytes, and buildsformat!("{ip}:{port}").0x03, lines 184 to 199, reads a 1-byte length, that many domain bytes, then 2 port bytes, and buildsformat!("{domain}:{port}").0x04, lines 200 to 205, reads nothing:It is identical to the
_catch-all arm below it, except for the error message.The 18 unconsumed bytes
The 0x04 arm returns without consuming the 16 address bytes and 2 port bytes of the request body. In practice this is not a stream desync, because the handler returns
Errimmediately and the connection is torn down. It becomes a real bug the moment anyone changes this branch to keep the stream alive, so the fix should consume the bytes as part of parsing rather than relying on teardown.Bracketing is required for the destination string
The parsed
destinationstring is handed toopen_direct_tcpip_channel_with_family(destination.as_str(), None, address_family)at line 219, which resolves it and filters the candidates by the forced family. An IPv6 literal must be bracketed to round-trip throughto_socket_addrs, so the 0x04 arm must buildformat!("[{ip}]:{port}"), notformat!("{ip}:{port}").This is the same rule already established elsewhere in the codebase. #251 added bracketed IPv6 literal handling for
-H, andsrc/node.rslines 28 to 32 document why: an unbracketed trailing numeric segment is indistinguishable from a port.Documentation currently overstates coverage
Checked, and neither
docs/man/bssh.1nor the-Dhelp text insrc/cli/bssh.rsline 328 mentions an IPv6 limitation for SOCKS5. The man page (around line 1186) says bssh resolves the target of a SOCKS5-Drequest and filters the candidate list by address family, with no caveat. That description is accurate only for ATYP 0x01 and 0x03 today, so the docs silently overstate what works rather than documenting the gap.Impact
A SOCKS5 client that resolves the hostname itself and sends the resulting address, which is common (for example
curl --socks5rather thancurl --socks5-hostname), cannot reach any IPv6-only destination through a bssh SOCKS proxy.The failure is confusing to diagnose because the 0x03 domain path is unaffected. The same destination reaches through by name and is refused by literal, and the client reports a generic SOCKS failure either way.
bssh -6 -D 1080is the worst case: the user has forced IPv6 on every other path in the invocation, and the SOCKS5 handler is the one place that rejects IPv6 outright.Proposed Solution
Implement the 0x04 arm symmetrically with 0x01: read 16 address bytes into
std::net::Ipv6Addr, read the 2 port bytes, and build a bracketedformat!("[{ip}]:{port}")destination. It then flows through the existing family-aware channel opener with no further change, and a forced-4will fail closed throughError::NoAddressForFamilythe same way every other path does.Separately, confirm the reply shape. Every response in this function hardcodes an IPv4 BND.ADDR,
[5, rep, 0, 1, 0, 0, 0, 0, 0, 0]. Per RFC 1928 a reply carrying ATYP 0x01 with an all-zero address is accepted by essentially all clients, so keeping the current shape for IPv6 requests is defensible. It should be a deliberate, documented decision rather than an accident of the IPv4-only implementation.Note that
src/forwarding/dynamic/socks.rshas no test module today, so this work adds the first coverage for the request parser.Acceptance Criteria
[2001:db8::1]:443.-4with an IPv6 literal destination is defined and tested: it fails closed via the existingError::NoAddressForFamilypath and returns a well-formed SOCKS5 failure reply, rather than silently connecting or dropping the connection.docs/man/bssh.1and the-Dhelp text insrc/cli/bssh.rsdescribe SOCKS5 destination address type support accurately once the gap is closed.