Skip to content

fix(ssh): connect ProxyJump hosts through their bastion - #75

Merged
timhartmann7 merged 6 commits into
timhartmann7:mainfrom
Kvesten:fix/proxy-jump
Aug 18, 2026
Merged

fix(ssh): connect ProxyJump hosts through their bastion#75
timhartmann7 merged 6 commits into
timhartmann7:mainfrom
Kvesten:fix/proxy-jump

Conversation

@Kvesten

@Kvesten Kvesten commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

ProxyJump is parsed from ~/.ssh/config into Host.proxy_jump, but nothing downstream acts on it:

  • the terminal refuses such a host outright ("ProxyJump is not yet supported in the terminal", pty.rs);
  • metrics, SFTP, snippets and key setup go through connect_and_auth, which opens a TCP connection straight to hostname:port — past the bastion that is the only route to an internal host.

So a config like this cannot be used at all: the terminal tab errors out and the dashboard card sits on a failed connection.

Host public-proxy
    HostName proxy.example.com
    User ops

Host internal
    HostName 192.168.100.50
    User admin
    ProxyJump public-proxy

The 1.1.0 changelog records this as a known gap ("not yet supported in the terminal … Tracked as a follow-up").

Solution

connect_and_auth now walks the jump chain the way ssh -J does: each hop is connected and authenticated in turn, and the next one rides a direct-tcpip channel opened on its predecessor. Since every native SSH path already shares this function, the terminal, metrics, SFTP, snippets and key setup are all fixed at once.

  • New ssh::jump module — pure, I/O-free chain resolution, so it unit-tests without a filesystem. A jump alias is looked up in the merged host list (hosts.toml + ~/.ssh/config) and inherits that entry's HostName, User, Port and IdentityFile; an alias matching no entry is used as a literal hostname. Multi-hop values (first,second), inline user@host:port overrides, IPv6 literals, bastions behind bastions and the ProxyJump none opt-out all follow OpenSSH. Cycles and chains past ten hops are reported instead of looping.
  • New SshConnection — owns the bastion handles alongside the target's, because dropping one would tear down every tunnel above it. It derefs to the target Handle, so all existing callers open channels unchanged.
  • Host keys and auth per hop — each hop is verified against known_hosts under its own name and authenticates with the usual agent → identity file → default keys → password order.
  • TUI edit form no longer drops ProxyJump — the form has no field for it, so saving an imported host used to lose the bastion. The GUI already preserved it (commands/hosts.rs).

No new dependencies; the tunnel uses russh's own channel_open_direct_tcpip + connect_stream.

Test plan

  • cargo test — green. 20 new unit tests in ssh::jump cover spec parsing (bare alias, user@host:port, multi-hop, IPv6 bracketed and bare, unusable hops), resolution against known hosts, inline overrides, the HostName-less fallback, nested bastions, none, cycles (by alias and by address) and the hop cap. One test drives the whole path the reported config takes, from parse_ssh_config through resolve_chain.
  • cargo clippy -- -D warnings and cargo fmt --check — clean, for the workspace and for -p omnyssh-gui.
  • Manually verified against a real bastion: an internal host with ProxyJump public-proxy connects in the GUI (cargo tauri dev) where it previously failed.
  • Hosts without ProxyJump take the same code path as before — the host list is not even loaded for them.

Notes for review

  • Resolution reads the merged host list at connect time via config::load_all_hosts (off the async worker, and only when proxy_jump is set). Happy to thread the list in from the app layer instead if you'd rather the engine not read config here.
  • The CHANGELOG entry sits under a new ## Unreleased heading — say the word if you'd prefer it folded into a version section.
  • The TUI still has no ProxyJump field in the add/edit form, so a bastion can only arrive via ~/.ssh/config import. That felt like a separate feature; glad to add it here if you want it in the same PR.

Kvesten and others added 6 commits August 12, 2026 00:50
The ssh_config parser read ProxyJump into `Host.proxy_jump`, but nothing
downstream used it: the terminal refused to open such a host outright,
while metrics, SFTP, snippets and key setup dialled the target address
directly — past the bastion that was the only route to it.

`connect_and_auth` now walks the jump chain the way `ssh -J` does. Each
hop is connected and authenticated in turn, and the next one rides a
`direct-tcpip` channel opened on its predecessor. The new `SshConnection`
owns the bastion handles alongside the target's, since dropping one would
tear down every tunnel above it; it derefs to the target handle, so
callers open channels exactly as before.

Chain resolution lives in the new `ssh::jump` module — pure and I/O-free,
so it is unit-tested without a filesystem. A jump alias is looked up in
the merged host list and inherits that entry's HostName, User, Port and
IdentityFile; an alias matching no entry is used as a literal hostname.
Multi-hop values, inline `user@host:port` overrides, IPv6 literals,
bastions behind bastions and the `ProxyJump none` opt-out all follow
OpenSSH. Cycles and chains past ten hops are reported rather than
looped over.

Also stops the TUI edit form from dropping ProxyJump: it has no field for
it, so saving an imported host used to lose the bastion. The GUI already
preserved it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@timhartmann7
timhartmann7 merged commit 4dce023 into timhartmann7:main Aug 18, 2026
5 checks passed
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