SSH tunnels without a local listener

The SSH tunnel section of a DevOpsPad connection with SSH host, port, username, authentication method, private key file, known hosts file and host key fingerprint fields.
The SSH tunnel section of a DevOpsPad connection with SSH host, port, username, authentication method, private key file, known hosts file and host key fingerprint fields.

Most database GUIs implement “connect over SSH” the way you would by hand: start ssh -L, or an equivalent library call, bind a local port such as 127.0.0.1:53306, and point the database driver at it. It works, and it leaves a listening socket on your machine that any local process can use for as long as the tunnel is up. DevOpsPad does not open a local port. Every engine’s driver dials through the SSH connection directly, inside the app’s process.

This post covers how that works across seven engines, what we refused to do, and the limits that came with it.

The problem with a local listener

A forwarded port is ambient authority. While it is open, anything running as your user, including a browser tab exploiting a local service or a misbehaving dev server, can connect to 127.0.0.1:53306 and reach production MySQL through your SSH session. The port also outlives the work: tunnels left running after a query finished are common. Then there are the practical problems. You have to pick a free port, track the tunnel’s lifetime separately from the query, and work out what an error means when the tunnel died but the driver only reports “connection refused”.

The design: one session per operation, dialed in-process

The tunnel is built per operation. Running a query, testing a connection or loading a resource list opens an authenticated SSH client connection, and the nested reads inside that operation reuse it. When the operation completes or is cancelled, every channel and the SSH connection itself are closed. Nothing stays up between queries.

The hook into each driver is a dial function, not a port:

  • MySQL uses one permanently registered dialer name, with the active SSH session carried in the connection’s context. There are no per-profile global registrations and no DSNs with credentials in them.
  • ClickHouse and Kubernetes are HTTP clients, so their transports get a custom DialContext and their proxy settings are switched off. Environment proxies and kubeconfig proxy URLs are bypassed for tunneled requests.
  • Kafka uses a transport dial function. That matters because Kafka clients connect to every broker the metadata advertises, not just the bootstrap address, and all of those connections must go through the tunnel too.
  • Redis and MongoDB take a custom dialer in the same way.

Inside the dial function, DevOpsPad opens an SSH direct-tcpip channel to the target address. SSH channels do not support read and write deadlines, which database drivers rely on for timeouts, so the channel is bridged through an in-memory net.Pipe. The driver gets a normal connection with working deadlines, and two goroutines copy bytes between the pipe and the channel. When the session’s context ends, both sides close.

Two consequences follow from dialing on the SSH server’s side:

  1. DNS and routing happen on the SSH server. localhost means the SSH server’s loopback, and internal hostnames resolve with its resolver. Enter the database address exactly as the SSH server would see it.
  2. There is no fallback to a direct connection. If SSH fails, the operation fails. A silent fallback would mean a profile marked “through the bastion” could connect over the open internet without you noticing.

Host keys are verified, never assumed

The SSH client checks the server key against either your ~/.ssh/known_hosts (or a known_hosts file you choose) or a SHA256:… fingerprint you paste in. The fingerprint comparison is constant-time. An unknown or changed key fails with an explanation. There is no “trust on first use” prompt. Clicking “yes” on a fingerprint you have not compared adds friction without adding security, so we left it out.

Authentication supports a private key (including encrypted keys with a passphrase), a password, or the SSH agent at SSH_AUTH_SOCK. Dialing the server is bounded: 5 seconds to connect, 10 seconds for the handshake, 5 seconds to open each forwarded channel.

TLS stays end to end

The tunnel carries bytes; it does not replace TLS. If a MySQL, Redis, ClickHouse or Kafka profile enables TLS, hostname verification still uses the database’s real name. Kubernetes keeps the kubeconfig’s CA, client certificate and TLS server name. Kafka keeps each advertised broker’s own TLS identity rather than pinning one name for all brokers.

Secrets stay separate

The database password, SSH password and SSH key passphrase are three separate OS keychain entries. They are resolved just before an operation and never returned to the UI after saving. Private keys stay in their files. DevOpsPad stores the path, not the key. Switching authentication methods or turning SSH off removes the secrets that are no longer used. See storing secrets in the OS keychain.

Trade-offs and limits

  • Single hop only. There is no ProxyJump, no ProxyCommand and no reading of ~/.ssh/config aliases. The target must be reachable from the one SSH server you configure.
  • No keyboard-interactive authentication, so OTP-style prompts are not supported.
  • SSH agent via Unix socket. If the desktop app cannot see SSH_AUTH_SOCK, which can happen when it is launched from Finder, use a key file or password.
  • A new session per operation costs a handshake each time. On high-latency links that is noticeable. We chose short-lived sessions over a pool that holds a production path open.
  • Kafka’s advertised listeners must be reachable from the SSH server, which surprises people whose brokers advertise addresses only the brokers themselves can resolve.

The setup steps are in the SSH tunnel overview, with host key verification and SSH agent authentication covered separately. When something does not connect, start with SSH troubleshooting. The SSH tunnels feature page summarises which engines support it (all seven).