Skip to content

fix: Wire up -4/-6 address family flags (currently parsed but ignored) #246

Description

@inureyes

Problem

bssh declares OpenSSH-compatible -4/--ipv4 and -6/--ipv6 flags, but no code path ever reads them. They parse successfully, appear in --help, are documented in the man page, and silently do nothing.

A user who runs bssh -6 -H host "uptime" against a dual-stack host may still get an IPv4 connection. The flags are advertised behavior that does not exist.

Declaration site, src/cli/bssh.rs lines 280 to 296:

#[arg(
    short = '4',
    long = "ipv4",
    conflicts_with = "ipv6",
    help = "Force use of IPv4 addresses only"
)]
pub ipv4: bool,

#[arg(
    short = '6',
    long = "ipv6",
    conflicts_with = "ipv4",
    help = "Force use of IPv6 addresses only"
)]
pub ipv6: bool,

conflicts_with is wired correctly, so mutual exclusion already works. Only the consumption is missing.

Proof they are unused. rg -n 'ipv4|ipv6' src/ --type rust outside src/cli/bssh.rs returns only unrelated hits:

  • src/hostlist/parser.rs (is_ipv6_start, bracket parsing in hostlist expressions)
  • src/utils/sanitize.rs (hostname validation)
  • src/app/initialization.rs (is_ipv4_address, used for SSH single-host mode detection)
  • src/cli/pdsh.rs lines 321 and 322, which only hardcode ipv4: false, ipv6: false when building a Cli from pdsh-compat arguments

None of them reads cli.ipv4 or cli.ipv6.

The AddressFamily config keyword is dead in the same way. AddressFamily is parsed (src/ssh/ssh_config/parser/options/connection.rs line 102), stored (src/ssh/ssh_config/types.rs line 78, pub address_family: Option<String>), and merged during host-config resolution (src/ssh/ssh_config/resolver.rs lines 214 to 215). It is never read anywhere else, so the value round-trips through the config layer and is then discarded. Since the plumbing already exists, honoring AddressFamily belongs in this issue rather than a follow-up, and the OpenSSH precedence rule applies: the command line flag overrides the config keyword.

Expected behavior

Match OpenSSH semantics:

  • -4 forces IPv4 only. Only IPv4 socket addresses are candidates.
  • -6 forces IPv6 only. Only IPv6 socket addresses are candidates.
  • Neither flag means try all resolved addresses in resolver order. This is today's behavior and must remain the default.
  • AddressFamily any|inet|inet6 in SSH config applies the same constraint at lower precedence than the command line flag.
  • When the forced family yields no candidate address, fail with a specific error naming the family and the host, not the current generic message.

Affected code paths

1. Primary connect path (in scope)

src/ssh/tokio_client/connection.rs, connect_with_config_inner (starts at line 337). It resolves the target and iterates every candidate address with no family filter:

let socket_addrs = addr
    .to_socket_addrs()
    .map_err(super::Error::AddressInvalid)?;
let mut connect_res: Result<...> = Err(super::Error::AddressInvalid(io::Error::new(
    io::ErrorKind::InvalidInput,
    "could not resolve to any addresses",
)));
for socket_addr in socket_addrs {
    ...
    let stream = match tokio::net::TcpStream::connect(socket_addr).await { ... };

The fix is to filter socket_addrs by SocketAddr::is_ipv4() / SocketAddr::is_ipv6() when a family is forced, and to produce a clear error when the filter leaves no candidates (for example no IPv6 address found for <host> instead of the current could not resolve to any addresses).

The addr parameter is an impl ToSocketAddrsWithHostname, so the family preference cannot be encoded in the address type. It has to be threaded in as a separate argument or carried on the existing Config struct.

2. Jump hosts (first hop in scope, later hops out of scope)

The first hop goes through JumpChain::connect_to_first_jump (src/jump/chain.rs line 350), which reaches the same connect_with_config_inner path, so it is covered by fix 1.

Later hops in src/jump/chain/tunnel.rs call russh::client::connect_stream (lines 122 and 234) on a stream that already exists (a channel through the previous hop). No local TCP connect happens there. The remote sshd performs the resolution, which bssh cannot influence.

One nuance worth deciding on: src/jump/chain/tunnel.rs lines 97 and 220 do call to_socket_addrs().next(), but only to build a SocketAddr for ClientHandler (host key verification context and display), not to open a socket. Taking .next() unconditionally means the family recorded there is also not controllable, so a mismatch between the recorded address and the actual connection could confuse known_hosts diagnostics.

3. Port forwarding listeners (decision required)

  • src/forwarding/local.rs line 250: TcpListener::bind(self.bind_addr)
  • src/forwarding/dynamic/forwarder.rs line 210: TcpListener::bind(self.bind_addr)

bind_addr currently defaults to IpAddr::V4(Ipv4Addr::LOCALHOST) (src/forwarding/spec.rs line 55 for Local, line 105 for Remote).

In OpenSSH, AddressFamily also constrains which addresses forwarded ports listen on. This issue must decide whether -6 makes -L/-D bind to ::1 instead of 127.0.0.1.

Related: ChannelManager::open_direct_tcpip_channel (src/ssh/tokio_client/channel_manager.rs line 169) resolves the forwarding target locally and iterates candidates to send in the direct-tcpip request. Whether the forced family should filter that list is a separate question, since the address is interpreted by the remote sshd rather than connected to locally.

4. bssh-server (out of scope)

src/server/mod.rs line 277 (TcpListener::bind(addr)) belongs to the bssh-server binary, which has its own CLI in src/bin/bssh_server.rs. Out of scope unless the server CLI grows the same flags.

Decision points

Answer these before implementation:

  1. Forwarding listeners. Does -6 change the default listen address for -L/-D from 127.0.0.1 to ::1? OpenSSH's AddressFamily does constrain forwarding listeners, but changing this default is user-visible and could break scripts that assume IPv4 loopback. If yes, does an explicit bind_address in the forwarding spec override the flag?
  2. Forwarding targets. Should the forced family filter the candidate list passed to open_direct_tcpip_channel, given that the remote sshd performs the actual connect?
  3. Jump host hops beyond the first. Confirm they are out of scope, and document that limitation in the man page so it is explicit rather than surprising.
  4. Handler address in tunnel.rs. Should the SocketAddr used for ClientHandler respect the forced family, for consistency with host key records?
  5. Failure mode. What exact error text and exit code when the forced family yields no address? Proposal: a distinct error variant carrying the hostname and requested family, surfaced as no IPv6 address found for <host>, using the same non-zero exit path as other connection failures. Confirm the wording, and confirm it is a hard failure with no fallback to the other family (which is what OpenSSH does).
  6. AddressFamily values. Accept any, inet, inet6 case-insensitively. Decide the behavior for an unrecognized value: hard parse error, or warn and treat as any.

Acceptance criteria

Integration into the real code flow is required. A standalone helper function that nothing calls does not satisfy this issue.

  • -4 and -6 are read from Cli and threaded through to connect_with_config_inner, so the flag demonstrably changes which address is connected to. Verified on a dual-stack host, or with a test double that asserts the family of the connected SocketAddr.
  • AddressFamily any|inet|inet6 from SSH config is honored, with the command line flag taking precedence over the config keyword.
  • Forcing a family that has no resolved address fails with a specific error naming the host and the requested family, not the generic could not resolve to any addresses.
  • No behavior change when neither the flag nor AddressFamily is set: all resolved addresses are tried in resolver order.
  • First jump hop honors the flag (it shares the primary connect path). Behavior for later hops is documented.
  • The decisions above about forwarding listeners and forwarding targets are implemented as decided, or explicitly documented as intentionally unaffected.
  • Unit test asserting the address filter: given a mixed IPv4/IPv6 candidate list, -4 yields only IPv4 candidates, -6 yields only IPv6, and neither flag yields the original list unchanged and in order.
  • Test covering the empty-after-filter error path.
  • Test covering command line over config precedence.
  • Docs updated. docs/man/bssh.1 already documents -4 at line 82 and -6 at line 86 with the text "Force use of IPv4/IPv6 addresses only", so the man page currently describes behavior that does not exist. Update those entries with the actual scope once implemented (which paths are affected, the jump host limitation, forwarding behavior), and document AddressFamily support.

Notes

Found while refreshing #75. A proposed network-ping command would be the natural first consumer of the address family preference, since connectivity testing is exactly where forcing a family matters most. Whoever picks up #75 should depend on the preference being plumbed through rather than reimplementing the filter locally.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions