A database through a jump host
A reporting job needs a PostgreSQL database that sits in a private network, reachable only from a bastion host. Instead of a VPN,
the job opens an SSH connection to the bastion and forwards a local port to the database, as ssh -L does. Any ADO.NET provider,
ORM or tool then connects to 127.0.0.1 on that port.
flowchart LR
subgraph job["Reporting job (this machine)"]
code["Npgsql / EF Core / Dapper"] -- "127.0.0.1:{random port}" --> fwd["SshLocalPortForwarder"]
end
fwd == "SSH connection, encrypted<br/>(direct-tcpip channel per DB connection)" ==> bastion["bastion.example.com"]
subgraph private["Private network"]
bastion -- "TCP" --> db[("db.internal:5432")]
end
The approach
- One SSH connection, many database connections. Each connection the provider opens (a connection pool has several) becomes its own channel inside the SSH connection.
- Loopback and port 0. The forwarder listens on
127.0.0.1only, so no other machine can use it, on a free port the system picks, so concurrent jobs do not collide. - The database still authenticates. The tunnel replaces network reachability, not the database's own login and TLS.
The code
/// <summary>An SSH connection to the bastion and a local port forwarded to the database, for as long as it is not disposed.</summary>
public sealed class DatabaseTunnelHandle : IAsyncDisposable
{
private readonly SshClient _bastion;
private readonly SshLocalPortForwarder _forwarder;
private DatabaseTunnelHandle(SshClient bastion, SshLocalPortForwarder forwarder) => (_bastion, _forwarder) = (bastion, forwarder);
/// <summary>The local port the database is reachable on.</summary>
public int Port => _forwarder.LocalEndPoint.Port;
/// <summary>Cancelled when the SSH connection drops: the tunnel is gone, and with it the database connections.</summary>
public CancellationToken Closed => _bastion.ConnectionClosed;
public static async Task<DatabaseTunnelHandle> OpenAsync(string bastionAlias, string databaseHost, int databasePort,
CancellationToken cancellationToken)
{
var bastion = await SshClient.ConnectAsync(bastionAlias, cancellationToken: cancellationToken);
try
{
// Loopback only, port 0: a free port, reachable from this machine and nowhere else.
var forwarder = await bastion.ForwardLocalPortAsync(new IPEndPoint(IPAddress.Loopback, 0), databaseHost, databasePort,
cancellationToken);
return new DatabaseTunnelHandle(bastion, forwarder);
}
catch
{
await bastion.DisposeAsync();
throw;
}
}
public async ValueTask DisposeAsync()
{
await _forwarder.DisposeAsync();
await _bastion.DisposeAsync();
}
}
await using var tunnel = await DatabaseTunnelHandle.OpenAsync("bastion", "db.internal", 5432, cancellationToken);
// The database password from the environment or a secret store; the tunnel does not change how the database authenticates.
var connectionString =
$"Host=127.0.0.1;Port={tunnel.Port};Database=reports;Username=report_reader;" +
$"Password={Environment.GetEnvironmentVariable("REPORTS_DB_PASSWORD")};SSL Mode=Require";
// Hand the connection string to your ADO.NET provider (Npgsql here), Dapper or EF Core as usual:
// await using var db = new NpgsqlConnection(connectionString);
// await db.OpenAsync(cancellationToken);
The bastion's entry in ~/.ssh/config (or an application's own configuration, see
Use ssh_config and known_hosts):
Host bastion
HostName bastion.example.com
User report
IdentityFile ~/.ssh/report_ed25519
ServerAliveInterval 30
For production
- Keep-alive.
ServerAliveInterval(orKeepAliveInterval) detects a dead tunnel in bounded time instead of leaving database calls hanging. - A dropped tunnel ends every database connection through it. Watch
tunnel.Closed, and open a new tunnel (and clear the provider's connection pool) when it fires; see Keep-alive and reconnecting. - Bastion policy. The bastion must allow forwarding to the database (sshd's
AllowTcpForwarding, perhapsPermitOpen db.internal:5432to allow only that). - Several databases can share one bastion connection: call
ForwardLocalPortAsynconce per database. - No local port at all. A client library that can take a
Streamcan useOpenTcpTunnelAsyncinstead; most database providers need a host and port, which is what the forwarder gives them.