fix: wire up the -4/-6 address family flags and AddressFamily - #247
Conversation
`-4`/`--ipv4` and `-6`/`--ipv6` were declared in `src/cli/bssh.rs`, shown in `--help`, and documented in the man page, but no code path ever read them: `bssh -6` against a dual-stack host could still connect over IPv4. The ssh_config `AddressFamily` keyword was dead the same way, parsed and merged during host-config resolution and then discarded. Both now share one representation, `AddressFamily` in `src/ssh/tokio_client/address_family.rs`, resolved once per dispatch path with OpenSSH precedence (command line flag over config keyword over the `any` default) and carried on `SshConnectionConfig`, the struct every connection path already threads. `connect_with_config_inner` filters the resolved candidate list by that preference before attempting any connection. `Any` returns the resolver's list untouched, so the unflagged path is unchanged. The SFTP paths, which never carried a connection config, thread the `Copy` family value alone and rebuild the same default with it applied. Scope, matching the decisions signed off on the issue: direct connections for exec, interactive, ping, and SFTP are hard-filtered, as is the first hop of a `-J` chain since it shares that path. `-6` moves the implicit `-L`/`-D` listen address from `127.0.0.1` to `::1` (and the `*:port` wildcard from `0.0.0.0` to `::`), while an explicit bind address still wins; this is a user-visible default change and has a changelog note. Forwarding targets for `-L` and SOCKS5 `-D` are filtered as a best-effort hint, since the remote sshd performs the connect; SOCKS4 carries a literal IPv4 destination by protocol definition and is passed through. Jump hops past the first ride an existing channel with no local TCP connect and stay unconstrained, but the family now selects the address recorded for host key verification on those hops instead of an unconditional first-resolved pick, with a fallback rather than a panic when nothing matches. Forcing a family with no matching resolved address is a hard failure with no fallback to the other family, reported through the new `Error::NoAddressForFamily` variant as `no IPv6 address found for <host>` instead of the generic `could not resolve to any addresses`. An unrecognized `AddressFamily` value warns via tracing and falls back to `any` rather than rejecting a config file OpenSSH would accept. Validated with `cargo clippy --lib --bins --tests -- -D warnings`, the new `tests/address_family_test.rs` suite (including a dual-stack loopback test that asserts which listener actually accepts the connection), and unit coverage for the candidate filter, the empty-after-filter error path, command-line-over-config precedence, the forwarding listener defaults, and the jump-hop handler address. ARCHITECTURE.md gains an "Address Family Preference" section and the man page gains an ADDRESS FAMILY SELECTION section documenting the limitations. Closes #246
Implementation Review SummaryIntentMake the previously-parsed-but-ignored Verification
The no-flag invariant holds
Locked in by Wiring completenessEvery dispatch route reaches a connect call that carries the preference.
FindingsMEDIUM. The man page and ARCHITECTURE.md give a factually wrong reason for excluding jump hops past the first. Both say those connections "ride an existing channel with no local TCP connect ... the remote server resolves the address and bssh cannot influence which family it picks." The first half is right; the second is not. Decision 3 scoped the behavior out and I am not asking to reverse it. Hard-failing an IPv4-only intermediate hop under LOW. Error chain now repeats host:port on the jump-host resolution failure path. LOW. LOW. LOW, cosmetic. Informational, not defects
|
Review addendum: second-opinion findingsA second reviewer pass surfaced two items. I verified both against the code. Neither changes the verdict (still no CRITICAL or HIGH, nothing auto-fixed), but one of them upgrades a finding I had rated too low and adds a detail I missed. 1. Per-host resolution of the config keyword (upgrading my earlier LOW to MEDIUM)
I originally rated this LOW because it is a faithful extension of an established pattern rather than a new defect: MEDIUM rather than higher because the subject of #246, the 2. Source-breaking public API changes (MEDIUM)
What makes this worth raising is not the semver rule in the abstract, it is that the PR itself already chose the opposite pattern twice: Related and smaller, in the same category: Unchanged from the main reviewNo CRITICAL or HIGH findings. The no-flag invariant holds on every application site, all seven dispatch routes reach a connect call carrying the preference, and the |
…I break The man page and ARCHITECTURE.md justified excluding jump hops beyond the first by claiming the remote server resolves those addresses so bssh cannot influence which family is picked. That is not true: bssh resolves the target locally through the same `open_direct_tcpip_channel` mechanism `-L`/SOCKS5 `-D` targets use, it just is not given the address family filter yet. Both docs now describe this as a scope limitation of the current change, tracked as issue #248, instead of a technical impossibility. CHANGELOG.md's `-4`/`-6` entry also filed the source-breaking library signature changes (`ForwardingSpec::parse_local`/`parse_dynamic`/`parse`, the four `SshClient::*_with_jump_hosts` helpers, `ForwardingConfig::address_family`, `Error::NoAddressForFamily`) under `### Fixed`, which would not signal a version bump. They now live in a new `### Changed` entry marked "Breaking, lib API", listing each affected signature; the CLI behavior change (the `-L`/`-D` listener default under `-6`) stays under `### Fixed` since it is a distinct, user-facing change, and its own jump-hop wording was corrected to match. Validation: - mandoc -Tlint docs/man/bssh.1 (no new warnings)
`ForwardingType`'s `Display` wrote `bind_addr:bind_port` directly, so a `-6` local forward rendered as `::1:8080→example.com:80`, an ambiguous string that does not parse back to an address and port. Building a `SocketAddr` from the two fields instead reuses its own `Display`, which brackets IPv6 (`[::1]:8080→example.com:80`) and leaves IPv4 unchanged (`127.0.0.1:8080→...`); applied to all three variants (`Local`, `Remote`, `Dynamic`) since they share the same `bind_addr`/`bind_port` fields. Added `test_forwarding_type_display_brackets_ipv6` covering both families across all three variants, and fixed the matching unbracketed example in `ForwardingSpec`'s module doc comment. `resolve_handler_address` in `src/jump/chain/tunnel.rs` restated `host:port` in its own error context on top of the identical wording every call site's `.with_context()` already adds, so a resolution failure rendered the same `host:port` twice in the chain, regressing the no-duplicate-wording convention from issue #238. The function now lets `?` propagate the bare `to_socket_addrs` error and uses a short, address-free message for the empty-candidates case, leaving `host:port` to the one call-site layer that already carries it. Added a regression test that renders the full two-layer chain for a `.invalid` hostname and asserts `host:port` appears exactly once. Validation: - cargo fmt --check - cargo clippy --lib --tests -- -D warnings - cargo test --lib forwarding:: (25 passed) - cargo test --lib jump:: (49 passed) - cargo test --test address_family_test (9 passed) - cargo test --doc forwarding (2 passed) Refs #246
PR finalizationAddressed the four review-requested corrections and added the tests/docs polish that went with them. Corrections
Polish
Validation
Left alone (out of scope, as instructed)
|
Summary
-4/--ipv4and-6/--ipv6were declared, shown in--help, and documented in the man page, but nothing ever read them:bssh -6against a dual-stack host could still connect over IPv4. The ssh_configAddressFamilykeyword was dead the same way, parsed and merged during host-config resolution and then discarded. This wires both up end to end, implementing the six decisions signed off on the issue.Design
Both sources share one representation,
AddressFamily { Any, V4, V6 }insrc/ssh/tokio_client/address_family.rs. It is resolved once per dispatch path with OpenSSH precedence (command line flag over config keyword over theanydefault) and carried onSshConnectionConfig, the struct every connection path already threads, so exec, interactive, ping, the jump chain, and port forwarding all inherit it without a separate parameter.connect_with_config_innerfilters the resolved candidate list before attempting any connection;Anyreturns the resolver's list untouched, which keeps the unflagged path byte-for-byte identical to before. The SFTP paths never carried a connection config (they relied onestablish_connectionsubstituting the default), so they thread theCopyAddressFamilyvalue alone and rebuild that same default with the family applied.What changed, per decision point
-6moves the implicit-L/-Dlisten address from127.0.0.1to::1, and the*:portwildcard form from0.0.0.0to::.-4and the no-flag default keep the IPv4 loopback. An explicit bind address in the spec always wins.ForwardingSpec::parse_local/parse_dynamicand the newparse_bind_spec_with_familytake the preference;Cli::parse_port_forwardsnow takes it too. This is a user-visible default change and has its own changelog entry.Client::open_direct_tcpip_channel_with_familyfilters the candidate list;open_direct_tcpip_channeldelegates to it withAnyso nothing else changes.ForwardingConfigcarries the family to the-Lforwarder and the SOCKS5-Dhandler. SOCKS4 carries a literal IPv4 destination by protocol definition and is passed through unfiltered, with a comment saying so. The man page states this is a best-effort hint because the remote sshd performs the connect.JumpChain::connect_to_first_jump, which already usesconnect_with_ssh_config, so it is covered automatically.tunnel.rs. Bothto_socket_addrs().next()sites are replaced by a sharedresolve_handler_addressthat prefers a candidate of the forced family, falls back to the first resolved address when none matches (no panic), and logs the fallback at debug level.Error::NoAddressForFamily { host, family }renders asno IPv6 address found for <host>(and the IPv4 equivalent), replacing the genericcould not resolve to any addresses, and returns through the same non-zero exit path as other connection failures. No fallback to the other family.AddressFamilyvalues. Case-insensitiveany|inet|inet6; an unrecognized value warns throughtracingand falls back toanyrather than rejecting a config file OpenSSH would accept.Two adjacent gaps were closed so the flags are not silently ignored on some subcommands:
pingnow receives the resolvedSshConnectionConfig(which also makes it honorServerAliveInterval/Compressionlike exec already did), and the port-forwarding carrier connection incommands/exec.rsswitched fromClient::connecttoClient::connect_with_ssh_config.src/cli/pdsh.rsstill hardcodesipv4: false, ipv6: false; pdsh has no equivalent flag, so that is left as-is.Tests added
tests/address_family_test.rs(9 tests): ssh_config keyword honored per host, command line flag over config keyword, unrecognized value falling back toany,-6changing the-L/-Dlisten default, the no-flag IPv4 default, an explicit bind address overriding the flag, a dual-stack loopback test that binds both families on the same port and asserts which listener actually accepts the connection under-4and under-6(skipped with a message when IPv6 loopback is unavailable), the unforced path still connecting, and the hard-failure message.Unit tests: 12 in
address_family.rscovering the filter (mixed list yields only IPv4 under-4, only IPv6 under-6, and the original list unchanged and in order with neither), emptying,first_match, case-insensitive config parsing, precedence, and the bind-address defaults; 5 inconnection_tests.rscovering the empty-after-filter error path on the real connect path for both families, the default staying unconstrained, builder chaining, and the unforced path never reporting a family mismatch; 4 inforwarding/spec.rsand 1 inforwarding/mod.rsfor the listener defaults; 3 injump/chain/tunnel.rsfor the handler address preference and fallback.Docs
docs/man/bssh.1: the-4/-6entries are rewritten,AddressFamilyis documented under a new Connection Options subsection, and a newADDRESS FAMILY SELECTIONsection spells out what the constraint covers (direct connects, first jump hop,-L/-Dlistener), what it only hints at (forwarding targets, SOCKS4 exemption), what it does not cover (later jump hops,-Rlistener,bssh-server), and the failure behavior.ARCHITECTURE.mdgains an "Address Family Preference" section describing the representation, threading, scope table, and failure mode.CHANGELOG.mdgains two entries under Unreleased/Fixed, one of which calls out the forwarding listener default change and the migration (name the bind address explicitly).Test plan
cargo clippy --lib --bins --tests -- -D warningscleancargo test --test address_family_test(9 passed)cargo test --lib ssh::(348 passed),cargo test --lib forwarding::(24 passed),cargo test --lib jump::(48 passed),cargo test --lib cli::(24 passed)cargo test --test ssh_keepalive_test(34 passed),cargo test --test pdsh_compat_test(35 passed)cargo test --doc forwarding(2 passed)Closes #246