Table of Contents

Port forwarding and tunnels

An SSH connection can carry other connections. The server opens them on your behalf (to reach services only it can reach), or accepts them and hands them to you (to give the server a way back). SshSharp does what ssh -L, -R and -D do, and also returns a tunnel as a plain Stream or plugs it into HttpClient.

When to use

You want to Use
Reach a service behind the server from a program that connects to a port (a database client, a browser) Local forwarding, ssh -L
Reach it from your own code, without opening a port A tunnel stream or HttpClient
Let programs reach many destinations through the server Dynamic forwarding, ssh -D
Let the server reach a service on your side Remote forwarding, ssh -R

How each kind flows

Every forwarded connection is its own channel inside the one encrypted SSH connection. What differs is who listens and who makes the final TCP connection:

flowchart LR
    subgraph here["This machine"]
        lApp["Database client"] -- "127.0.0.1:15432" --> lFwd["Local forwarder (-L)"]
        dApp["Browser, curl"] -- "SOCKS to 127.0.0.1:1080" --> dFwd["SOCKS proxy (-D)"]
        rFwd["Remote forwarder (-R)"] -- "TCP" --> rSvc["127.0.0.1:3000"]
    end
    subgraph server["The server and its network"]
        lSrv["sshd"] -- "TCP" --> lDb[("db.internal:5432")]
        dSrv["sshd"] -- "TCP" --> dAny["any destination"]
        rProg["Program on the server"] -- "localhost:8080" --> rSrv["sshd listener"]
    end
    lFwd == "direct-tcpip channel" ==> lSrv
    dFwd == "direct-tcpip channel" ==> dSrv
    rSrv == "forwarded-tcpip channel" ==> rFwd

Local forwarding (ssh -L)

// ssh -L 15432:db.internal:5432: connections to 127.0.0.1:15432 here reach db.internal:5432 from the server.
await using var forwarder = await client.ForwardLocalPortAsync(
    new IPEndPoint(IPAddress.Loopback, 15432), "db.internal", 5432, cancellationToken);
Console.WriteLine($"Listening on {forwarder.LocalEndPoint}");

await Task.Delay(Timeout.Infinite, cancellationToken);  // forwarding runs until the forwarder is disposed

Port 0 lets the system pick a free port, which avoids collisions when several jobs run at once:

// Port 0: the system picks a free port, so parallel jobs never collide.
await using var forwarder = await client.ForwardLocalPortAsync(
    new IPEndPoint(IPAddress.Loopback, 0), "metrics.internal", 9090, cancellationToken);
var url = $"http://127.0.0.1:{forwarder.LocalEndPoint.Port}/metrics";

The server resolves db.internal, so names that only the server's network knows work.

Tunnels without a local port

A tunnel is one forwarded connection, returned as a Stream. Nothing listens locally, so no other process on this machine can use it:

// No local port at all: a Stream to the far end, for code that takes a Stream.
await using var redis = await client.OpenTcpTunnelAsync("cache.internal", 6379, cancellationToken);
await redis.WriteAsync("PING\r\n"u8.ToArray(), cancellationToken);
var reply = new byte[64];
var read = await redis.ReadAsync(reply, cancellationToken);

// A Unix socket on the server: the Docker API, for example.
await using var docker = await client.OpenUnixSocketTunnelAsync("/var/run/docker.sock", cancellationToken);

HttpClient through the server

CreateHttpConnectCallback() opens each of HttpClient's connections as a tunnel, so web services that only the server can reach work with the usual HTTP stack. TLS still runs end to end, from this machine to the service:

// HttpClient whose connections are opened by the server: internal web services, no local listener.
using var http = new HttpClient(new SocketsHttpHandler
{
    ConnectCallback = client.CreateHttpConnectCallback(),
    UseProxy = false,
});
var health = await http.GetStringAsync(new Uri("http://grafana.internal:3000/api/health"), cancellationToken);

The same handler works with IHttpClientFactory (ConfigurePrimaryHttpMessageHandler).

Dynamic forwarding: a SOCKS proxy (ssh -D)

// ssh -D 1080: a SOCKS 4/4a/5 proxy here; the server makes each connection and resolves the names.
await using var socks = await client.ForwardDynamicPortAsync(new IPEndPoint(IPAddress.Loopback, 1080), cancellationToken);

// Any SOCKS-aware client can use it, HttpClient included.
using var http = new HttpClient(new SocketsHttpHandler { Proxy = new WebProxy("socks5://127.0.0.1:1080") });
var page = await http.GetStringAsync(new Uri("http://intranet.internal/"), cancellationToken);

Remote forwarding (ssh -R)

The direction is reversed: the server listens, and each connection made to it there is relayed to a host and port reached from this machine.

// ssh -R 8080:127.0.0.1:3000: the server listens on its loopback port 8080, and each connection reaches port 3000 here.
await using var remote = await client.ForwardRemotePortAsync("localhost", 8080, "127.0.0.1", 3000, cancellationToken);
Console.WriteLine($"The server forwards its port {remote.RemotePort} to {remote.LocalHost}:{remote.LocalPort}");

Remote dynamic forwarding (ssh -R 1080, OpenSSH 7.6+) makes this machine the SOCKS proxy for programs on the server. Restrict what they may reach:

// ssh -R 1080: the server listens, and this machine is the SOCKS proxy. Allow only what the server's side should reach.
await using var socks = await client.ForwardRemoteDynamicPortAsync("localhost", 1080,
    allowDestination: (host, port) => host == "mirror.internal" && port == 443, cancellationToken);

Notes and pitfalls

  • Bind to loopback. A local forwarder or SOCKS proxy has no authentication: anyone who can reach its port can use the server's network. Use IPAddress.Loopback unless other machines should use it.
  • Server policy. sshd's AllowTcpForwarding no refuses tunnels: each forwarded connection is closed, and OpenTcpTunnelAsync throws SshException with ChannelClosed. A bind address other than loopback for remote forwarding needs GatewayPorts.
  • Lifetime. Forwarding runs until the forwarder is disposed, the client is disposed, or the connection ends. Watch client.ConnectionClosed if you need to know (see Keep-alive and reconnecting).
  • Remote destinations come from the server's side. With remote dynamic forwarding, every destination is allowed when allowDestination is null, as with ssh. Pass a filter unless you trust everyone on the server.
  • Limits. The server can open at most SshClientOptions.MaxForwardedConnections (1,000) forwarded connections at a time.
  • From ssh_config. LocalForward, RemoteForward and DynamicForward start with the connection when SshConfigConnectOptions.StartForwardings is true.

See also