Table of Contents

Jump hosts (ProxyJump)

Servers inside a private network are often reachable only through a bastion (jump host). SshSharp follows ssh_config's ProxyJump and ProxyCommand like ssh does, and the same thing can be done by hand: open a tunnel through one connection and run a second SSH connection over it.

When to use

  • The target is not reachable directly, only through one or more bastions.
  • You want each hop authenticated and verified on its own, with the bastion unable to read the traffic to the target.

How a ProxyJump chain connects

With ProxyJump bastion (or ssh -J bastion app1), the client connects and logs in to the bastion, asks it for a TCP connection to the target's SSH port (a direct-tcpip channel), and runs a complete second SSH connection over that channel. The bastion relays bytes it cannot decrypt; the target's host key is verified against known_hosts as for a direct connection.

sequenceDiagram
    autonumber
    participant C as SshSharp
    participant B as bastion.example.com
    participant T as app1.internal
    C->>B: TCP, key exchange, host key check, login (bastion's own config)
    C->>B: open direct-tcpip channel to app1.internal:22
    B->>T: TCP connect
    rect rgba(127, 127, 127, 0.15)
        Note over C,T: second SSH connection, end to end through the channel
        C->>T: key exchange, host key check (known_hosts entry for app1.internal)
        C->>T: login (app1's own config and keys)
    end
    C->>T: commands, SFTP, forwarding ...

ProxyJump a,b repeats this: b is reached through a, and the target through b. Each hop is resolved, verified and authenticated with its own ssh_config settings, and each hop's ConnectTimeout applies to it.

From ssh_config

Host bastion
  HostName bastion.example.com
  User jump

Host app1
  HostName app1.internal
  User deploy
  ProxyJump bastion
// Everything comes from ~/.ssh/config: HostName, User, IdentityFile, ProxyJump, known_hosts, ...
await using var client = await SshClient.ConnectAsync("prod-db", cancellationToken: cancellationToken);

// The other destination forms ssh accepts.
await using var other = await SshClient.ConnectAsync("deploy@build.example.com", cancellationToken: cancellationToken);
await using var third = await SshClient.ConnectAsync("ssh://deploy@build.example.com:2222", cancellationToken: cancellationToken);

The configuration can also ship with your application instead of living in ~/.ssh/config:

// A configuration that ships with the application, instead of the user's ~/.ssh/config.
var config = SshConfig.Parse("""
    Host bastion
      HostName bastion.example.com
      User jump
      IdentityFile ~/.ssh/fleet_ed25519

    Host app-*
      ProxyJump bastion
      User deploy
      IdentityFile ~/.ssh/fleet_ed25519
      UserKnownHostsFile /etc/myapp/known_hosts
      StrictHostKeyChecking yes
      ServerAliveInterval 30
    """);

await using var client = await SshClient.ConnectAsync("app-3", new SshConfigConnectOptions { Config = config }, cancellationToken);

By hand

Without ssh_config, open a tunnel through the bastion and connect over it with SshClient.ConnectAsync(Stream, ...):

// The bastion: an ordinary connection.
await using var bastion = await SshClient.ConnectAsync("bastion.example.com", 22, new SshClientOptions
{
    UserName = "jump",
    PrivateKeys = [key],
    HostKeyVerifier = verifier,
}, cancellationToken);

// A tunnel from the bastion to the target's SSH port, and a second SSH connection over it. The target's host key is
// verified as always; HostKeyAlias names it in known_hosts, since this connection has no host name of its own.
var tunnel = await bastion.OpenTcpTunnelAsync("app1.internal", 22, cancellationToken);
await using var target = await SshClient.ConnectAsync(tunnel, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = [key],
    HostKeyAlias = "app1.internal",
    HostKeyVerifier = verifier,
}, leaveOpen: false, cancellationToken);

var result = await target.RunCommandAsync("hostname", cancellationToken);

The connection over a stream has no host name of its own, so HostKeyAlias names the target for known_hosts lookups (or use a pinned key).

Scan a host behind a bastion

var keys = await SshClient.ScanHostKeysAsync(
    ct => new ValueTask<Stream>(bastion.OpenTcpTunnelAsync("app1.internal", 22, ct)), cancellationToken: cancellationToken);

Notes and pitfalls

  • Passwords go to the destination only. When connecting by alias, SshConfigConnectOptions.Password and the keyboard-interactive handler are offered to the final host, never to a jump host. Hops log in with keys or the agent, as ssh asks for each host's password separately.
  • Disposal. Disposing the destination client tears down the whole chain. When chaining by hand, dispose the target before the bastion (the await using order above does that).
  • ProxyCommand runs a local program and speaks SSH over its standard input and output. It is allowed by default, as in ssh; set SshConfigConnectOptions.AllowProxyCommand = false when the configuration file is not under your control.
  • Forwarding is the bastion's call. sshd's AllowTcpForwarding no on the bastion refuses the tunnel, and the connection fails with ConnectFailed ("the jump host could not open a connection to the next host").
  • A proxy for the first hop. SshConfigConnectOptions.Proxy (HTTP or SOCKS) applies to the first network connection: the destination, or the first ProxyJump host.

See also