Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- SSH tunnels can now forward to a Unix socket on the remote server, for databases that only listen on a socket. Set the socket path on the General pane, and TablePro forwards to it instead of a host and port, through jump hosts if you use them. (#1871)

### Fixed

- Fixed the right sidebar refusing to resize while a Users & Roles tab was open. The user list now collapses on its own when the tab gets too narrow to hold it. (#1872)
Expand Down
10 changes: 7 additions & 3 deletions TablePro/Core/Database/DatabaseManager+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ extension DatabaseManager {
}

let sshPassword = sshPasswordOverride ?? storedSshPassword
let destination = connection.sshForwardDestination

let tunnelPort = try await SSHTunnelManager.shared.createTunnel(
connectionId: connection.id,
Expand All @@ -67,8 +68,7 @@ extension DatabaseManager {
keyPassphrase: keyPassphrase,
sshPassword: sshPassword,
agentSocketPath: sshConfig.agentSocketPath,
remoteHost: connection.host,
remotePort: connection.port,
destination: destination,
jumpHosts: sshConfig.jumpHosts,
totpMode: sshConfig.totpMode,
totpSecret: totpSecret,
Expand All @@ -77,7 +77,11 @@ extension DatabaseManager {
totpPeriod: sshConfig.totpPeriod
)

return tunneledConnection(from: connection, localPort: tunnelPort)
return tunneledConnection(
from: connection,
localPort: tunnelPort,
forwardsToUnixSocket: destination.isUnixSocket
)
}

// MARK: - SSH Tunnel Recovery
Expand Down
22 changes: 20 additions & 2 deletions TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,26 @@ extension DatabaseManager {
/// seed list is collapsed to that endpoint and a direct connection is forced,
/// stopping replica set discovery from reaching members behind the tunnel.
/// Shared by the SSH and Cloudflare tunnel paths.
func tunneledConnection(from connection: DatabaseConnection, localPort: Int) -> DatabaseConnection {
///
/// A server listening on a Unix socket answers every TLS request with a refusal, because
/// it decides from its own listening socket family and never consults `pg_hba`. Encryption
/// modes are therefore dropped for a socket forward, which costs nothing: the whole path
/// already runs inside the SSH transport.
func tunneledConnection(
from connection: DatabaseConnection,
localPort: Int,
forwardsToUnixSocket: Bool = false
) -> DatabaseConnection {
var tunnelSSL = connection.sslConfig
if tunnelSSL.isEnabled {
if forwardsToUnixSocket {
if tunnelSSL.isEnabled {
Self.logger.notice("Socket forward: disabling TLS, the destination cannot negotiate it")
}
tunnelSSL.mode = .disabled
tunnelSSL.caCertificatePath = ""
tunnelSSL.clientCertificatePath = ""
tunnelSSL.clientKeyPath = ""
} else if tunnelSSL.isEnabled {
if tunnelSSL.verifiesCertificate {
tunnelSSL.mode = .required
}
Expand All @@ -29,6 +46,7 @@ extension DatabaseManager {
}

var effectiveFields = connection.additionalFields
effectiveFields[DatabaseConnection.sshForwardUnixSocketPathKey] = nil
if connection.usePgpass {
effectiveFields["pgpassOriginalHost"] = connection.host
effectiveFields["pgpassOriginalPort"] = String(connection.port)
Expand Down
39 changes: 39 additions & 0 deletions TablePro/Core/SSH/LibSSH2ForwardChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// LibSSH2ForwardChannel.swift
// TablePro
//

import Foundation

import CLibSSH2

/// Opens the channel that carries forwarded traffic to the destination. The two libssh2
/// entry points behave identically to the caller: both return a channel that reads and
/// writes the same way, and both signal "not ready yet" with `LIBSSH2_ERROR_EAGAIN`.
internal enum LibSSH2ForwardChannel {
static func open(
session: OpaquePointer,
destination: SSHForwardDestination,
originPort: Int
) -> OpaquePointer? {
switch destination {
case .tcp(let host, let port):
return libssh2_channel_direct_tcpip_ex(
session,
host,
Int32(port),
Self.originHost,
Int32(originPort)
)
case .unixSocket(let path):
return libssh2_channel_direct_streamlocal_ex(
session,
path,
Self.originHost,
Int32(originPort)
)
}
}

private static let originHost = "127.0.0.1"
}
31 changes: 13 additions & 18 deletions TablePro/Core/SSH/LibSSH2Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {

// MARK: - Forwarding

func startForwarding(remoteHost: String, remotePort: Int) {
func startForwarding(destination: SSHForwardDestination) {
sessionQueue.sync { libssh2_session_set_blocking(session, 0) }

forwardingTask = Task.detached { [weak self] in
Expand All @@ -94,8 +94,10 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
defer { continuation.resume() }
guard let self else { return }

let target = destination.logDescription

Self.logger.info(
"Forwarding started on port \(self.localPort) -> \(remoteHost):\(remotePort)"
"Forwarding started on port \(self.localPort) -> \(target)"
)

while self.isRunning {
Expand All @@ -110,21 +112,16 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
// Open channel on sessionQueue (serialized libssh2 call),
// then hand off relay to relayQueue (concurrent I/O).
let channel: OpaquePointer? = self.sessionQueue.sync {
self.openDirectTcpipChannel(
remoteHost: remoteHost,
remotePort: remotePort
)
self.openForwardChannel(destination: destination)
}

guard let channel else {
Self.logger.error("Failed to open direct-tcpip channel")
Self.logger.error("Failed to open forwarding channel to \(target)")
Darwin.close(clientFD)
continue
}

Self.logger.debug(
"Client connected, relaying to \(remoteHost):\(remotePort)"
)
Self.logger.debug("Client connected, relaying to \(target)")
self.spawnRelay(clientFD: clientFD, channel: channel)
}

Expand Down Expand Up @@ -288,16 +285,14 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
return clientFD
}

/// Open a direct-tcpip channel, handling EAGAIN with select().
/// Open the forwarding channel to the destination, handling EAGAIN with select().
/// Must be called on `sessionQueue`.
private func openDirectTcpipChannel(remoteHost: String, remotePort: Int) -> OpaquePointer? {
private func openForwardChannel(destination: SSHForwardDestination) -> OpaquePointer? {
while isRunning {
let channel = libssh2_channel_direct_tcpip_ex(
session,
remoteHost,
Int32(remotePort),
"127.0.0.1",
Int32(localPort)
let channel = LibSSH2ForwardChannel.open(
session: session,
destination: destination,
originPort: localPort
)

if let channel {
Expand Down
38 changes: 35 additions & 3 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ internal enum LibSSH2TunnelFactory {
connectionId: UUID,
config: SSHConfiguration,
credentials: SSHTunnelCredentials,
remoteHost: String,
remotePort: Int,
destination: SSHForwardDestination,
localPort: Int
) async throws -> LibSSH2Tunnel {
_ = initialized
Expand All @@ -48,6 +47,8 @@ internal enum LibSSH2TunnelFactory {
)

do {
try verifyUnixSocketDestination(session: chain.session, destination: destination)

let listenFD = try bindListenSocket(port: localPort)

let tunnel = LibSSH2Tunnel(
Expand All @@ -67,7 +68,7 @@ internal enum LibSSH2TunnelFactory {
)

logger.info(
"Tunnel created: \(config.host) -> 127.0.0.1:\(localPort) -> \(remoteHost):\(remotePort)"
"Tunnel created: \(config.host) -> 127.0.0.1:\(localPort) -> \(destination.logDescription)"
)

return tunnel
Expand Down Expand Up @@ -696,6 +697,37 @@ internal enum LibSSH2TunnelFactory {

// MARK: - Channel Operations

/// A refused streamlocal forward is otherwise invisible until the database driver dials
/// the local port, where it surfaces as an unexplained dropped connection. `sshd` gates
/// socket forwarding behind `AllowStreamLocalForwarding`, separately from TCP forwarding,
/// so probing once at connect time turns that into an actionable error. TCP destinations
/// keep their existing behaviour: probing them would open and drop a real database
/// connection on every connect.
private static func verifyUnixSocketDestination(
session: OpaquePointer,
destination: SSHForwardDestination
) throws {
guard case .unixSocket(let path) = destination else { return }

libssh2_session_set_blocking(session, 1)
defer { libssh2_session_set_blocking(session, 0) }

guard let channel = LibSSH2ForwardChannel.open(
session: session,
destination: destination,
originPort: 0
) else {
var msgPtr: UnsafeMutablePointer<CChar>?
var msgLen: Int32 = 0
libssh2_session_last_error(session, &msgPtr, &msgLen, 0)
let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error"
throw SSHTunnelError.socketForwardingRefused(path: path, detail: detail)
}

libssh2_channel_close(channel)
libssh2_channel_free(channel)
}

private static func openChannel(
session: OpaquePointer,
socketFD: Int32,
Expand Down
28 changes: 28 additions & 0 deletions TablePro/Core/SSH/SSHForwardDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// SSHForwardDestination.swift
// TablePro
//

import Foundation

/// What the SSH server connects to on the far end of a local forward. A TCP destination
/// opens a `direct-tcpip` channel; a socket destination opens a `direct-streamlocal@openssh.com`
/// channel, the equivalent of `ssh -L <localPort>:/path/to/socket`.
internal enum SSHForwardDestination: Sendable, Hashable {
case tcp(host: String, port: Int)
case unixSocket(path: String)

var isUnixSocket: Bool {
switch self {
case .tcp: return false
case .unixSocket: return true
}
}

var logDescription: String {
switch self {
case .tcp(let host, let port): return "\(host):\(port)"
case .unixSocket(let path): return path
}
}
}
20 changes: 15 additions & 5 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum SSHTunnelError: Error, LocalizedError, Equatable {
case connectionTimeout
case hostKeyVerificationFailed
case channelOpenFailed
case socketForwardingRefused(path: String, detail: String)

var errorDescription: String? {
switch self {
Expand Down Expand Up @@ -56,6 +57,17 @@ enum SSHTunnelError: Error, LocalizedError, Equatable {
return String(localized: "SSH host key verification failed")
case .channelOpenFailed:
return String(localized: "Failed to open SSH channel for port forwarding")
case .socketForwardingRefused(let path, let detail):
return String(
format: String(
localized: """
The SSH server would not forward the socket %@. Check that the path exists on the server \
and that sshd allows socket forwarding (AllowStreamLocalForwarding). (%@)
"""
),
path,
detail
)
}
}
}
Expand Down Expand Up @@ -89,8 +101,7 @@ actor SSHTunnelManager: TunnelManaging {
keyPassphrase: String? = nil,
sshPassword: String? = nil,
agentSocketPath: String? = nil,
remoteHost: String,
remotePort: Int,
destination: SSHForwardDestination,
jumpHosts: [SSHJumpHost] = [],
totpMode: TOTPMode = .none,
totpSecret: String? = nil,
Expand Down Expand Up @@ -132,8 +143,7 @@ actor SSHTunnelManager: TunnelManaging {
connectionId: connectionId,
config: config,
credentials: credentials,
remoteHost: remoteHost,
remotePort: remotePort,
destination: destination,
localPort: localPort
)
}.value
Expand All @@ -147,7 +157,7 @@ actor SSHTunnelManager: TunnelManaging {
tunnels[connectionId] = tunnel
Self.tunnelRegistry.withLock { $0[connectionId] = tunnel }

tunnel.startForwarding(remoteHost: remoteHost, remotePort: remotePort)
tunnel.startForwarding(destination: destination)
tunnel.startKeepAlive()

updateAppNapState()
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection+Display.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ extension DatabaseConnection {
}

private var endpointDescription: String {
if let socketPath = sshForwardUnixSocketPath, resolvedSSHConfig.enabled {
return (socketPath as NSString).abbreviatingWithTildeInPath
}
if host.isEmpty {
let trimmed = database.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? type.rawValue : (trimmed as NSString).abbreviatingWithTildeInPath
Expand Down
11 changes: 11 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
//

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 👍 / 👎.


/// Where the SSH server should connect once the tunnel is up. A socket path takes
/// precedence over `host`/`port`, which the SSH server never uses in that case.
var sshForwardDestination: SSHForwardDestination {
if let path = sshForwardUnixSocketPath {
return .unixSocket(path: path)
}
return .tcp(host: host, port: port)
}

/// The resolved SSH configuration, derived from `sshTunnelMode`.
var resolvedSSHConfig: SSHConfiguration {
switch sshTunnelMode {
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,11 @@ struct DatabaseConnection: Identifiable, Hashable {
set { additionalFields["preConnectScript"] = newValue ?? "" }
}

var sshForwardUnixSocketPath: String? {
get { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey]?.nilIfEmpty }
set { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey] = newValue ?? "" }
}

init(
id: UUID = UUID(),
name: String,
Expand Down
Loading
Loading