diff --git a/CHANGELOG.md b/CHANGELOG.md index 319617737..c71a5740c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index ff1f2a933..15270efce 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -56,6 +56,7 @@ extension DatabaseManager { } let sshPassword = sshPasswordOverride ?? storedSshPassword + let destination = connection.sshForwardDestination let tunnelPort = try await SSHTunnelManager.shared.createTunnel( connectionId: connection.id, @@ -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, @@ -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 diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 79aee009d..de0a2eca8 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -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 } @@ -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) diff --git a/TablePro/Core/SSH/LibSSH2ForwardChannel.swift b/TablePro/Core/SSH/LibSSH2ForwardChannel.swift new file mode 100644 index 000000000..2d3ebfa33 --- /dev/null +++ b/TablePro/Core/SSH/LibSSH2ForwardChannel.swift @@ -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" +} diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 526122a7c..9a85c5e8f 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -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 @@ -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 { @@ -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) } @@ -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 { diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index e9254563c..f5b94165b 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -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 @@ -48,6 +47,8 @@ internal enum LibSSH2TunnelFactory { ) do { + try verifyUnixSocketDestination(session: chain.session, destination: destination) + let listenFD = try bindListenSocket(port: localPort) let tunnel = LibSSH2Tunnel( @@ -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 @@ -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? + 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, diff --git a/TablePro/Core/SSH/SSHForwardDestination.swift b/TablePro/Core/SSH/SSHForwardDestination.swift new file mode 100644 index 000000000..5a4bf8d74 --- /dev/null +++ b/TablePro/Core/SSH/SSHForwardDestination.swift @@ -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 :/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 + } + } +} diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index a855011b2..d21b46754 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -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 { @@ -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 + ) } } } @@ -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, @@ -132,8 +143,7 @@ actor SSHTunnelManager: TunnelManaging { connectionId: connectionId, config: config, credentials: credentials, - remoteHost: remoteHost, - remotePort: remotePort, + destination: destination, localPort: localPort ) }.value @@ -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() diff --git a/TablePro/Models/Connection/DatabaseConnection+Display.swift b/TablePro/Models/Connection/DatabaseConnection+Display.swift index 1115dce49..02deab829 100644 --- a/TablePro/Models/Connection/DatabaseConnection+Display.swift +++ b/TablePro/Models/Connection/DatabaseConnection+Display.swift @@ -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 diff --git a/TablePro/Models/Connection/DatabaseConnection+SSH.swift b/TablePro/Models/Connection/DatabaseConnection+SSH.swift index e7f54d8b9..099f73ac5 100644 --- a/TablePro/Models/Connection/DatabaseConnection+SSH.swift +++ b/TablePro/Models/Connection/DatabaseConnection+SSH.swift @@ -4,6 +4,17 @@ // extension DatabaseConnection { + static let sshForwardUnixSocketPathKey = "sshForwardUnixSocketPath" + + /// 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 { diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 01e15a450..0823e5882 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -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, diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 41605d1d2..1c19a1381 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -143,11 +143,16 @@ struct GeneralPaneView: View { text: $coordinator.network.host, prompt: Text("localhost") ) + .disabled(usesForwardSocket) TextField( String(localized: "Port"), text: $coordinator.network.port, prompt: Text(defaultPortString) ) + .disabled(usesForwardSocket) + } + if coordinator.ssh.state.enabled { + sshForwardSocketField } ForEach(connectionFields, id: \.id) { field in if !isHostListField(field) && coordinator.network.isFieldVisible(field) { @@ -159,6 +164,61 @@ struct GeneralPaneView: View { } } + private var usesForwardSocket: Bool { + coordinator.ssh.state.enabled && coordinator.network.forwardsToUnixSocket + } + + @ViewBuilder + private var sshForwardSocketField: some View { + TextField( + String(localized: "Socket Path"), + text: $coordinator.network.sshForwardUnixSocketPath, + prompt: Text(verbatim: "/var/run/postgresql/.s.PGSQL.5432") + ) + switch coordinator.network.socketPathIssue { + case .notAbsolute: + socketPathCaption( + String(localized: "Enter an absolute path, as it appears on the SSH server."), + systemImage: "exclamationmark.triangle", + tint: .orange + ) + case .looksLikeDirectory: + socketPathCaption( + String(localized: "Point at the socket file itself, not the directory holding it."), + systemImage: "exclamationmark.triangle", + tint: .orange + ) + case .none: + if usesForwardSocket { + socketPathCaption( + String(localized: """ + The SSH server connects to this socket instead of Host and Port. \ + A database on a socket cannot negotiate TLS, so TablePro turns it off; \ + the SSH tunnel still encrypts the whole path. + """), + systemImage: "info.circle", + tint: .secondary + ) + } else { + socketPathCaption( + String(localized: "Optional. Set this to reach a database that only listens on a Unix socket."), + systemImage: "info.circle", + tint: .secondary + ) + } + } + } + + private func socketPathCaption( + _ message: String, + systemImage: String, + tint: Color + ) -> some View { + Label(message, systemImage: systemImage) + .font(.caption) + .foregroundStyle(tint) + } + @ViewBuilder private var authenticationSection: some View { if connectionMode != .fileBased { diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 34cbcd7b6..e6993ed0c 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -14,10 +14,15 @@ final class NetworkPaneViewModel { var host: String = "" var port: String = "" var database: String = "" + var sshForwardUnixSocketPath: String = "" var additionalFieldValues: [String: String] = [:] var coordinator: WeakCoordinatorRef? + var forwardsToUnixSocket: Bool { + !sshForwardUnixSocketPath.trimmingCharacters(in: .whitespaces).isEmpty + } + var connectionMode: ConnectionMode { PluginManager.shared.connectionMode(for: type) } @@ -115,6 +120,7 @@ final class NetworkPaneViewModel { port = connection.port > 0 ? String(connection.port) : "" database = connection.database type = connection.type + sshForwardUnixSocketPath = connection.sshForwardUnixSocketPath ?? "" var values: [String: String] = [:] let allFields = PluginManager.shared.additionalConnectionFields(for: connection.type) @@ -138,5 +144,31 @@ final class NetworkPaneViewModel { for (key, value) in additionalFieldValues { fields[key] = value } + let socketPath = sshForwardUnixSocketPath.trimmingCharacters(in: .whitespaces) + if !socketPath.isEmpty { + fields[DatabaseConnection.sshForwardUnixSocketPathKey] = socketPath + } + } +} + +/// Advisory only. `ssh -L` needs the socket file itself, while libpq's own `host` convention +/// names the directory holding it, and mixing the two up is the usual mistake. Save is never +/// blocked on this: the SSH server is the only authority on whether the path resolves. +enum SSHForwardSocketPathIssue: Equatable { + case notAbsolute + case looksLikeDirectory +} + +extension NetworkPaneViewModel { + nonisolated static func socketPathIssue(for rawPath: String) -> SSHForwardSocketPathIssue? { + let path = rawPath.trimmingCharacters(in: .whitespaces) + guard !path.isEmpty else { return nil } + guard path.hasPrefix("/") else { return .notAbsolute } + guard !path.hasSuffix("/") else { return .looksLikeDirectory } + return nil + } + + var socketPathIssue: SSHForwardSocketPathIssue? { + Self.socketPathIssue(for: sshForwardUnixSocketPath) } } diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift index 8c4af4f00..916377c80 100644 --- a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -75,4 +75,60 @@ struct DatabaseManagerTunnelTests { #expect(tunneled.additionalFields["mongoParam_directConnection"] == nil) } + + @Test("A socket forward drops TLS, which the destination cannot negotiate") + func socketForwardDisablesTLS() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql, + sslConfig: SSLConfiguration(mode: .required) + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + let tunneled = DatabaseManager.shared.tunneledConnection( + from: connection, + localPort: 62_000, + forwardsToUnixSocket: true + ) + + #expect(tunneled.sslConfig.mode == .disabled) + } + + @Test("A TCP forward keeps encryption on") + func tcpForwardKeepsTLS() { + let connection = DatabaseConnection( + name: "pg", + host: "db.internal", + port: 5_432, + type: .postgresql, + sslConfig: SSLConfiguration(mode: .verifyIdentity) + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.sslConfig.mode == .required) + } + + @Test("The socket path never reaches the driver") + func socketPathIsStrippedFromDriverFields() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + let tunneled = DatabaseManager.shared.tunneledConnection( + from: connection, + localPort: 62_000, + forwardsToUnixSocket: true + ) + + #expect(tunneled.host == "127.0.0.1") + #expect(tunneled.port == 62_000) + #expect(tunneled.additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey] == nil) + } } diff --git a/TableProTests/Core/SSH/SSHForwardDestinationTests.swift b/TableProTests/Core/SSH/SSHForwardDestinationTests.swift new file mode 100644 index 000000000..5f94e27c4 --- /dev/null +++ b/TableProTests/Core/SSH/SSHForwardDestinationTests.swift @@ -0,0 +1,78 @@ +// +// SSHForwardDestinationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSH forward destination") +struct SSHForwardDestinationTests { + @Test("A connection without a socket path forwards to its host and port") + func defaultsToTCP() { + let connection = DatabaseConnection( + name: "tcp", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + + #expect(connection.sshForwardDestination == .tcp(host: "db.internal", port: 5_432)) + #expect(connection.sshForwardDestination.isUnixSocket == false) + } + + @Test("A socket path takes precedence over host and port") + func socketPathWins() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + #expect( + connection.sshForwardDestination + == .unixSocket(path: "/var/run/postgresql/.s.PGSQL.5432") + ) + #expect(connection.sshForwardDestination.isUnixSocket) + } + + @Test("Clearing the socket path falls back to host and port") + func clearingRestoresTCP() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + connection.sshForwardUnixSocketPath = nil + + #expect(connection.sshForwardUnixSocketPath == nil) + #expect(connection.sshForwardDestination == .tcp(host: "db.internal", port: 5_432)) + } + + @Test("The socket path survives a Codable round trip") + func survivesCodableRoundTrip() throws { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + + #expect(decoded.sshForwardDestination == .unixSocket(path: "/var/run/postgresql/.s.PGSQL.5432")) + } + + @Test("Log description names the endpoint") + func logDescription() { + #expect(SSHForwardDestination.tcp(host: "db", port: 5_432).logDescription == "db:5432") + #expect(SSHForwardDestination.unixSocket(path: "/tmp/pg.sock").logDescription == "/tmp/pg.sock") + } +} diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 731f5683d..1dd978f45 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -59,4 +59,16 @@ struct SSHTunnelErrorTests { let error = SSHTunnelError.connectionTimeout #expect(error.errorDescription != nil) } + + @Test("SSHTunnelError.socketForwardingRefused names the socket and the sshd setting") + func socketForwardingRefusedDescription() { + let error = SSHTunnelError.socketForwardingRefused( + path: "/var/run/postgresql/.s.PGSQL.5432", + detail: "channel open failed" + ) + + #expect(error.errorDescription?.contains("/var/run/postgresql/.s.PGSQL.5432") == true) + #expect(error.errorDescription?.contains("AllowStreamLocalForwarding") == true) + #expect(error.errorDescription?.contains("channel open failed") == true) + } } diff --git a/TableProTests/Views/ConnectionForm/NetworkPaneSocketForwardTests.swift b/TableProTests/Views/ConnectionForm/NetworkPaneSocketForwardTests.swift new file mode 100644 index 000000000..8c2c11a0d --- /dev/null +++ b/TableProTests/Views/ConnectionForm/NetworkPaneSocketForwardTests.swift @@ -0,0 +1,73 @@ +// +// NetworkPaneSocketForwardTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Network pane socket forwarding") +@MainActor +struct NetworkPaneSocketForwardTests { + @Test("An absolute socket file path raises no issue") + func acceptsAbsoluteSocketPath() { + #expect(NetworkPaneViewModel.socketPathIssue(for: "/var/run/postgresql/.s.PGSQL.5432") == nil) + } + + @Test("An empty path raises no issue, because the field is optional") + func emptyPathIsFine() { + #expect(NetworkPaneViewModel.socketPathIssue(for: "") == nil) + #expect(NetworkPaneViewModel.socketPathIssue(for: " ") == nil) + } + + @Test("A relative path is flagged") + func flagsRelativePath() { + #expect(NetworkPaneViewModel.socketPathIssue(for: "var/run/postgresql") == .notAbsolute) + } + + @Test("A trailing slash is flagged, because ssh forwards to the socket file not its directory") + func flagsDirectoryPath() { + #expect(NetworkPaneViewModel.socketPathIssue(for: "/var/run/postgresql/") == .looksLikeDirectory) + } + + @Test("Loading a connection populates the socket path") + func loadsSocketPath() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + let viewModel = NetworkPaneViewModel() + viewModel.load(from: connection) + + #expect(viewModel.sshForwardUnixSocketPath == "/var/run/postgresql/.s.PGSQL.5432") + #expect(viewModel.forwardsToUnixSocket) + } + + @Test("Writing trims the socket path") + func writeTrimsSocketPath() { + let viewModel = NetworkPaneViewModel() + viewModel.sshForwardUnixSocketPath = " /var/run/postgresql/.s.PGSQL.5432 " + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(fields[DatabaseConnection.sshForwardUnixSocketPathKey] == "/var/run/postgresql/.s.PGSQL.5432") + } + + @Test("Writing an empty socket path stores nothing") + func writeOmitsEmptySocketPath() { + let viewModel = NetworkPaneViewModel() + viewModel.sshForwardUnixSocketPath = " " + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(fields[DatabaseConnection.sshForwardUnixSocketPathKey] == nil) + #expect(viewModel.forwardsToUnixSocket == false) + } +} diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 553e69ab8..a58c9a6ec 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -28,7 +28,7 @@ flowchart LR -The database **Host** and **Port** on the General pane are what the SSH server uses to reach the database, not what your Mac would use. Use `localhost` if the database runs on the SSH server itself, or the internal hostname (for example an RDS endpoint reached through a bastion) if it runs elsewhere. +The database **Host** and **Port** on the General pane are what the SSH server uses to reach the database, not what your Mac would use. Use `localhost` if the database runs on the SSH server itself, or the internal hostname (for example an RDS endpoint reached through a bastion) if it runs elsewhere. If the database listens on a Unix socket rather than a port, use [Socket Path](#forwarding-to-a-unix-socket) instead. To reuse one SSH config across several connections, save it as a profile with **Save Current as Profile...** or pick one from the **Profile** picker. See [SSH Profiles](/features/ssh-profiles). @@ -95,6 +95,28 @@ When the database sits behind one or more bastions, expand the **Jump Hosts** se Password auth is not available for jump hosts. If the jump host list is empty and the SSH host matches a config entry with a `ProxyJump` directive, TablePro follows it. +## Forwarding to a Unix Socket + +Some servers only listen on a Unix socket, with no TCP port open at all. A PostgreSQL box configured for `local` connections in `pg_hba.conf` is the common case. Fill in **Socket Path** on the General pane and TablePro forwards to that socket instead of a host and port, the same thing `ssh -L 5434:/var/run/postgresql/.s.PGSQL.5432 server` does by hand. **Host** and **Port** are ignored while a socket path is set. + +Give the path to the socket **file**, not the directory holding it: + +| Database | Typical socket path | +|----------|---------------------| +| PostgreSQL | `/var/run/postgresql/.s.PGSQL.5432` | +| MySQL / MariaDB | `/var/run/mysqld/mysqld.sock` | +| Redis | `/var/run/redis/redis.sock` | + +This works through jump hosts: the hops get you to the SSH server, and the socket is opened from there. + + +A database listening on a Unix socket cannot negotiate TLS, so TablePro turns SSL off for the connection. Nothing is exposed by this: the whole path still runs inside the encrypted SSH tunnel. + + +`peer` authentication works, because the connection to the socket is made by the SSH server as your SSH login user. So a `local all all peer` line authenticates you as the PostgreSQL role matching your SSH username. + +The SSH server has to allow socket forwarding. It is on by default, but if `sshd_config` sets `AllowStreamLocalForwarding no`, TablePro says so instead of failing with a dropped connection. Note this is a separate setting from `AllowTcpForwarding`. + ## Import from URL Paste a `scheme+ssh://` URL to create a connection with SSH pre-filled. The `+ssh` suffix works with any non-file-based scheme (`mysql+ssh`, `postgres+ssh`, `redis+ssh`, `mongodb+ssh`, ...). TablePlus SSH URLs import directly. See the [Connection URL Reference](/databases/connection-urls#ssh-tunnel-format) for the format and parameters.