You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Surfaced by the address family work in #246 and #248. The v2.4.0 release notes already list the best-effort nature of forwarding target filtering as a known issue; this issue is about the underlying cause of that caveat.
Problem / Background
bssh resolves -L and -D forwarding targets on the local machine and sends a literal IP address in the SSH direct-tcpip channel-open request. OpenSSH sends the target hostname exactly as the user wrote it and lets the server resolve it.
The divergence breaks any target whose name resolves only from the server's network position, which is one of the primary reasons to use a bastion in the first place. It is also the underlying reason the -4/-6 filtering added in v2.4.0 is only advisory for the far end: the filter exists precisely because bssh resolves locally, and the "advisory" caveat exists because the server may disagree with whatever the client resolved.
All line references below are against main at 47ca657 (tag v2.4.0).
Technical Analysis
Resolution happens locally, before the channel is opened
src/ssh/tokio_client/channel_manager.rs, direct_tcpip_targets (lines 150 to 167), calls target.to_socket_addrs() and then applies the address family filter to the result:
let resolved = target
.to_socket_addrs().map_err(super::Error::AddressInvalid)?;let targets = address_family.filter(resolved);
to_socket_addrs() performs name resolution through the client's resolver. If the name does not resolve on the client, this returns an error before any SSH traffic is sent.
What goes on the wire is an IP literal, never the name
open_direct_tcpip_channel_with_family (lines 203 to 240) iterates the resolved SocketAddr values and calls (lines 226 to 231):
target.ip().to_string() is the decisive detail: the "host to connect" field of the channel request is always a numeric address, never the hostname the user typed.
The doc comment at lines 195 to 202 already acknowledges the consequence:
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.
So the behavior is known and documented. What was never decided is whether it is the right default.
Affected call sites
Path
Where the target string is built
Where it is sent
-L local forwarding
src/forwarding/local.rs line 380, let target = format!("{remote_host}:{remote_port}"); inside handle_connection (starts at line 367)
lines 381 to 382
-D SOCKS5, domain destination (ATYP 0x03)
src/forwarding/dynamic/socks.rs line 198, format!("{domain}:{port}")
line 219
Jump chain hop 2 and later
src/jump/chain/tunnel.rs line 100
same call
Destination reached through a jump chain
src/jump/chain/tunnel.rs line 241
same call
For -L, remote_host is the literal string parsed out of the -L specification (src/forwarding/spec.rs lines 55 and 71), so the user-supplied name survives all the way to direct_tcpip_targets and is then discarded in favor of whatever the local resolver returned. For SOCKS5, domain is the hostname the SOCKS client asked for, which is the whole point of ATYP 0x03: the client is explicitly delegating resolution.
The jump chain rows are the paths #248 made family-aware. They inherit the same resolution model.
What OpenSSH does
Verified against openssh-portablechannels.c on master (function names are stable; line numbers drift):
channel_setup_fwd_listener_tcpip sets host = fwd->connect_host (the literal string from the -L specification) and stores it verbatim: c->path = xstrdup(host);. The only getaddrinfo call in that function resolves the listen address, not the connect target.
port_open_helper writes that stored string straight into the channel request:
if (strcmp(rtype, "direct-tcpip") ==0) {
/* target host, port */if ((r=sshpkt_put_cstring(ssh, c->path)) !=0||
(r=sshpkt_put_u32(ssh, c->host_port)) !=0)
channel_decode_socks5 sets c->path = xstrdup(dest_addr) for the domain form, and only runs inet_ntop to produce a literal for the IPv4 and IPv6 address forms. A SOCKS5 domain request is forwarded as a domain.
channel_connect_stdio_fwd, which backs ssh -W host:port (the mechanism ProxyJump uses), also does c->path = xstrdup(host_to_connect);.
ssh->chanctxt->IPv4or6, which carries the AddressFamily setting, is consulted only in getaddrinfo hints for sockets the local process opens. It never touches the string sent as a forwarded target.
Two supporting documentation points:
RFC 4254 section 7.2 explicitly allows either form: "The 'host to connect' may be either a domain name or a numeric IP address." Both bssh and OpenSSH are protocol-conformant. This is a behavioral choice, not a spec violation.
sshd_config(5)PermitOpen states that "no pattern matching or address lookups are performed on supplied names", confirming that sshd matches the literal string the client sent against the configured destinations.
Impact
1. Split-horizon DNS (the common case)
A name like db.internal that resolves only inside the server's network fails on the client with a resolution error, even though the entire purpose of -L is that the server can reach it. The equivalent OpenSSH command works. This is not an edge case: internal-only names behind a bastion are a mainstream use of port forwarding.
The failure is also badly signposted. It surfaces as a local resolution error, which points the user at their own DNS configuration rather than at a client design choice, so the natural debugging path leads nowhere.
2. Resolvable locally, but to a different host
A name that resolves on the client to an address unreachable from the server, or to a genuinely different machine (a name that maps to one private address on the local network and another one remotely), tunnels to the wrong host or fails to connect. Nothing reports that the client's and the server's views of the name differ, so a wrong-host outcome can go unnoticed.
3. Server-side logging and access control
sshd sees a request for a numeric address rather than the requested hostname. Hostname-based logging records the literal, and any hostname-based PermitOpen rule on the server behaves differently than an administrator would expect after testing with an OpenSSH client. A PermitOpen db.internal:5432 rule that works for OpenSSH does not match a bssh request, because bssh sends the address and PermitOpen does no address lookups.
4. Round-robin and multi-address targets
Local resolution collapses the choice to whatever the client's resolver returned first, rather than letting the server select among the addresses it sees. Load-balanced targets lose their balancing, and a target whose address set differs by vantage point loses the server's (correct) view of it.
Proposed Solution
There is a real tension to state before the options. Local resolution is what makes -4/-6 filtering possible at all for forwarded targets: once the hostname is sent as written, the server picks the family and the client has no way to influence it. Any fix has to make this tradeoff explicitly rather than silently swapping one behavior for the other.
Option A (recommended): send the name by default, resolve locally only under a forced family
Send the hostname exactly as written (OpenSSH-compatible) whenever the address family is Any. Resolve locally and filter only when a family is explicitly forced with -4, -6, or AddressFamily inet|inet6.
This makes the common case correct while keeping the family feature working in the only case where it is meaningful: the user asked for a specific family, and accepting a locally-chosen address of that family is a better approximation of the request than sending a name and hoping. The cost is that the family filter no longer applies when no family is forced, which is exactly the case where it had nothing to filter for.
Option A preserves every acceptance criterion from #246 and #248, since both are about what happens under a forced family, and fixes the split-horizon case, which only arises when no family is forced.
Option B: always send the name, drop local family filtering for forwarded targets
Full OpenSSH parity, at the cost of narrowing the documented guarantee: -4/-6 would govern only bssh's own connections and its listener bind addresses, never forwarded targets. Simpler and more predictable, but it walks back part of what #246 and #248 delivered.
Option C: keep current behavior, document the divergence prominently
Status quo made explicit. Not recommended: it leaves a mainstream bastion workflow broken relative to OpenSSH, with a misleading error message.
Option A is the recommendation.
Acceptance Criteria
A -L forward whose target hostname does not resolve on the client but does resolve on the server succeeds, with the hostname sent as written in the direct-tcpip request.
SOCKS5 ATYP 0x03 domain destinations follow the same rule as -L.
Jump chain hops past the first, and the destination reached through a chain, follow the same rule.
docs/man/bssh.1 states the actual guarantee precisely, replacing the current "best-effort hint" paragraph under "What the constraint only hints at" (around line 1190) with the resolution model that is actually implemented.
ARCHITECTURE.md describes the resolution model, updating the "Scope" table in the "Address Family Preference" section (around lines 780 to 790), whose -L / SOCKS5 -D target and jump chain rows currently describe the advisory behavior.
The doc comment on open_direct_tcpip_channel_with_family (src/ssh/tokio_client/channel_manager.rs lines 195 to 202) is updated to match the implemented behavior.
A test covers a target name that is unresolvable locally and asserts that the name reaches the channel-open request rather than producing a local resolution error.
A test covers the forced-family path and asserts what is sent under -4 and -6.
AddressFamily::Any behavior for the primary connect path, the first jump hop, and forwarding listeners is unchanged. Only the forwarded-target behavior moves.
The change is integrated into the real forwarding and jump chain code paths, not added as a helper that no caller uses.
Technical Considerations
The change is concentrated in open_direct_tcpip_channel_with_family, which needs to keep the target's hostname (available through the ToSocketAddrsWithHostname bound it already carries) instead of discarding it after resolution. Under Option A the function branches on address_family.is_forced(): forced keeps today's resolve-filter-iterate loop, unforced sends target.hostname() and the port in a single channel-open attempt. Error::NoAddressForFamily stays reachable on the forced path only.
The retry loop over multiple candidate addresses disappears on the unforced path, since there is one request and the server does the iterating. That is the correct division of labor, and it matches OpenSSH, but it does mean a per-address failure is no longer distinguishable from a resolution failure on the client side. The error text should account for that.
src/jump/chain/tunnel.rs has a second, independent local resolution in resolve_handler_address (line 42 onward), which builds the SocketAddr handed to ClientHandler for host key verification context and display. That one is not on the wire and is already documented as best-effort with a fallback, so it is out of scope here, but whoever implements this should confirm the two do not drift into inconsistency: after the change, the address recorded for known_hosts diagnostics may be one the server never connected to.
Two related address family forwarding gaps are open in the same area and touch adjacent code: #255 (SOCKS4 ignores -4/-6 entirely) and #256 (SOCKS5 rejects IPv6 destination literals). All three change how a forwarded destination is turned into a channel-open request, so whoever lands one second should rebase rather than develop in parallel.
Surfaced by the address family work in #246 and #248. The v2.4.0 release notes already list the best-effort nature of forwarding target filtering as a known issue; this issue is about the underlying cause of that caveat.
Problem / Background
bssh resolves
-Land-Dforwarding targets on the local machine and sends a literal IP address in the SSHdirect-tcpipchannel-open request. OpenSSH sends the target hostname exactly as the user wrote it and lets the server resolve it.The divergence breaks any target whose name resolves only from the server's network position, which is one of the primary reasons to use a bastion in the first place. It is also the underlying reason the
-4/-6filtering added in v2.4.0 is only advisory for the far end: the filter exists precisely because bssh resolves locally, and the "advisory" caveat exists because the server may disagree with whatever the client resolved.All line references below are against
mainat47ca657(tagv2.4.0).Technical Analysis
Resolution happens locally, before the channel is opened
src/ssh/tokio_client/channel_manager.rs,direct_tcpip_targets(lines 150 to 167), callstarget.to_socket_addrs()and then applies the address family filter to the result:to_socket_addrs()performs name resolution through the client's resolver. If the name does not resolve on the client, this returns an error before any SSH traffic is sent.What goes on the wire is an IP literal, never the name
open_direct_tcpip_channel_with_family(lines 203 to 240) iterates the resolvedSocketAddrvalues and calls (lines 226 to 231):target.ip().to_string()is the decisive detail: the "host to connect" field of the channel request is always a numeric address, never the hostname the user typed.The doc comment at lines 195 to 202 already acknowledges the consequence:
So the behavior is known and documented. What was never decided is whether it is the right default.
Affected call sites
-Llocal forwardingsrc/forwarding/local.rsline 380,let target = format!("{remote_host}:{remote_port}");insidehandle_connection(starts at line 367)-DSOCKS5, domain destination (ATYP0x03)src/forwarding/dynamic/socks.rsline 198,format!("{domain}:{port}")src/jump/chain/tunnel.rsline 100src/jump/chain/tunnel.rsline 241For
-L,remote_hostis the literal string parsed out of the-Lspecification (src/forwarding/spec.rslines 55 and 71), so the user-supplied name survives all the way todirect_tcpip_targetsand is then discarded in favor of whatever the local resolver returned. For SOCKS5,domainis the hostname the SOCKS client asked for, which is the whole point of ATYP0x03: the client is explicitly delegating resolution.The jump chain rows are the paths #248 made family-aware. They inherit the same resolution model.
What OpenSSH does
Verified against
openssh-portablechannels.conmaster(function names are stable; line numbers drift):channel_setup_fwd_listener_tcpipsetshost = fwd->connect_host(the literal string from the-Lspecification) and stores it verbatim:c->path = xstrdup(host);. The onlygetaddrinfocall in that function resolves the listen address, not the connect target.port_open_helperwrites that stored string straight into the channel request:channel_decode_socks5setsc->path = xstrdup(dest_addr)for the domain form, and only runsinet_ntopto produce a literal for the IPv4 and IPv6 address forms. A SOCKS5 domain request is forwarded as a domain.channel_connect_stdio_fwd, which backsssh -W host:port(the mechanismProxyJumpuses), also doesc->path = xstrdup(host_to_connect);.ssh->chanctxt->IPv4or6, which carries theAddressFamilysetting, is consulted only ingetaddrinfohints for sockets the local process opens. It never touches the string sent as a forwarded target.Two supporting documentation points:
sshd_config(5)PermitOpenstates that "no pattern matching or address lookups are performed on supplied names", confirming that sshd matches the literal string the client sent against the configured destinations.Impact
1. Split-horizon DNS (the common case)
A name like
db.internalthat resolves only inside the server's network fails on the client with a resolution error, even though the entire purpose of-Lis that the server can reach it. The equivalent OpenSSH command works. This is not an edge case: internal-only names behind a bastion are a mainstream use of port forwarding.The failure is also badly signposted. It surfaces as a local resolution error, which points the user at their own DNS configuration rather than at a client design choice, so the natural debugging path leads nowhere.
2. Resolvable locally, but to a different host
A name that resolves on the client to an address unreachable from the server, or to a genuinely different machine (a name that maps to one private address on the local network and another one remotely), tunnels to the wrong host or fails to connect. Nothing reports that the client's and the server's views of the name differ, so a wrong-host outcome can go unnoticed.
3. Server-side logging and access control
sshd sees a request for a numeric address rather than the requested hostname. Hostname-based logging records the literal, and any hostname-based
PermitOpenrule on the server behaves differently than an administrator would expect after testing with an OpenSSH client. APermitOpen db.internal:5432rule that works for OpenSSH does not match a bssh request, because bssh sends the address andPermitOpendoes no address lookups.4. Round-robin and multi-address targets
Local resolution collapses the choice to whatever the client's resolver returned first, rather than letting the server select among the addresses it sees. Load-balanced targets lose their balancing, and a target whose address set differs by vantage point loses the server's (correct) view of it.
Proposed Solution
There is a real tension to state before the options. Local resolution is what makes
-4/-6filtering possible at all for forwarded targets: once the hostname is sent as written, the server picks the family and the client has no way to influence it. Any fix has to make this tradeoff explicitly rather than silently swapping one behavior for the other.Option A (recommended): send the name by default, resolve locally only under a forced family
Send the hostname exactly as written (OpenSSH-compatible) whenever the address family is
Any. Resolve locally and filter only when a family is explicitly forced with-4,-6, orAddressFamily inet|inet6.This makes the common case correct while keeping the family feature working in the only case where it is meaningful: the user asked for a specific family, and accepting a locally-chosen address of that family is a better approximation of the request than sending a name and hoping. The cost is that the family filter no longer applies when no family is forced, which is exactly the case where it had nothing to filter for.
Option A preserves every acceptance criterion from #246 and #248, since both are about what happens under a forced family, and fixes the split-horizon case, which only arises when no family is forced.
Option B: always send the name, drop local family filtering for forwarded targets
Full OpenSSH parity, at the cost of narrowing the documented guarantee:
-4/-6would govern only bssh's own connections and its listener bind addresses, never forwarded targets. Simpler and more predictable, but it walks back part of what #246 and #248 delivered.Option C: keep current behavior, document the divergence prominently
Status quo made explicit. Not recommended: it leaves a mainstream bastion workflow broken relative to OpenSSH, with a misleading error message.
Option A is the recommendation.
Acceptance Criteria
-Lforward whose target hostname does not resolve on the client but does resolve on the server succeeds, with the hostname sent as written in thedirect-tcpiprequest.0x03domain destinations follow the same rule as-L.-4/-6andAddressFamilyguarantees established by fix: Wire up -4/-6 address family flags (currently parsed but ignored) #246 and feat: apply address family preference to jump hops beyond the first #248 continue to hold in whatever form the chosen option defines, with no silent narrowing.docs/man/bssh.1states the actual guarantee precisely, replacing the current "best-effort hint" paragraph under "What the constraint only hints at" (around line 1190) with the resolution model that is actually implemented.ARCHITECTURE.mddescribes the resolution model, updating the "Scope" table in the "Address Family Preference" section (around lines 780 to 790), whose-L/ SOCKS5-Dtarget and jump chain rows currently describe the advisory behavior.open_direct_tcpip_channel_with_family(src/ssh/tokio_client/channel_manager.rslines 195 to 202) is updated to match the implemented behavior.-4and-6.AddressFamily::Anybehavior for the primary connect path, the first jump hop, and forwarding listeners is unchanged. Only the forwarded-target behavior moves.Technical Considerations
The change is concentrated in
open_direct_tcpip_channel_with_family, which needs to keep the target's hostname (available through theToSocketAddrsWithHostnamebound it already carries) instead of discarding it after resolution. Under Option A the function branches onaddress_family.is_forced(): forced keeps today's resolve-filter-iterate loop, unforced sendstarget.hostname()and the port in a single channel-open attempt.Error::NoAddressForFamilystays reachable on the forced path only.The retry loop over multiple candidate addresses disappears on the unforced path, since there is one request and the server does the iterating. That is the correct division of labor, and it matches OpenSSH, but it does mean a per-address failure is no longer distinguishable from a resolution failure on the client side. The error text should account for that.
src/jump/chain/tunnel.rshas a second, independent local resolution inresolve_handler_address(line 42 onward), which builds theSocketAddrhanded toClientHandlerfor host key verification context and display. That one is not on the wire and is already documented as best-effort with a fallback, so it is out of scope here, but whoever implements this should confirm the two do not drift into inconsistency: after the change, the address recorded for known_hosts diagnostics may be one the server never connected to.Two related address family forwarding gaps are open in the same area and touch adjacent code: #255 (SOCKS4 ignores
-4/-6entirely) and #256 (SOCKS5 rejects IPv6 destination literals). All three change how a forwarded destination is turned into a channel-open request, so whoever lands one second should rebase rather than develop in parallel.