Skip to content

feat(ssh): forward a tunnel to a Unix socket on the remote server (#1871)#1876

Merged
datlechin merged 1 commit into
mainfrom
feat/1871-ssh-unix-socket-forward
Jul 14, 2026
Merged

feat(ssh): forward a tunnel to a Unix socket on the remote server (#1871)#1876
datlechin merged 1 commit into
mainfrom
feat/1871-ssh-unix-socket-forward

Conversation

@datlechin

Copy link
Copy Markdown
Member

Closes #1871.

What

An SSH tunnel can now forward to a Unix domain socket on the remote server, for databases that only listen on a socket. This is what the reporter was doing by hand:

ssh -L 5434:/var/run/postgresql/.s.PGSQL.5432 database-server.com

Fill in Socket Path on the General pane (shown only when SSH is on) and TablePro forwards to that socket instead of Host and Port, which dim while it is set. Leave it empty and nothing changes.

Scope note: jump hosts already worked

The issue asks for two things, and one already ships. LibSSH2TunnelFactory.buildAuthenticatedChain already chains N hops, verifies each hop's host key before authenticating, and follows ProxyJump from ~/.ssh/config. The reporter's host1 -> host2 chain composes with a socket destination for free: a session's transport being a relayed socketpair is orthogonal to which channel type it opens. Nothing was needed there.

How

The forward destination was a (remoteHost: String, remotePort: Int) pair threaded through four layers into one hard-coded libssh2_channel_direct_tcpip_ex call. That is the root cause: the destination was two scalars where it needs two legal shapes.

It is now a typed SSHForwardDestination (.tcp / .unixSocket), resolved once at the DatabaseManager+SSH boundary, so the libssh2 call site switches exhaustively instead of four files sniffing strings. Both libssh2 entry points sit behind one LibSSH2ForwardChannel.open.

libssh2_channel_direct_streamlocal_ex returns the same LIBSSH2_CHANNEL* with the same EAGAIN protocol as direct_tcpip_ex, so the retry loop and the whole SSHChannelRelay byte pump are reused unchanged. libssh2 is already pinned at 1.11.1 and the symbol is already exported by the shipped static libs, so no library rebuild was needed.

Jump-hop routing (LibSSH2TunnelFactory.openChannel) stays TCP and is untouched.

The Postgres TLS trap

Over a TCP local forward, libpq's raddr is AF_INET, so it sends an SSLRequest. The server decides from its own listening socket, which is AF_UNIX, answers "no" unconditionally, and never consults pg_hba. So sslmode=require and the verify modes would hard-fail against a socket destination.

tunneledConnection now drops TLS for socket forwards. That costs nothing: the whole path already runs inside the SSH transport. A test pins this, and a matching test pins that TCP forwards still keep encryption on.

peer auth works, which is the reporter's actual goal: the connection to the socket is made by sshd's post-auth child running as the SSH login user.

Failing fast

sshd gates socket forwarding behind AllowStreamLocalForwarding, separately from AllowTcpForwarding. A refusal is otherwise invisible until the driver dials the local port, where it surfaces as an unexplained dropped connection. The tunnel now probes a socket destination once at connect time and reports SSHTunnelError.socketForwardingRefused, naming the path and the sshd setting.

The probe is deliberately not applied to TCP destinations: probing them would open and immediately drop a real database connection on every connect, which logs noise and bumps connection counters for every existing SSH user, for a feature they did not ask for.

Driver impact

None. The driver always gets 127.0.0.1:<localPort>, so this works for PostgreSQL, MySQL, and Redis with no plugin changes.

Tests

swiftlint lint --strict is clean. 26 tests pass, including the pre-existing tunnel tests as regression guards.

  • destination resolution, socket path precedence, Codable round trip
  • TLS dropped for a socket forward, kept for a TCP forward
  • the socket path is stripped before the connection reaches the driver
  • socket path validation (relative, trailing slash, empty)
  • the new error names the socket and the sshd setting

Not covered: the libssh2_channel_direct_streamlocal_ex call against a live sshd. The SSH stack has never had live-server integration coverage (the same gap exists for direct_tcpip_ex), so this needs a manual pass: key auth to a socket, a jump-host chain to a socket, AllowStreamLocalForwarding no, and peer auth.

iOS needs no change: TableProMobile/SSH/ is a separate, simpler implementation with no jump hosts and no shared code.

@mintlify

mintlify Bot commented Jul 14, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 14, 2026, 3:17 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@datlechin datlechin merged commit 5f80f69 into main Jul 14, 2026
3 checks passed
@datlechin datlechin deleted the feat/1871-ssh-unix-socket-forward branch July 14, 2026 15:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ddc1004df8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

//

extension DatabaseConnection {
static let sshForwardUnixSocketPathKey = "sshForwardUnixSocketPath"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve socket paths in copied connection URLs

When a connection uses this new key, the copied +ssh connection URL does not round-trip it: I checked ConnectionURLFormatter.formatSSH, which still serializes only connection.host/connection.port for the database endpoint, and ConnectionURLParser/applyParsed has no socket-path field to restore. If a user copies or imports a socket-forwarded connection URL, the imported connection silently falls back to the stale Host/Port and no longer forwards to the Unix socket, so the socket path needs to be included in the URL query and applied on import.

Useful? React with 👍 / 👎.

@mintlify

mintlify Bot commented Jul 14, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟡 Building Jul 14, 2026, 3:16 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

socket connection

1 participant