Skip to content

Bound every guest transport against an unresponsive guest - #451

Open
DarkaMaul wants to merge 3 commits into
mainfrom
ssh/bound-unresponsive-commands
Open

Bound every guest transport against an unresponsive guest#451
DarkaMaul wants to merge 3 commits into
mainfrom
ssh/bound-unresponsive-commands

Conversation

@DarkaMaul

@DarkaMaul DarkaMaul commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What this does

Guest transports ran with no deadline. When a guest stops answering — a paused
VM, a wedged sshd, a lost TAP device — ssh, scp, and rsync block on a
dead socket indefinitely, so the lifecycle command that invoked them hangs
until the user interrupts it. coop exec and coop push/pull hang the same
way.

ssh, scp, and rsync's -e command now derive from one list,
SshTarget::transport_opts, which carries the bounds:

  • BatchMode=yes — a key the guest rejects fails instead of stalling on a
    password prompt where no interactive user exists (coop up in CI).
  • ConnectTimeout=10 — bounds the TCP connect and banner exchange.
  • ServerAliveInterval=30 / ServerAliveCountMax=3 — bounds the established
    session, so a guest whose sshd stops answering fails after ~90s.

The three transports previously kept three hand-copied option lists, which is
how the last hardening change reached only one of them. Only the port flag now
differs (-p vs -P).

Why one list rather than a per-caller bound

OpenSSH honors the first value it obtains for a repeated -o:

$ ssh -G -o ServerAliveInterval=30 -o ServerAliveInterval=5 localhost | grep alive
serveraliveinterval 30

So a bound cannot be tightened by appending, only by owning the base list.
Three callsites used to append ServerAliveInterval=30 by hand —
ssh.rs:keepalive_opts (interactive coop shell), port_forward.rs
(background forwards), and proxy.rs (the reverse tunnel). All three wanted
the same value the base list now sets, so this removes them: same effective
bound, three fewer places to keep in step. ServerAliveCountMax was
previously left at OpenSSH's default of 3 in the two tunnels and is now
explicit.

The consequence worth reviewing is that no caller can pick a shorter bound.
That is the point — it is also why the earlier revisions of this branch needed
two option tiers and four accessors to keep the base list probe-free.

Trade-off

Boot readiness probes (SshTarget::wait_until_ready, the Lima auth loop in
lima.rs) share the ~90s ceiling rather than getting a tighter one. Both loops
check their deadline after the probe, so a sshd that accepts and then goes
silent overshoots the boot timeout by up to that much. Before this branch that
probe hung indefinitely, and ConnectTimeout=10 still covers the ordinary boot
case of nothing listening or no banner.

ConnectTimeout does not bound key exchange or authentication — only connect
and banner. ServerAlive* covers everything after the client loop starts.

Testing

  • Verified: cargo fmt -- --check; cargo clippy --all-targets --all-features -- -D warnings; cargo test (1112 passed).
  • Verified: the -o precedence claim above against local OpenSSH 10.3p1.
  • Not run: the integration suite on either backend. This changes the SSH
    options behind every guest command and transfer, so it wants
    ./tests/run-integration.sh on Lima and on a Firecracker host before merge.

Two tests cover the change: one asserts all four bounds reach ssh, scp, and
rsync's -e and that each carries exactly one ServerAlive* pair (a second
pair would be the silently-shadowed failure mode); one asserts ssh and scp
options differ only in the port flag.

Follow-up, not addressed here

On an unresponsive guest, the bootstrap checks still report "CLI is not
installed — rebuild the image" rather than naming the real cause, because
exec_ok folds "exited non-zero" together with "never answered". Distinguishing
them is an error-message change rather than a hang fix, and lands separately.

@DarkaMaul
DarkaMaul marked this pull request as ready for review September 4, 2026 11:42
DarkaMaul and others added 2 commits September 4, 2026 14:08
The previous commit bounded the `SshTarget`/`SshSession` methods and
stopped there, leaving the other one-shot transports with the same
unbounded hang — and making `coop push`/`pull` worse, since the now-bounded
`which rsync` probe returned `false` for an unresponsive guest and routed
to a tar pipe that hung with no deadline at all.

- `scp_opts` and `rsync_ssh_cmd` were hand-copied option lists that missed
  `BatchMode` and `ConnectTimeout`. All three transports now derive from
  one `transport_opts`, differing only in the port flag.
- `coop exec`, `coop shell <cmd>`, the tar pipes, and `check_guest_dirty`
  go through `SshTarget::command_args`, the single argv seam.
- Split the keepalive by caller: arbitrary-length guest work (installs,
  clones, `post_start`) gets the interactive-grade 30/3 (~90s) rather than
  a 10s bound an unscheduled sshd can trip mid-install; short retried
  probes keep 5/2.
- `SshTarget::probe` returns a third state, so a paused VM no longer
  reports as a missing CLI or an image without Secret Service support and
  tells the user to rebuild. ssh's own stderr ("Timeout, server not
  responding") now survives into the error instead of `/dev/null`.
- Docstrings: `ConnectTimeout` bounds the TCP connect and banner exchange,
  not the KEX/userauth that follow; `BatchMode` guards public-key auth
  fallback, not key exchange.

Not run: `./tests/run-integration.sh` on either backend, and no test
suspends a VM to observe a real timeout — the unit tests pin the argv, not
the runtime behavior. A suspend-and-assert integration phase is backend-
specific and slow; the bounds are still unexercised outside production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous two commits bounded the guest transports through two
keepalive tiers (~90s for guest work, ~10s for probes) plus four
`*_opts`/`*_args` accessors and their `SshSession` mirrors. Two tiers
forced the base options to stay `ServerAlive*`-free, since OpenSSH
honors the first value of a repeated `-o`, and that in turn needed
tests pinning each call site to the right accessor.

Every existing consumer already wanted the same 30s/3 bound —
`ssh.rs` interactive sessions, the `proxy.rs` reverse tunnel, and the
`port_forward.rs` forwarder each appended it by hand. Put that pair in
the shared `transport_opts` list instead and the tiers collapse: the
three appenders become redundant at identical values, `ssh`, `scp`,
and rsync's `-e` all inherit the bound, and no caller can lose it,
so the accessors and their pinning tests are unnecessary.

Also drops the `GuestProbe` taxonomy that told an unreachable guest
apart from a missing feature. That is an error-message change rather
than a hang fix, and it lands separately.

Boot probes now share the ~90s ceiling rather than getting ~10s. Both
probe loops check their deadline after the probe, so a wedged sshd
overshoots the boot timeout by up to that much — still bounded, where
before this branch it hung indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DarkaMaul DarkaMaul changed the title Bound SSH commands against an unresponsive guest Bound every guest transport against an unresponsive guest Sep 4, 2026

@hbrodin hbrodin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with one nonblocking inline suggestion to remove historical test commentary. No functional or security findings survived review. All eight review lenses were covered. Verified SSH option precedence and shortened banner/key-exchange timeouts, and confirmed the new tests fail when each transport bound is removed. CI is green; full Rust tests were not rerun locally, and Lima/Firecracker integration remain unverified.

Comment thread src/backend.rs
Comment on lines +3328 to +3329
// Two hand-copied lists is how the last hardening change reached only
// one of them; they now derive from `transport_opts`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove these two comment lines: “the last hardening change” records development history, while the test name and assertions already explain the current contract. Nonblocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants