Skip to content

Add ProxyCommand support and make ProxyJump actually work#16

Open
ArielTM wants to merge 2 commits into
macnev2013:mainfrom
ArielTM:feat/proxycommand-proxyjump
Open

Add ProxyCommand support and make ProxyJump actually work#16
ArielTM wants to merge 2 commits into
macnev2013:mainfrom
ArielTM:feat/proxycommand-proxyjump

Conversation

@ArielTM
Copy link
Copy Markdown

@ArielTM ArielTM commented May 11, 2026

Summary

The README already advertises "proxy jump (bastion host)" support, but it turns out manager.rs never reads the proxy_jump field — the connection always goes direct TCP. So the field has been stored, imported from ~/.ssh/config, and editable in the UI, but ignored at connect time. This PR wires it up for real and adds ProxyCommand alongside it.

Highlights:

  • New ProxyCommand field, end-to-end (struct → DB migration → form → import → connect).
  • Single-hop ProxyJump now actually does something — native via russh's channel_open_direct_tcpip, no shell-out to system ssh.
  • Both PTY (connect) and SFTP (connect_no_pty) paths go through one shared open_authenticated_handle helper, so SFTP gets proxy support automatically.
  • ssh_config importer now picks up ProxyCommand directives (it was already grabbing ProxyJump).
  • README tightened so it stops overstating what's shipped.

How it works

Transport selection (matches OpenSSH precedence: ProxyCommand wins if both are set):

  1. ProxyCommand: tokens %h/%p/%r/%% are expanded, then the string is handed to sh -c (Unix) / cmd /C (Windows) via russh_config::Stream::proxy_command. Wrapping in a shell rather than calling Stream's built-in split(' ') keeps quotes, pipes, env vars and ~ expansion working — matters for anything copy-pasted from ~/.ssh/config.
  2. ProxyJump: parse [user@]host[:port], recursively authenticate to the bastion (reusing the target's auth method), open a direct-tcpip channel to (target_host, target_port), then feed channel.into_stream() to russh::client::connect_stream for the target session. The bastion handle is kept alive in AuthenticatedHandle.jump_chain — dropping it would tear down the transport.
  3. Neither set: plain TCP, same code path as before.

Multi-hop ProxyJump (a,b,c) is rejected with an error message pointing at ProxyCommand. ~95% of bastion setups are single-hop, and chains have an escape hatch via ProxyCommand ssh -J a,b,c -W %h:%p.

UI

HostEditModal gains a "Proxy / Jump host" section with two inputs:

  • ProxyJump[user@]host[:port]. Hint: "Single-hop only. Reuses this host's credentials to authenticate to the bastion."
  • ProxyCommand — raw OpenSSH-style command. Hint: "Runs as a local subprocess. Tokens: %h host, %p port, %r remote user. If both proxy fields are set, ProxyCommand wins."

Why russh-config instead of building it ourselves

russh-config 0.48 provides Stream::tcp_connect / Stream::proxy_command returning an AsyncRead + AsyncWrite you can feed straight to russh::client::connect_stream. No need to hand-roll subprocess piping. Note: russh-config itself doesn't handle ProxyJump — its Config::stream() only does ProxyCommand. The jump logic in this PR uses raw russh channels.

The crate is added as a separate dep at "0.48". It's standalone (no transitive dep on russh), so the version skew with this project's russh = "0.46" doesn't matter.

Why native ProxyJump and not shell out to ssh -J

Shelling out to system ssh would have been ~2 lines (just feed it through ProxyCommand), but:

  • Windows users without OpenSSH installed would silently lose ProxyJump support.
  • known_hosts trust and ssh-agent keys would split between anyscp's own store and ~/.ssh/, leading to "works in Terminal but not in anyscp" support tickets.
  • The app already owns the full SSH stack (PPK conversion, keychain, etc.) — going native here is consistent.

Files

File Change
src-tauri/Cargo.toml + russh-config = "0.48"
src-tauri/src/types/session.rs HostConfig gains proxy_jump, proxy_command
src-tauri/src/db/mod.rs Migration 11→12 adds proxy_command column; SavedHost field + every SQL site
src-tauri/src/ssh/manager.rs Shared open_authenticated_handle; transport builder; token expansion; jump-chain anchoring
src-tauri/src/ssh/session.rs SshSession carries the optional jump_chain anchor
src-tauri/src/ssh/commands.rs, portforward/commands.rs Forward proxy fields when building HostConfig
src-tauri/src/import/mod.rs Enable ALLOW_UNSUPPORTED_FIELDS; extract ProxyCommand from unsupported_fields["proxycommand"]
src-tauri/src/import/commands.rs Pass proxy_command through to SavedHost
src-tauri/tests/proxy_command_import.rs Integration test pinning the ssh2-config assumption
src/types/ssh.ts TS types updated
src/components/dashboard/HostEditModal.tsx New section + form fields + state wiring
src/components/dashboard/ImportSshConfigModal.tsx Forward proxy_command on import
README.md Updated to match shipped behaviour
pnpm-workspace.yaml Approve esbuild build script + disable pnpm 11's pre-flight deps check (otherwise pnpm tauri dev errors on a fresh checkout)

Test plan

  • cargo check clean
  • cargo test --lib — 7 new unit tests for expand_proxy_command_tokens and parse_proxy_jump, all pass
  • cargo test --test proxy_command_import — verifies ssh2-config behaviour our extractor depends on
  • tsc --noEmit clean
  • Manual: DB migration 11→12 ran against an existing anyscp.db without losing data
  • Manual: imported ~/.ssh/config with ProxyCommand /path/to/script %h %p — value appears in the DB and the host edit modal
  • Manual: connect through ProxyCommand against a working AWS SSM ssh-proxy script

Things that are intentionally out of scope:

  • Multi-hop ProxyJump (errors with a clear pointer to ProxyCommand).
  • Separate credentials for the jump host (reuses target's auth).
  • "Test connection" button (the existing Save-and-connect flow is the de-facto test).

ArielTM added 2 commits May 11, 2026 15:31
Adds a new ProxyCommand field end-to-end and makes the existing
ProxyJump field actually do something at connect time (previously
stored and shown in the UI but never consulted by manager.rs).

Backend
- types/session.rs: HostConfig gains proxy_jump, proxy_command.
- db: migration 11->12 adds proxy_command column; SavedHost field
  and every SQL site (insert/upsert/select for list_hosts, get_host,
  test fixture) updated.
- ssh/commands.rs, portforward/commands.rs: forward both fields when
  building HostConfig from SavedHost.
- ssh/manager.rs: extract open_authenticated_handle helper shared by
  connect() and connect_no_pty(). Builds transport per OpenSSH
  precedence: ProxyCommand wins, then ProxyJump, then direct TCP.
  - ProxyCommand: russh_config::Stream::proxy_command run via
    sh -c (Unix) / cmd /C (Windows) so quotes, pipes, env vars and
    tilde all work. Tokens %h/%p/%r/%% expanded at connect time.
  - ProxyJump: native single-hop via channel_open_direct_tcpip on
    the bastion handle, then client::connect_stream over that
    channel. Reuses the target's auth_method for the jump. The
    bastion handle is kept alive in an AuthenticatedHandle.jump_chain
    so dropping it doesn't tear down the transport. Multi-hop chains
    are rejected with a clear pointer to ProxyCommand.
  - parse_proxy_jump + expand_proxy_command_tokens with 7 unit tests.
- ssh/session.rs: SshSession carries the optional jump_chain anchor.
- import: enable ALLOW_UNSUPPORTED_FIELDS in ssh2-config and pull
  ProxyCommand out of params.unsupported_fields["proxycommand"];
  forward through SshConfigEntry/SshConfigImportEntry to SavedHost.

Frontend
- types/ssh.ts: proxy_command added to SavedHost, HostConfig,
  SshConfigEntry.
- HostEditModal: replace the "TODO: Proxy / Jump Host" placeholder
  with a real "Proxy / Jump host" section. Two inputs (ProxyJump,
  ProxyCommand) with inline hints documenting single-hop, token
  substitution, and OpenSSH precedence.
- ImportSshConfigModal: forward proxy_command on import.

Docs
- README: tighten the proxy bullet to "ProxyJump (single-hop bastion
  host), and ProxyCommand" so docs match shipped behaviour.

Dependencies
- Cargo.toml: russh-config = "0.48" (standalone crate; no dep on
  russh itself, so version-skew with russh 0.46 is fine).
- tests/proxy_command_import.rs: integration test that locks in the
  assumption our import path makes about ssh2-config 0.7 — that
  ProxyCommand lands in `params.unsupported_fields["proxycommand"]`
  (lowercased key, tokenised args) when parsed with
  ALLOW_UNSUPPORTED_FIELDS. Verified against a real-world style
  ssh_config snippet during dev.

- pnpm-workspace.yaml: pnpm 11.x treats the IGNORED_BUILDS warning
  as a hard error inside its internal `runDepsStatusCheck` pre-flight,
  which kills `pnpm tauri dev` before vite/cargo even start. Approve
  esbuild's build script via `onlyBuiltDependencies` and disable the
  pre-flight check via `verifyDepsBeforeRun: false` so `tauri dev`
  works out-of-the-box on a fresh checkout.
@macnev2013 macnev2013 added the enhancement New feature or request label May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants