fix: show full anyhow error chain on interactive connect failure - #240
Conversation
Interactive-mode connection failures printed only the outermost anyhow context via eprintln!("{}", e), hiding the actual failing hop. A user hitting a destination sshd that was not yet accepting connections saw only "Failed to establish jump host connection", with the real cause (a refused direct-tcpip channel open) invisible.
Add format_connection_error() in the new src/commands/error_format.rs module, which renders an anyhow::Error's full context chain via the alternate Display form ({:#}), matching the format already used by src/executor/parallel.rs so exec-mode and interactive-mode output stay consistent. src/executor/parallel.rs itself is untouched.
Wire the helper into both interactive execution sites (PTY path and traditional path in src/commands/interactive/execution.rs) and into the directory-download error path in src/commands/download.rs, which shares the same jump-host connection stack and previously discarded context via e.to_string(). Other eprintln!/println! sites in src/commands/ were audited and left alone: they either print leaf errors with no wrapped context (exec.rs port-forwarding warning) or a foreign error type with no anyhow chain (ReadlineError in interactive/single_node.rs and interactive/multiplex.rs).
Add unit tests in error_format.rs asserting that all layers of a nested .context() chain appear in the formatted output, that different failure hops (first-jump vs destination-auth) produce distinguishable messages containing their own root cause, and that a single-layer error still formats correctly.
Refs #238
This PR is a user-facing bug fix but shipped without a CHANGELOG bullet, and since the release commit mechanically rolls the `## [Unreleased]` section into the new version heading, the fix would have been silently absent from the next release notes. The repository convention for a user-facing fix is a `## [Unreleased]` / `### Fixed` bullet referencing the issue number rather than the PR number (see 41642d9 for #234 and 127246b for #189, which likewise opened a fresh Unreleased section immediately after a release), so this adds that section back on top of the just-released v2.3.1 heading with a single bullet describing the interactive connection error chain fix. Documentation only, no code or behavior change. Refs #238
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Independent audit of
|
Security and performance reviewVerdict: no CRITICAL or HIGH findings, nothing auto-fixed, no commits added. The change is sound and I recommend merging. Two follow-up items below, both pre-existing rather than introduced here. I traced every context layer reachable from the three new call sites through No credential material can reach stderr through
|
The #238 chain-rendering fix makes the full anyhow context chain visible for the first time, which surfaced three pre-existing defects in the messages being wrapped: they would now render as duplicated or backwards text instead of just being silently truncated by anyhow's default Display. src/jump/chain.rs's intermediate jump-hop `.with_context()` interpolated the jump host twice ("Failed to connect to jump host bastion (hop 2): bastion"). The closure is now a small testable `intermediate_jump_hop_context` helper that interpolates the host once. src/ssh/client/connection.rs and src/ssh/client/file_transfer.rs both built their anyhow chain backwards via `anyhow!(friendly_message).context(e)`; `.context()`'s argument becomes the outer layer, so the raw SSH error rendered first and the friendly message rendered underneath it as a near-duplicate cause. Both now use `anyhow::Error::new(e).context(friendly_message)` so the friendly message is outermost and `e` is the cause, and their catch-all match arms no longer interpolate `e`'s own Display text into the friendly message (which would otherwise duplicate the cause verbatim once the ordering is corrected). src/commands/ping.rs's error-chain print now reuses the shared `format_connection_error` helper instead of its own inline `format!("{e:#}")`; output is unchanged since the helper does the same formatting. src/commands/error_format.rs's test fixture data is updated to match the corrected jump-hop message instead of the old duplicated form. src/executor/parallel.rs is untouched. Validation: - cargo check --lib --tests - cargo test --lib jump::chain (17 passed, including 2 new) - cargo test --lib ssh::client::connection (6 passed, including 2 new) - cargo test --lib ssh::client::file_transfer (2 passed, new module) - cargo test --lib commands::error_format (3 passed) - cargo clippy --lib --tests -- -D warnings (clean) - cargo fmt --all -- --check (clean) Refs #238
Correcting the inverted anyhow nesting exposed a second defect in the same messages. Because anyhow's `{:#}` form joins layers with ": ", a context message that restates its own cause prints the same wording twice on one line.
The direct-connect path returned a context message for every error variant, so `PasswordWrong` rendered as "Password authentication failed.: Password authentication failed" and `SshError` interpolated the russh error that the variant's own Display already carried. `connect_error_message` now returns `Option<String>` and yields `None` for variants whose Display already says everything, so those get no extra layer. The messages it does return add remediation guidance without echoing the cause.
`KeyInvalid`'s context in both the direct-connect and file-transfer paths no longer re-interpolates the inner key error, which the variant's Display already includes. Context messages also no longer end with a period, which rendered as the sequence ".: " mid-line.
Since `SshError` is now shown directly rather than behind a wrapping layer, its message text is user-visible, so the `occured` misspelling and the `Ssh` capitalization are fixed there and in `SftpError`.
Adds regression tests covering the omitted-context variants, the inner key error appearing exactly once, and the absence of trailing periods.
Two defects found reviewing #239's TOFU implementation. `russh::keys::check_known_hosts_path` reports `Err(KeyChanged)` only when an entry for the host exists with the same key algorithm, and `Ok(false)` both for a hostname with no entry at all and for a hostname whose entries are all of a different algorithm. accept-new treated the second case as a first use, appending the offered key next to the existing entry and accepting the connection, so a man-in-the-middle that advertises support for only an algorithm the host has not used yet bypassed pinning entirely: SSH negotiation picks the first client algorithm the server also supports, so the attacker chooses it. OpenSSH resists this by reordering its per-host key algorithm proposal to prefer already-known types, which russh cannot do per host, so `verify_accept_new` now consults `known_host_keys_path` before recording and treats any existing entry for the host as a changed key. The printed removal command did not work for non-standard ports. known_hosts records those as `[host]:port`, but the warning banner and both client-facing guidance messages emitted `ssh-keygen -R <host>`, which matches nothing, and `Error::HostKeyChanged` did not carry the port for the downstream messages to use. The variant now carries the port, the banner follows OpenSSH's `ssh-keygen -f "<file>" -R "<entry>"` form with the resolved default known_hosts path, and every printed argument is double quoted so an unmatched `[...]` glob does not make zsh reject the command. Both #240 invariants are preserved: no context layer restates its own cause, and no context message ends with a period. Refs #239
Two review passes on the accept-new TOFU implementation reported three remaining gaps that were not yet fixed: known_hosts `@revoked`/`@cert-authority` marker lines were silently ignored, host matching was case-sensitive where OpenSSH is not, and known_hosts permission tightening happened after creation instead of at creation. russh's `known_host_keys_path` splits each line on `' '` and takes the first field as the host list, so on a marker line such as `@revoked node1 ssh-ed25519 <key>` that first field is the literal string `@revoked`, which never matches `node1`. The line was therefore invisible to the lookup, and a revoked key looked like an ordinary first use: recorded and accepted while printing the normal "Permanently added" notice. A new scan in `host_verification.rs` runs before the ordinary lookup in both accept-new and strict (`yes`)/`DefaultKnownHostsFile` mode: a `@revoked` line whose host matches (exact, comma-list, or `*`/`?` glob form, erring toward matching when in doubt since failing to honor a revocation is worse than an unnecessary rejection) and whose key equals the offered key is a hard rejection with a new `HostKeyRevoked` error, while a matching `@revoked` line naming a different key does not block the offered one. A `@cert-authority` match only warns and falls through to ordinary TOFU, since bssh has no CA signature validation path and failing closed would break every working CA setup with no workaround. Hostnames are now lowercased once at entry to `verify_accept_new` and `verify_known_hosts_file`, and the normalized value is used for both the lookup and the recorded entry. russh's `match_hostname` is a plain byte compare, so without this a differently-cased hostname bypassed an existing pin and re-TOFU'd instead of hitting the same entry OpenSSH would have written. `~/.ssh` and `known_hosts` are now created with mode 0700/0600 directly via `DirBuilder`/`OpenOptions` (`#[cfg(unix)]`, with a `#[cfg(not(unix))]` fallback) before `learn_known_hosts_path` runs, instead of letting it create them with the process umask and tightening the mode only afterward. A `mkdir`/`open` syscall that requests the final mode directly cannot be widened by any standard umask, since umask only clears requested bits and neither 0700 nor 0600 carries a group or other bit to clear, so this closes the window instead of narrowing it after the fact. Pre-existing files and directories are left untouched either way, and the prior post-creation chmod is kept as a fallback for the rare case where pre-creation could not run (for example a missing grandparent directory that only `learn_known_hosts_path`'s `create_dir_all` fills in). `HostKeyRevoked` gets its own guidance arms in `connect_error_message` and `format_ssh_error`, verified against both #240 invariants (no restating the cause, no trailing period), and a regression test confirms it does not trigger interactive password fallback, matching `HostKeyChanged`. Validation: - cargo check --lib --tests (clean) - cargo clippy --lib --tests -- -D warnings (clean) - cargo fmt --all -- --check (clean) - cargo test --lib ssh::tokio_client (36 passed, 9 new: revoked matching/non-matching, cert-authority, comma-list/glob/non-standard-port marker matching, case-insensitive hostname matching for accept-new and strict mode) - cargo test --lib ssh::client (22 passed, 2 new guidance-invariant tests) - cargo test --lib commands::interactive (20 passed, 1 new password-fallback exclusion test) - cargo test --lib ssh::known_hosts (5 passed) and cargo test --lib jump::chain (17 passed) Refs #239
## Summary
The documented, recommended default `--strict-host-key-checking accept-new` performed no host key verification at all: it mapped to `ServerCheckMethod::NoCheck`, accepted any server key, never wrote to `~/.ssh/known_hosts`, and never rejected changed keys. This PR implements real TOFU for accept-new, fixes strict (`yes`) mode's silent downgrade to `NoCheck` when the known_hosts file is missing, makes changed keys a first-class error in every known_hosts-based check method, and, after two review passes, closes the gaps those reviews found: an OpenSSH-compatible any-matching-entry acceptance rule, hostname sanitization before recording, `@revoked`/`@cert-authority` known_hosts marker line handling, case-insensitive hostname matching, and a closed permission-creation window on `~/.ssh`/`known_hosts`.
## Correction to the issue's verified facts
The issue's "Verified Implementation Facts" section claims russh 0.62.1 exposes no `learn_known_hosts` helper and that bssh must implement the known_hosts append itself. That claim is incorrect: `russh::keys::known_hosts::learn_known_hosts_path(host, port, pubkey, path)` exists in russh 0.62.1 and already handles parent directory creation, newline-safe appending, the `[host]:port` entry form for non-22 ports, and OpenSSH key serialization. This implementation uses that library helper instead of hand-rolling the append; what the helper does not do, and what this PR adds on top, is restrictive permissions on newly created files (created up front rather than tightened afterward), serialization of concurrent writers, an OpenSSH-compatible any-matching-entry acceptance rule (the helper's own `check_known_hosts_path` short-circuits on the first non-matching same-algorithm entry), `@revoked`/`@cert-authority` marker line handling (the helper's parser reads the marker itself as the literal host field and never matches these lines), and hostname sanitization before recording.
## What changed
- `src/ssh/tokio_client/authentication.rs`: new path-carrying `ServerCheckMethod::AcceptNewKnownHostsFile(String)` variant (plus `with_accept_new_known_hosts_file`), so tests can inject a temp known_hosts path instead of mutating `HOME`.
- `src/ssh/tokio_client/host_verification.rs` (new): implements the trust decision for both accept-new and strict/`DefaultKnownHostsFile` mode through a shared `lookup_known_host`, which reads a host's known_hosts entries directly and applies OpenSSH's rule: any recorded key equal to the offered key verifies, and only a host whose recorded keys none of them match counts as changed. This also closes the alternate-algorithm pinning bypass (a key offered under an algorithm the host had not used yet, which `check_known_hosts_path` cannot distinguish from an unknown host) and avoids the false-positive rejection `check_known_hosts_path`'s short-circuiting collection produces for legitimate layouts such as a stale key kept beside a rotated one or a cluster-style shared line beside a per-node line. Before recording or looking anything up, the module also: rejects a hostname that cannot round-trip through a known_hosts entry (empty, over 255 bytes, whitespace/control characters, or `#`/`,`/`|`/`*`/`?`/`!`/`\` metacharacters, since `learn_known_hosts_path` writes the hostname with no escaping and a malformed entry can never be verified again either); scans for `@revoked`/`@cert-authority` marker lines naming the host (host matching supports the exact, comma-list, and `*`/`?` glob forms, erring toward matching for `@revoked` when in doubt) and hard-rejects a key matching an `@revoked` entry with a new `HostKeyRevoked` error while warning and falling through to ordinary TOFU for a `@cert-authority` match, since bssh has no CA signature validation to fall back to; and normalizes the hostname to lowercase once, using the normalized value for both the lookup and the recorded entry, matching OpenSSH's case-insensitive known_hosts comparison. Unknown hosts are recorded via `learn_known_hosts_path` with the OpenSSH-style `Permanently added '<host>' (<ALGO>) to the list of known hosts.` notice; conflicting keys are rejected with the OpenSSH warning banner (SHA256 fingerprint of the offered key, offending file and line, `ssh-keygen -f "<file>" -R "<entry>"` remediation hint naming the actual `[host]:port`-qualified entry) without touching the recorded entry. Recording failure warns and accepts, matching OpenSSH accept-new semantics.
- Concurrency: the whole check-then-record sequence runs under a process-wide `tokio::sync::Mutex`, so parallel first-time connects to the same new host record exactly one entry and readers never observe a half-written line. A tokio mutex (not `std::sync::Mutex`) was chosen because the guard lives inside the async `check_server_key` handler; the critical section is purely synchronous file I/O today, and the tokio mutex keeps the guard sound if an await point is ever introduced while parking contending tasks instead of blocking runtime workers.
- Permissions: `~/.ssh` and `known_hosts` are created with mode 0700/0600 directly (`DirBuilder`/`OpenOptions` with an explicit mode, `#[cfg(unix)]`) before `learn_known_hosts_path` runs, rather than letting it create them with the process umask and tightening the mode only afterward, closing the window in which an unusually permissive umask could leave either one group- or world-writable; pre-existing files and directories are left untouched either way, verified by test.
- `src/ssh/tokio_client/error.rs`: new `Error::HostKeyChanged { host, port, line }` and `Error::HostKeyRevoked { host, port, line }` variants; the `KnownHostsFile` and `DefaultKnownHostsFile` arms in `check_server_key` no longer collapse `russh::keys::Error::KeyChanged` into the generic `ServerCheckFailed`, so strict mode reports changed and revoked keys specifically too.
- `src/ssh/known_hosts.rs`: `get_check_method` is now a pure mapping with no filesystem side effects (it no longer pre-creates an empty known_hosts file; `learn_known_hosts_path` creates it on demand). `Yes` maps to `KnownHostsFile(default path)` unconditionally, since russh treats a missing file as empty, so unknown hosts are rejected instead of unverified; an undeterminable home directory falls back to `DefaultKnownHostsFile`, which fails closed inside russh. `AcceptNew` maps to `AcceptNewKnownHostsFile(default path)`. Stale "async-ssh2-tokio doesn't support TOFU" comments deleted.
- `src/jump/chain/tunnel.rs`: removed the redundant strict-mode match before `get_check_method` (it already maps `No` to `NoCheck`).
- `src/commands/exec.rs`: the port-forwarding path had its own hand-rolled mode mapping that sent accept-new through strict default-known_hosts checking (rejecting unknown hosts); it now routes through `get_check_method`, so TOFU covers every connection path: direct, first jump hop, intermediate hops, destination through the tunnel, SFTP, interactive (shell and PTY), and port forwarding. All `get_check_method` call sites were individually verified to flow into `ClientHandler` with the real hostname preserved (via `ToSocketAddrsWithHostname` or direct construction), so `[host]:port` entries round-trip on every path.
- `src/ssh/client/connection.rs` and `src/ssh/client/file_transfer.rs`: guidance arms for `HostKeyChanged` and `HostKeyRevoked` (and, in the direct path, `SshError(UnknownKey)` for strict-mode unknown-host rejections) in `connect_error_message` and `format_ssh_error`, following both #240 invariants: the context layer does not restate its cause and does not end with a period, enforced by tests alongside the existing ones.
- `src/commands/interactive/connection.rs`: regression tests that neither `HostKeyChanged` nor `HostKeyRevoked` ever triggers interactive password fallback (retrying with a password would hand credentials to an untrusted endpoint).
- Docs: `docs/architecture/ssh-client.md` host key section updated to the actual behavior, including the any-matching-entry rule, `@revoked`/`@cert-authority` marker handling, and case-insensitive matching. `CHANGELOG.md` gains four `### Security` entries under `[Unreleased]` covering each of the above. The CLI help text (`accept-new - Accept new hosts, reject changed keys (recommended)`) and man page were verified to match reality and are unchanged.
## Deliberately out of scope
Two review passes surfaced additional lower-severity findings that are intentionally not addressed here:
- Running the known_hosts lookup unlocked and taking the process-wide lock only when the host looks unknown. Measured cost of the current fully-serialized lookup is 25 to 400 ms across a 512-node fan-out for realistic known_hosts sizes, and moving the read out of the lock risks reintroducing the duplicate-append race this PR exists to prevent.
- Cross-process locking (`KNOWN_HOSTS_LOCK` is per-process only).
- `get_check_method(AcceptNew)`'s no-home-directory `NoCheck` fallback.
- The dead, divergent duplicate `ServerCheckMethod` enum in `src/shared/auth_types.rs`.
## Test plan
- [x] `cargo test --lib` (1314 passed, 0 failed, exit 0)
- [x] `cargo test --lib ssh::tokio_client` (36 passed, including the host_verification suite: first-use recording, no duplicate on reconnect, changed key rejected with entry preserved, any-matching-entry acceptance for stale-beside-rotated and cluster-shared-line layouts, alternate-algorithm rejection, 8-way concurrent first connects, `[host]:2222` round-trip, created dir/file get 0700/0600 with pre-existing modes preserved, `@revoked` matching-key rejection and non-matching-key pass-through, `@cert-authority` warning-then-TOFU, comma-list/glob/non-standard-port marker matching, case-insensitive hostname matching for both accept-new and strict mode, strict mode parity with accept-new, hostname-sanitization rejection and regression coverage, handler end-to-end TOFU arm)
- [x] `cargo test --lib ssh::known_hosts` (5 passed)
- [x] `cargo test --lib ssh::client` (22 passed, including the `HostKeyChanged`/`HostKeyRevoked` guidance-message invariant tests) and `cargo test --lib jump::chain` (17 passed)
- [x] `cargo test --lib commands::interactive` (20 passed, including the password-fallback exclusion tests for both error variants) and `cargo test --lib commands::exec` (0 tests; `exec.rs` has no unit tests of its own)
- [x] `cargo check --lib --tests`, `cargo clippy --lib --tests -- -D warnings`, and `cargo fmt --all` clean
- [x] `cargo check --lib --target x86_64-pc-windows-gnu` could not complete in this environment: the target is installed but the `aws-lc-sys` build script requires `x86_64-w64-mingw32-gcc`, which is not present; the failure occurs in a C dependency before any bssh code compiles and is unrelated to this change. Windows compatibility is preserved by gating the permission code on `#[cfg(unix)]` with a non-unix fallback arm.
Note on an unrelated pre-existing flake: `commands::interactive::utils::tests::test_expand_path_with_tilde` fails nondeterministically under the full parallel `cargo test --lib` because several tests call `EnvGuard::set("HOME", ...)`, mutating process-global env while that test reads `dirs::home_dir()` twice. It is untouched by this PR, and no test added here mutates `HOME`; the new `ServerCheckMethod::AcceptNewKnownHostsFile` variant carries a path so every new test injects a temp known_hosts location explicitly.
Closes #239
Summary
Interactive-mode connection failures used to print only the outermost anyhow context via
eprintln!("{}", e), hiding the failing hop; this adds a shared helper that renders the full anyhow context chain and wires it into both interactive execution sites plus the other user-facing sites insrc/commands/that had the same problem. Because that fix makes the full chain visible for the first time, it also exposed several pre-existing message-construction defects that would have rendered as duplicated or backwards text once the whole chain was shown; those are fixed here too, since a chain that reads backwards or repeats itself defeats the purpose of showing it.What changed
src/commands/error_format.rswithformat_connection_error(&anyhow::Error) -> String, which renders the full context chain via the alternate Display form ({:#}), matching the format already used bysrc/executor/parallel.rs(left untouched) so exec-mode and interactive-mode output stay visually consistent.src/commands/mod.rs.src/commands/interactive/execution.rs: both the PTY-path and traditional-path connection failure prints (lines ~64 and ~133) now use the helper instead of bare{}Display.src/commands/download.rs: the directory-download failure print now uses the helper instead ofe.to_string(), sincedownload_dir_from_nodegoes through the same jump-host chain and was losing the same context.src/commands/ping.rs: the error-chain print now reuses the shared helper instead of its own inlineformat!("{e:#}"), for consistency; output and per-line indentation are unchanged since the helper does the same formatting.eprintln!/println!sites insrc/commands/and left them alone:exec.rs's port-forwarding warning wraps a leafanyhow!()with no added context, theReadlineErrorprints ininteractive/single_node.rsandinteractive/multiplex.rsare not anyhow errors at all, andinteractive/multiplex.rs:212prints ananyhow::Errorwith{}whose source is a bare leafrussh::Errorwith no.context()layer added, so{}and{:#}render identically there and switching it would be a no-op.error_format.rscovering a nested.context()chain (all layers present in output), two different failure hops producing distinguishable messages that each contain their own root cause, and a single-layer error formatting correctly.Fixed defects the chain rendering would have made visible
src/jump/chain.rs: the intermediate jump-hop.with_context()interpolated the jump host twice, producingFailed to connect to jump host bastion (hop 2): bastion. The closure is now a small, unit-testedintermediate_jump_hop_contexthelper that interpolates the host once, producingFailed to connect to jump host bastion (hop 2). The sibling first-jump and destination contexts were checked and did not have the same defect.src/ssh/client/connection.rsandsrc/ssh/client/file_transfer.rs: both built their anyhow chain backwards viaanyhow!(friendly_message).context(e). Since.context()'s argument becomes the outer layer, the raw SSH error rendered first and the friendly message rendered underneath it as a near-duplicate cause. Both now buildanyhow::Error::new(e).context(friendly_message)so the friendly message is outermost andeis the cause.{:#}joins layers with": ", a context message that restates its own cause prints the same wording twice on one line. The direct-connect path returned a context message for every error variant, soPasswordWrongrendered asPassword authentication failed.: Password authentication failed, andSshErrorinterpolated the underlying russh error that the variant's own Display already carried.connect_error_messagenow returnsOption<String>and yieldsNonefor variants whose Display already says everything, so those get no extra layer at all; the messages it does return add remediation guidance without echoing the cause.KeyInvalid's context in both the direct-connect and file-transfer paths no longer re-interpolates the inner key error, which the variant's own Display already includes..:in the middle of a line.SshErroris now shown directly rather than behind a wrapping layer, its message text became user-visible, so theoccuredmisspelling and theSshcapitalization are fixed inSshErrorandSftpErrorinsrc/ssh/tokio_client/error.rs.Test plan
cargo check --lib --testscargo test --lib(1275 passed, 0 failed)cargo test --lib commands::error_format(3 passed)cargo test --lib commands::interactive(18 passed)cargo test --lib jump::chain(17 passed, including 2 new)cargo test --lib ssh::client::connection(8 passed, including 4 new)cargo test --lib ssh::client::file_transfer(4 passed, new module)cargo clippy --lib --tests -- -D warnings(clean)cargo fmt --all -- --check(clean)src/executor/parallel.rshas no diff (itsformat!("{e:#}")behavior is unchanged, per the issue's acceptance criteria)src/jump/chain.rsandsrc/jump/chain/tunnel.rscontext wrapping to confirm each now surfaces its own distinct innermost cause through the helperNote on an unrelated pre-existing flake:
commands::interactive::utils::tests::test_expand_path_with_tildefails nondeterministically under the full parallelcargo test --libbecause several tests callEnvGuard::set("HOME", ...), mutating process-global env while that test readsdirs::home_dir()twice. It passes in isolation onmainand on this branch, and is untouched by this PR. No test added here mutatesHOME.Closes #238