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.Passwordand the keyboard-interactive handler are offered to the final host, never to a jump host. Hops log in with keys or the agent, assshasks 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 usingorder 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; setSshConfigConnectOptions.AllowProxyCommand = falsewhen the configuration file is not under your control. - Forwarding is the bastion's call. sshd's
AllowTcpForwarding noon the bastion refuses the tunnel, and the connection fails withConnectFailed("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 firstProxyJumphost.