Skip to content

feat(ssh): open a SOCKS5 proxy over SSH for drivers with the socks5_proxy capability - #1467

Open
vinimcorrea wants to merge 1 commit into
TabularisDB:mainfrom
vinimcorrea:feat/ssh-socks5-dynamic-forward
Open

vinimcorrea wants to merge 1 commit into
TabularisDB:mainfrom
vinimcorrea:feat/ssh-socks5-dynamic-forward

Conversation

@vinimcorrea

@vinimcorrea vinimcorrea commented Nov 10, 2026 •

Copy link
Copy Markdown

Closes #1449

Plugin side: TabularisDB/tabularis-mongodb-plugin#13

Problem

With SSH enabled, resolve_ssh_params opens one -L forward to the connection's host:port and rewrites those two fields to 127.0.0.1:<local_port>. A driver whose connection names several endpoints can't use that. A mongodb+srv:// URI or a replica-set seedlist makes the driver discover the other nodes and connect to them directly, so it never uses the rewritten address. TabularisDB/tabularis-mongodb-plugin#12 hits this with the MongoDB plugin.

Fix

A driver can now declare a socks5_proxy capability. For those drivers, an SSH connection opens a SOCKS5 proxy over the SSH session (a dynamic forward, like ssh -D) instead of the fixed forward. The host leaves host, port and connection_uri as the user entered them and sends the plugin:

Field Value
socks5_proxy_host always 127.0.0.1
socks5_proxy_port local port of the proxy
socks5_proxy_username / socks5_proxy_password RFC 1929 credentials, when the proxy requires them

The driver connects to each node through the proxy by name, so the bastion resolves the hostnames. Drivers without the capability keep the fixed forward exactly as before.

  • drivers/driver_trait.rs, registry.rs. New socks5_proxy capability (camelCase socks5Proxy also accepted, default false). The registry keeps a synchronous set of drivers that declare it, like the existing local-path set. The built-in drivers set it to false.
  • ssh_tunnel.rs. A tunnel has a ForwardTarget, either Fixed { host, port } (what exists today) or Socks5. The system ssh backend gets -D instead of -L. The russh backend runs a SOCKS5 server and opens one direct-tcpip channel per accepted connection, to the destination the client asked for. Channels open concurrently, so a slow destination doesn't hold up the others, and a client that doesn't finish the SOCKS5 handshake within 10 s is dropped.
  • ssh_socks5.rs (new). The SOCKS5 server side: CONNECT only. A domain name is passed on as a name, not resolved locally.
  • application/tunnels.rs. resolve_ssh_params picks the forward target and the tunnel key from the capability, then makes a single SshTunnel::new_with_target call. A SOCKS5 tunnel is keyed by SSH user, host and port only, so every connection through the same bastion shares it. socks5_proxy_* values sent by a client are dropped before resolving.
  • Dead tunnel recovery. A cached tunnel whose SSH session has ended is reopened instead of reused. This applies to -L forwards too.
  • Keepalives. Both backends send SSH keepalives (every 15 s, 3 misses), so a silently dropped session is detected. The system ssh gets -o ServerAliveInterval=15 -o ServerAliveCountMax=3.
  • Logging. The per-connection SOCKS5 CONNECT <host:port> line is at debug level.
  • Connection test log. For a SOCKS5 tunnel, "SSH tunnel established" shows the proxy listener (SOCKS5 127.0.0.1:<port>) instead of the database port.
  • Docs. The capability, the fields, what a plugin must do with them, and the limits are in PLUGIN_GUIDE.md ("SSH Tunnels through a SOCKS5 Proxy"), manifest.schema.json and the web-ui plugin types.

Points from #1449

Explicit capability. The switch is capabilities.socks5_proxy, not the presence of connection_uri. A driver without it is unaffected. Covered by unit tests on the registry and on resolve_ssh_params.

URI proxy conflict. The host only supplies the endpoint, and the plugin owns the URI, so the check lives in the plugin. PLUGIN_GUIDE.md makes it a requirement for any driver with this capability: fail with a clear error when the user's connection already sets a different proxy. The MongoDB plugin does this (second screenshot).

DNS. Only the destinations the driver connects to go through the proxy, and the bastion resolves those names. Lookups the driver makes before connecting, such as the SRV and TXT queries for mongodb+srv://, still use the local resolver. If those records exist only in the private network's DNS, discovery fails, and the user needs a seedlist URI. This is listed under Limits in PLUGIN_GUIDE.md and in the plugin README. I haven't been able to test a network where the SRV records are private-only.

Local listener authentication. My call, by backend:

  • russh (SSH password, no key file): the proxy requires RFC 1929 credentials, randomly generated per tunnel and passed to the plugin. A connection with wrong or missing credentials is rejected.
  • System ssh -D (key file, ssh-agent or default keys): OpenSSH's dynamic forward can't authenticate clients, so the username and password are absent. While the tunnel is open, any local process can use it to reach anything the bastion can reach. It binds to 127.0.0.1 only. I documented this as a limit instead of forcing key logins through russh, which would change today's key, agent and passphrase handling for every SSH connection.

If you'd rather close the ssh -D gap, two options: only open SOCKS5 through russh, or put an authenticating relay in front of ssh -D. I'm happy to do either here or as a follow-up.

Sharing, cleanup and recovery. Covered in Testing below.

Screenshots

tabularis web from this branch with the MongoDB plugin branch, through an OpenSSH bastion in Docker. The replica set is reachable only as mongo-internal, a name that resolves on the bastion's network and not on the host.

Test Connection through the bastion URI that already sets proxyHost

Both connections went through the same bastion and show the same proxy port: one shared tunnel. The 29 s gap before the database step is a side effect of how I seeded the test connection, not the tunnel. Its URI wasn't saved, so the modal's database-list request fell back to the form default localhost:3306 and held the plugin until it timed out.

Testing

End to end (Docker: linuxserver/openssh-server bastion plus a mongo:7.0 replica set rs0 whose only member is mongo-internal:27017, using the plugin branch):

  • russh path (password): connect, list databases, query. The proxy refuses a client with wrong or no credentials.
  • System ssh -D path (key file): the same queries.
  • Two connections through the same bastion: one tunnel and one proxy port. Disconnecting one leaves the other working.
  • Tunnel drops:
    • Restarting the bastion container: the next connection opened a new tunnel; the dead session was detected in about 3 s.
    • Pausing it (silent drop): detected by keepalives after about 60 to 75 s, then reopened.
  • Blackholed destination: a CONNECT to an unroutable address no longer stalls the tunnel. Another connection through the same proxy completed in 88 ms; before the fix it timed out.
  • URI with proxyHost/proxyPort: the conflict error in the screenshot.

Rust

  • cargo test --lib: 2537 passed, 4 failed, none in code this PR touches.
    • 2 theme tests ("Theme paths must not contain symbolic links") fail the same way on main on my machine.
    • reconcile_active_drivers_keeps_ids_present_in_the_active_list fails when run with the whole suite because export::read_only_tests (feat(connections): add enforced read-only mode per connection #1463, just merged) leaves readonly-export-* manifests registered. It passes on its own.
    • transport::web::tests::rejects_rpc_body_delayed_until_after_session_cleanup is timing-sensitive here and fails intermittently, on its own as well.
  • New tests:
    • the SOCKS5 parsing, replies and authentication
    • the -L/-D arguments and keepalive options
    • tunnel keys, capability selection, dead-tunnel recovery and stripping client-sent socks5_* fields
    • the test-log endpoint
  • cargo check --all-targets passes. cargo clippy reports nothing new; rustfmt --check on the changed files is clean.

Limits

  • No authentication on the ssh -D listener. See "Local listener authentication" above.
  • Driver-side DNS stays local. SRV/TXT records that exist only in the private network can't be resolved.
  • Tunnel lifetime. A SOCKS5 tunnel stays open until Tabularis exits, the proxy settings change, or its SSH session ends. Disconnecting a connection doesn't close it. That is the existing behavior for -L tunnels.
  • Error dialog title. In the conflict case the test dialog's title says "SSH tunnel failed". The web UI picks the title from the error text; the error details say what to change.
  • Platforms. Tested on macOS 26 arm64 only. Not tested on Windows.
  • No desktop screenshots. They come from tabularis web; I also ran the desktop build against the same setup but couldn't capture its window from my terminal.

…roxy capability (TabularisDB#1449)

Drivers that declare the new socks5_proxy capability get a dynamic SOCKS5
forward through the SSH session instead of a fixed -L forward, so they can
reach every node their connection names (SRV seedlists, replica sets). The
host leaves host, port and connection_uri unchanged and passes
socks5_proxy_host/socks5_proxy_port, plus socks5_proxy_username and
socks5_proxy_password when the listener requires them. Other drivers keep
the fixed forward.

- russh backend: in-process SOCKS5 server requiring per-tunnel random
  RFC 1929 credentials, 10s handshake timeout, channels opened
  concurrently so a slow destination does not stall the tunnel.
- System ssh backend: ssh -D, which cannot authenticate; bound to
  127.0.0.1 and documented as a limitation.
- socks5_proxy_* values sent by callers are ignored.
- A cached tunnel whose SSH session ended is reopened instead of reused,
  and concurrent opens keep a single shared tunnel per bastion.
- SSH keepalives (15s, 3 misses) on both backends detect silent drops.
- SOCKS5 CONNECT destinations are logged at debug level.
- The capability, fields, plugin obligations and limits (local SRV/TXT
  lookups, ssh -D auth) are documented in PLUGIN_GUIDE.md, the manifest
  schema and the web-ui plugin types.
@kilo-code-bot

kilo-code-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Reviewed the SOCKS5 dynamic-forward support end to end. I traced the capability flow (manifest to registry to resolve_ssh_params), the tunnel cache (live_tunnel and cache_tunnel) and the RFC 1928/1929 server in ssh_socks5.rs. I also traced the russh accept loop and the system ssh -D argument building. No defects found in the changed lines.

Notes (no action required):

  • The ssh -D listener cannot authenticate local clients. The PR documents this as a known limit, and it binds to 127.0.0.1 only.
  • The SOCKS5 tunnel key excludes SSH credentials, the same as the existing fixed-forward key, so behaviour is consistent.
  • The plugin README and the "SRV/TXT lookups stay local" limit are documented in PLUGIN_GUIDE.md.
  • I could not run the tests in this read-only review.
Files Reviewed (20 files)
  • packages/web-ui/src/types/plugins.ts
  • plugins/PLUGIN_GUIDE.md
  • plugins/manifest.schema.json
  • src-tauri/src/application/connections.rs
  • src-tauri/src/application/connections_tests.rs
  • src-tauri/src/application/tunnels.rs
  • src-tauri/src/application/tunnels_tests.rs
  • src-tauri/src/drivers/driver_trait.rs
  • src-tauri/src/drivers/mysql/mod.rs
  • src-tauri/src/drivers/postgres/mod.rs
  • src-tauri/src/drivers/registry.rs
  • src-tauri/src/drivers/registry/tests.rs
  • src-tauri/src/drivers/sqlite/mod.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/models.rs
  • src-tauri/src/plugins/driver.rs
  • src-tauri/src/ssh_socks5.rs
  • src-tauri/src/ssh_socks5/tests.rs
  • src-tauri/src/ssh_tunnel.rs
  • src-tauri/tests/postgres_integration/parity.rs

Reviewed by claude-sonnet-5.5 · Input: 274 · Output: 5.1K · Cached: 1.1M

Review guidance: REVIEW.md from base branch main

@NewtTheWolf NewtTheWolf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for adding an explicit capability and preserving the driver's connection URI. I checked e52d9ec on Linux: typecheck, the production build, all 2,550 Rust library tests and the no-default-features server check pass.

I reproduced one registry race that needs fixing. register_driver publishes the synchronous SOCKS5 flag before awaiting the registry write lock, while unregister_driver clears it after removing the old entry. In a single-threaded Tokio probe I held that lock, first polled unregister_driver, then first polled registration of a replacement advertising socks5_proxy=true, released the lock and awaited removal followed by registration. The replacement remained registered with its manifest flag true, but is_socks5_proxy_driver returned false.

Tunnel resolution uses that false value to select the fixed forward for a driver requiring SOCKS5 routing. Please keep the registry entry and capability projections consistent across overlapping lifecycle operations and add this regression. The simple set/clear test does not cover the interleaving. I am leaving the PR open; native SSH/MongoDB E2E was not run after this initial blocker.

Since this is your first PR here, you are welcome to join us on Discord: https://discord.com/invite/K2hmhfHRSt

This branch has not been deployed

No deployments
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.

[Feat]: SSH tunnel for drivers with multi-endpoint URIs (SOCKS5 from the SSH session)

2 participants