diff --git a/CHANGELOG.md b/CHANGELOG.md index 4189ab24f..c395a56ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An SSH tunnel that cannot reach the database now says so, instead of leaving the database driver to report a timeout reading the server greeting. MySQL showed this as "Lost connection to server at 'handshake: reading initial communication packet', system error: 35", which named no cause. TablePro now checks the destination is reachable before the tunnel is handed over, and reports whether it was refused or timed out. The most common cause is the **Host** field holding the server's public address while the database only listens on `127.0.0.1`; the SSH server resolves that address from where it sits, so **Host** should be `localhost`. (#1981) +- The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host". +- An SSH tunnel no longer dies while it is busy. The keep-alive read a send that had to wait as a dead tunnel, so heavy traffic through the tunnel could tear it down along with every connection using it. +- A `ProxyCommand` line in `~/.ssh/config` no longer passes without a word. TablePro cannot run the command, so it connects straight to the hostname and can land somewhere `ssh` never would. It now logs that it skipped the directive. `ProxyJump` is still followed as before. - Closing a window that holds more than one tab now asks about unsaved changes in any of them, not just the tab on screen. (#1972) - MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970) - Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index b3acf68a2..d5d9da1e1 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -156,14 +156,18 @@ extension DatabaseManager { } } catch { let cancelled = isAttemptCancelled(attempt, for: connection.id) + var reportedError = error if cancelled { driver.disconnect() } else { + if let attributed = await attributedTunnelFailure(for: connection) { + reportedError = attributed + } closeActiveTunnel(for: connection) } finalizeConnectionFailure(for: connection.id, cancelled: cancelled) - throw error + throw reportedError } } diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 0f8ef66b9..ec970fada 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -80,6 +80,14 @@ extension DatabaseManager { } } + /// The SSH-layer reason a tunneled connect failed, when the tunnel recorded one. The driver + /// only ever sees a local socket that was accepted and then stayed silent, so its own error + /// names a read timeout and never the cause. Read before the tunnel is torn down. + func attributedTunnelFailure(for connection: DatabaseConnection) async -> SSHTunnelError? { + guard let manager = activeTunnelManager(for: connection) as? SSHTunnelManager else { return nil } + return await manager.consumeLastForwardFailure(connectionId: connection.id) + } + func closeActiveTunnel(for connection: DatabaseConnection) { guard let manager = activeTunnelManager(for: connection) else { return } let connectionId = connection.id diff --git a/TablePro/Core/SSH/LibSSH2ForwardChannel.swift b/TablePro/Core/SSH/LibSSH2ForwardChannel.swift index a3b083f60..c2f6eaa66 100644 --- a/TablePro/Core/SSH/LibSSH2ForwardChannel.swift +++ b/TablePro/Core/SSH/LibSSH2ForwardChannel.swift @@ -7,11 +7,13 @@ import Foundation import CLibSSH2 -/// Result of a single non-blocking attempt to open a forwarding channel. +/// Result of a single non-blocking attempt to open a forwarding channel. A failure carries +/// libssh2's own message because it names the cause the user needs (a refused destination, a +/// forwarding policy on the server) and is otherwise lost by the time anything can report it. internal enum SSHForwardChannelAttempt { case opened(OpaquePointer) case wouldBlock(RelayDirections) - case failed(Int32) + case failed(code: Int32, message: String) } /// One non-blocking attempt to open a forwarding channel. Implementations must return @@ -41,7 +43,9 @@ internal struct LibSSH2ForwardChannelOpener: SSHForwardChannelOpening { } let errorCode = libssh2_session_last_errno(session) - guard errorCode == LIBSSH2_ERROR_EAGAIN else { return .failed(errorCode) } + guard errorCode == LIBSSH2_ERROR_EAGAIN else { + return .failed(code: errorCode, message: LibSSH2ForwardChannel.lastErrorMessage(session: session)) + } return .wouldBlock(RelayDirections(libssh2BlockDirections: libssh2_session_block_directions(session))) } @@ -76,5 +80,15 @@ internal enum LibSSH2ForwardChannel { } } + static func lastErrorMessage(session: OpaquePointer) -> String { + var messagePointer: UnsafeMutablePointer? + var messageLength: Int32 = 0 + libssh2_session_last_error(session, &messagePointer, &messageLength, 0) + + guard let messagePointer else { return String(localized: "Unknown error") } + let message = String(cString: messagePointer) + return message.isEmpty ? String(localized: "Unknown error") : message + } + private static let originHost = "127.0.0.1" } diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index e94e2fd57..636f80fc2 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -44,6 +44,8 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { /// Callback invoked when the tunnel dies (keep-alive failure, etc.) var onDeath: ((UUID) -> Void)? + private let forwardFailure = SSHForwardFailureRecorder() + struct JumpHop { let session: OpaquePointer // LIBSSH2_SESSION* let socket: Int32 // TCP or socketpair fd @@ -53,13 +55,22 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { private static let relayBufferSize = 32_768 // 32KB - /// Bounds a forwarding channel open. libssh2 retries EAGAIN forever on its own, so - /// without this a stuck open outlives the database driver's connect timeout and the - /// client waits on a socket nothing will ever write to. Matches the driver's own - /// connect timeout so TablePro reports the cause before the driver gives up blind. - private static let channelOpenDeadlineSeconds: TimeInterval = 10 + /// Bounds a forwarding channel open for a client that has already been accepted. libssh2 + /// retries EAGAIN forever on its own, so without this a stuck open outlives the database + /// driver's connect timeout and the client waits on a socket nothing will ever write to. + /// Held strictly below every bundled driver's connect timeout (10s for MySQL, PostgreSQL, + /// Redis, MongoDB, and Cassandra) so the failure recorded here reaches the user before the + /// driver reports its own timeout, which names no cause. An equal budget loses that race: + /// the driver's clock starts when it dials, this one only once the accept has been noticed. + /// A registry plugin with a shorter connect timeout is not covered, because a plugin's + /// C-level timeout cannot be read from here. + internal static let channelOpenDeadlineSeconds: TimeInterval = 6 private static let channelOpenPollTimeoutMs: Int32 = 5_000 + /// How long the accept loop waits per poll before rechecking `isRunning`. Small enough that + /// noticing a client the kernel already accepted costs a slim part of the margin above. + internal static let acceptPollTimeoutMs: Int32 = 200 + init(connectionId: UUID, localPort: Int, session: OpaquePointer, socketFD: Int32, listenFD: Int32, jumpChain: [JumpHop] = []) { self.connectionId = connectionId @@ -108,15 +119,18 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { ) while self.isRunning { - let clientFD = self.acceptClient() - guard clientFD >= 0 else { + guard let client = self.acceptClient() else { if self.isRunning { continue } break } - self.spawnClient(clientFD: clientFD, destination: destination) + self.spawnClient( + clientFD: client.fd, + acceptedAt: client.acceptedAt, + destination: destination + ) } Self.logger.info("Forwarding loop ended for port \(self.localPort)") @@ -138,7 +152,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { self.sessionQueue.async { var secondsToNext: Int32 = 0 let rc = libssh2_keepalive_send(self.session, &secondsToNext) - continuation.resume(returning: rc != 0) + continuation.resume(returning: sshKeepAliveDidFail(rc)) } } @@ -258,13 +272,15 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } } - /// Accept a client connection on the listening socket with a 1-second poll timeout. - private func acceptClient() -> Int32 { + /// Accepts a client on the listening socket. The accept timestamp is taken here, not once + /// the open reaches `openAndRelay`, because the client's own connect timeout is already + /// running by then and the scheduling hops in between would push the deadline past it. + private func acceptClient() -> (fd: Int32, acceptedAt: Date)? { var pollFD = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0) - let pollResult = poll(&pollFD, 1, 1_000) // 1 second timeout + let pollResult = poll(&pollFD, 1, Self.acceptPollTimeoutMs) guard pollResult > 0, pollFD.revents & Int16(POLLIN) != 0 else { - return -1 + return nil } var clientAddr = sockaddr_in() @@ -276,13 +292,14 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } } - return clientFD + guard clientFD >= 0 else { return nil } + return (clientFD, Date()) } /// Open the channel and relay the client, off the accept loop so a slow open cannot /// stall the next accept, and off `sessionQueue` between attempts so it cannot stall /// the relays and keep-alive that share the session. - private func openAndRelay(clientFD: Int32, destination: SSHForwardDestination) { + private func openAndRelay(clientFD: Int32, acceptedAt: Date, destination: SSHForwardDestination) { let pump = SSHForwardChannelOpenPump( opener: LibSSH2ForwardChannelOpener( session: session, @@ -291,7 +308,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { sessionQueue: sessionQueue ), isActive: { [weak self] in self?.isRunning ?? false }, - deadline: Date().addingTimeInterval(Self.channelOpenDeadlineSeconds), + deadline: acceptedAt.addingTimeInterval(Self.channelOpenDeadlineSeconds), pollForReadiness: { [weak self] directions in guard let self else { return false } return pollReady( @@ -304,18 +321,29 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { let outcome = pump.run() logChannelOpenOutcome(outcome, destination: destination) + forwardFailure.record( + outcome, + destination: destination, + deadlineSeconds: Int(Self.channelOpenDeadlineSeconds) + ) handleChannelOpenOutcome(outcome, clientFD: clientFD) { channel in - runRelay(clientFD: clientFD, channel: channel) + runRelay(clientFD: clientFD, channel: channel, destination: destination) } } + func consumeLastForwardFailure() -> SSHTunnelError? { + forwardFailure.consume() + } + private func logChannelOpenOutcome(_ outcome: ChannelOpenOutcome, destination: SSHForwardDestination) { let target = destination.logDescription switch outcome { case .opened: Self.logger.debug("Client connected, relaying to \(target)") - case .failed(let errorCode): - Self.logger.error("Forwarding channel to \(target) failed to open, libssh2 error \(errorCode)") + case .failed(let errorCode, let message): + Self.logger.error( + "Forwarding channel to \(target) failed to open, libssh2 error \(errorCode): \(message)" + ) case .timedOut: Self.logger.error( "Forwarding channel to \(target) did not open within \(Int(Self.channelOpenDeadlineSeconds))s, closing local socket" @@ -328,7 +356,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { /// Opens the channel and relays one accepted client, off the accept loop so a slow /// open cannot delay the next accept. The loop runs on `relayQueue` (concurrent); /// individual libssh2 calls are dispatched to `sessionQueue` (serial) for thread safety. - private func spawnClient(clientFD: Int32, destination: SSHForwardDestination) { + private func spawnClient(clientFD: Int32, acceptedAt: Date, destination: SSHForwardDestination) { let task = Task.detached { [weak self] in guard let self else { Darwin.close(clientFD) @@ -342,7 +370,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { Darwin.close(clientFD) return } - self.openAndRelay(clientFD: clientFD, destination: destination) + self.openAndRelay(clientFD: clientFD, acceptedAt: acceptedAt, destination: destination) } } } @@ -358,7 +386,10 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } /// Blocking relay loop. Runs on `relayQueue`; libssh2 calls go through `sessionQueue`. - private func runRelay(clientFD: Int32, channel: OpaquePointer) { + /// Every termination is logged, not just a hangup: a channel that opens only after the + /// database client already gave up ends as `.localClosed`, and staying silent about that + /// leaves no trace of a tunnel that worked but arrived too late. + private func runRelay(clientFD: Int32, channel: OpaquePointer, destination: SSHForwardDestination) { let relay = SSHChannelRelay( localFD: clientFD, transportFD: socketFD, @@ -367,7 +398,14 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { isActive: { [weak self] in self?.isRunning ?? false } ) + let startedAt = Date() let termination = relay.run() + let elapsed = Date().timeIntervalSince(startedAt) + + let target = destination.logDescription + Self.logger.debug( + "Relay to \(target) ended as \(String(describing: termination)) after \(elapsed, format: .fixed(precision: 1))s" + ) Darwin.close(clientFD) guard self.isRunning else { return } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 8739b387c..cc312783e 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -27,6 +27,12 @@ internal enum LibSSH2TunnelFactory { /// overflow before the accept loop reaches it. private static let listenBacklogSize: Int32 = 16 + /// Bounds the connect-time forward probe. Independent of the per-client open deadline in + /// `LibSSH2Tunnel`: this one runs before any database driver has started its own clock, so + /// it is free to wait as long as the SSH connect itself does. + private static let forwardProbeDeadlineSeconds: TimeInterval = 10 + private static let probePollTimeoutMs: Int32 = 5_000 + // MARK: - Global Init private static let initialized: Bool = { @@ -52,7 +58,11 @@ internal enum LibSSH2TunnelFactory { ) do { - try verifyUnixSocketDestination(session: chain.session, destination: destination) + try probeForwardDestination( + session: chain.session, + socketFD: chain.socketFD, + destination: destination + ) let listenFD = try bindListenSocket(port: localPort) @@ -704,35 +714,53 @@ 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( + /// Confirms the SSH server can actually reach the forward destination before a tunnel port + /// is handed back. Creating a tunnel otherwise only proves the SSH hop works: a destination + /// the server cannot reach stays invisible until the database driver dials the local port, + /// where it surfaces as an accepted socket that goes silent and the driver reports as a + /// greeting timeout naming no cause (#1981). Covers TCP as well as sockets, because the + /// common case is a database bound to 127.0.0.1 behind a host field holding the server's + /// public address, which no amount of driver-side timeout tuning can explain to the user. + /// Bounded by the same deadline and poll pattern a per-client open uses, so a destination + /// that never answers cannot hang tunnel creation. + private static func probeForwardDestination( session: OpaquePointer, + socketFD: Int32, destination: SSHForwardDestination ) throws { - guard case .unixSocket(let path) = destination else { return } - - libssh2_session_set_blocking(session, 1) - defer { libssh2_session_set_blocking(session, 0) } + let probeQueue = DispatchQueue(label: "com.TablePro.ssh.probe") + probeQueue.sync { libssh2_session_set_blocking(session, 0) } + defer { probeQueue.sync { libssh2_session_set_blocking(session, 1) } } + + let pump = SSHForwardChannelOpenPump( + opener: LibSSH2ForwardChannelOpener( + session: session, + destination: destination, + originPort: 0, + sessionQueue: probeQueue + ), + isActive: { true }, + deadline: Date().addingTimeInterval(Self.forwardProbeDeadlineSeconds), + pollForReadiness: { directions in + pollReady(fd: socketFD, directions: directions, timeoutMs: Self.probePollTimeoutMs) + } + ) - 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) + let outcome = pump.run() + if case .opened(let channel) = outcome { + probeQueue.sync { + libssh2_channel_close(channel) + libssh2_channel_free(channel) + } + return } - libssh2_channel_close(channel) - libssh2_channel_free(channel) + let error = outcome.tunnelError( + destination: destination, + deadlineSeconds: Int(Self.forwardProbeDeadlineSeconds) + ) ?? SSHTunnelError.channelOpenFailed + logger.error("Forward probe to \(destination.logDescription) failed: \(error.localizedDescription)") + throw error } private static func openChannel( diff --git a/TablePro/Core/SSH/SSHConfigResolver.swift b/TablePro/Core/SSH/SSHConfigResolver.swift index cea073399..dbf933649 100644 --- a/TablePro/Core/SSH/SSHConfigResolver.swift +++ b/TablePro/Core/SSH/SSHConfigResolver.swift @@ -190,6 +190,7 @@ enum SSHConfigResolver { ) else { continue } for directive in block.directives { + warnIfRoutingDirectiveIgnored(directive) state.apply(directive) } if phase == .first { @@ -198,6 +199,18 @@ enum SSHConfigResolver { } } + /// Reports a directive that decides how the connection reaches the server and that TablePro + /// cannot honour. Dropping one without a word leaves a connection that works in Terminal and + /// fails here, with nothing pointing at `~/.ssh/config`. + private static func warnIfRoutingDirectiveIgnored(_ directive: SSHDirective) { + guard case .unrecognized(let key, _) = directive, + SSHUnsupportedDirective.changesRouting(key: key) else { return } + + logger.warning( + "Ignoring \(key) from ssh_config: TablePro connects to the host directly, so this connection may not reach the same server ssh would" + ) + } + private static func blockMatches( _ block: SSHConfigBlock, originalHost: String, diff --git a/TablePro/Core/SSH/SSHForwardChannelOpenPump.swift b/TablePro/Core/SSH/SSHForwardChannelOpenPump.swift index efd019b19..a42cea72e 100644 --- a/TablePro/Core/SSH/SSHForwardChannelOpenPump.swift +++ b/TablePro/Core/SSH/SSHForwardChannelOpenPump.swift @@ -7,11 +7,30 @@ import Foundation internal enum ChannelOpenOutcome: Equatable { case opened(OpaquePointer) - case failed(Int32) + case failed(code: Int32, message: String) case timedOut case cancelled } +internal extension ChannelOpenOutcome { + /// The SSH-layer reason this open failed, ready to surface in place of the database + /// driver's own error. A driver that dials the local port sees only an accepted socket + /// that went silent, so its error names a timeout and never the cause. + func tunnelError(destination: SSHForwardDestination, deadlineSeconds: Int) -> SSHTunnelError? { + switch self { + case .opened, .cancelled: + return nil + case .failed(_, let message): + if case .unixSocket(let path) = destination { + return .socketForwardingRefused(path: path, detail: message) + } + return .forwardRefused(destination: destination.logDescription, detail: message) + case .timedOut: + return .forwardTimedOut(destination: destination.logDescription, seconds: deadlineSeconds) + } + } +} + /// Drives a forwarding channel open to a decision within an app-owned deadline. /// /// libssh2 signals "not ready yet" with `LIBSSH2_ERROR_EAGAIN` and never gives up on its @@ -34,8 +53,8 @@ internal struct SSHForwardChannelOpenPump { switch opener.attemptOpen() { case .opened(let channel): return .opened(channel) - case .failed(let errorCode): - return .failed(errorCode) + case .failed(let errorCode, let message): + return .failed(code: errorCode, message: message) case .wouldBlock(let directions): guard pollForReadiness(directions) else { return .timedOut } } diff --git a/TablePro/Core/SSH/SSHForwardFailureRecorder.swift b/TablePro/Core/SSH/SSHForwardFailureRecorder.swift new file mode 100644 index 000000000..ce18a1e6d --- /dev/null +++ b/TablePro/Core/SSH/SSHForwardFailureRecorder.swift @@ -0,0 +1,32 @@ +// +// SSHForwardFailureRecorder.swift +// TablePro +// + +import Foundation +import os + +/// Holds why a forwarding channel last failed so the connect path can report that instead of +/// the database driver's own error. A driver dialing the local port only ever sees a socket +/// that was accepted and then stayed silent, so its error names a read timeout and never the +/// cause. Only failures are recorded: a later successful open belongs to a different client +/// and must not clear a reason the connect path has not read yet. +internal final class SSHForwardFailureRecorder: Sendable { + private let pending = OSAllocatedUnfairLock(initialState: nil) + + func record(_ outcome: ChannelOpenOutcome, destination: SSHForwardDestination, deadlineSeconds: Int) { + guard let error = outcome.tunnelError(destination: destination, deadlineSeconds: deadlineSeconds) else { + return + } + pending.withLock { $0 = error } + } + + /// Reads and clears the recorded reason, so a stale failure cannot be attributed to a + /// later connect attempt that failed for an unrelated reason. + func consume() -> SSHTunnelError? { + pending.withLock { failure in + defer { failure = nil } + return failure + } + } +} diff --git a/TablePro/Core/SSH/SSHKeepAliveResult.swift b/TablePro/Core/SSH/SSHKeepAliveResult.swift new file mode 100644 index 000000000..e07255342 --- /dev/null +++ b/TablePro/Core/SSH/SSHKeepAliveResult.swift @@ -0,0 +1,23 @@ +// +// SSHKeepAliveResult.swift +// TablePro +// + +import CLibSSH2 + +/// The libssh2 return codes the keep-alive has to tell apart, named here so callers and tests +/// can work with them without pulling the C module in. +internal enum SSHKeepAliveCode { + static let sent: Int32 = 0 + static let wouldBlock: Int32 = LIBSSH2_ERROR_EAGAIN + static let socketSend: Int32 = LIBSSH2_ERROR_SOCKET_SEND + static let socketDisconnect: Int32 = LIBSSH2_ERROR_SOCKET_DISCONNECT +} + +/// Whether a keep-alive return code means the tunnel is dead. The session is non-blocking for +/// the whole time it forwards, so libssh2 answers `LIBSSH2_ERROR_EAGAIN` when the send would +/// block on a transport that is busy carrying query results. That is a healthy tunnel under +/// load, not a dead one, and tearing it down drops every connection running through it. +internal func sshKeepAliveDidFail(_ resultCode: Int32) -> Bool { + resultCode != SSHKeepAliveCode.sent && resultCode != SSHKeepAliveCode.wouldBlock +} diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 6f1cf3367..b3de4e990 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -23,7 +23,7 @@ enum AuthFailureReason: Sendable, Equatable, CaseIterable { } /// Error types for SSH tunnel operations -enum SSHTunnelError: Error, LocalizedError, Equatable { +enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case tunnelCreationFailed(String) case tunnelAlreadyExists(UUID) case noAvailablePort @@ -32,6 +32,8 @@ enum SSHTunnelError: Error, LocalizedError, Equatable { case hostKeyVerificationFailed case channelOpenFailed case socketForwardingRefused(path: String, detail: String) + case forwardRefused(destination: String, detail: String) + case forwardTimedOut(destination: String, seconds: Int) var errorDescription: String? { switch self { @@ -77,6 +79,29 @@ enum SSHTunnelError: Error, LocalizedError, Equatable { path, detail ) + case .forwardRefused(let destination, let detail): + return String( + format: String( + localized: """ + The SSH server could not reach %@. That address is resolved from the SSH server, not from \ + your Mac, so a database that only listens on 127.0.0.1 needs Host set to localhost. Also \ + check that sshd allows TCP forwarding (AllowTcpForwarding). (%@) + """ + ), + destination, + detail + ) + case .forwardTimedOut(let destination, let seconds): + return String( + format: String( + localized: """ + The SSH server did not open a forwarding channel to %@ within %d seconds. Check for a \ + firewall between the SSH server and that address. + """ + ), + destination, + seconds + ) } } } @@ -252,6 +277,13 @@ actor SSHTunnelManager: TunnelManaging { return tunnel.localPort } + /// The reason this tunnel last failed to open a forwarding channel, cleared as it is read. + /// The connect path reports it in place of the database driver's error, which can only ever + /// name a timeout because the driver sees an accepted socket that stayed silent. + func consumeLastForwardFailure(connectionId: UUID) -> SSHTunnelError? { + tunnels[connectionId]?.consumeLastForwardFailure() + } + /// Check if an error message indicates a local port bind failure static func isLocalPortBindFailure(_ errorMessage: String) -> Bool { errorMessage.lowercased().contains("already in use") diff --git a/TablePro/Core/SSH/SSHUnsupportedDirective.swift b/TablePro/Core/SSH/SSHUnsupportedDirective.swift new file mode 100644 index 000000000..4683250b5 --- /dev/null +++ b/TablePro/Core/SSH/SSHUnsupportedDirective.swift @@ -0,0 +1,23 @@ +// +// SSHUnsupportedDirective.swift +// TablePro +// + +import Foundation + +/// Classifies the `~/.ssh/config` directives TablePro does not parse. Most of them are cosmetic +/// or already match the default, so reporting all of them would bury the few that matter. These +/// decide how the connection reaches the server, and ignoring one sends TablePro somewhere the +/// command line would never have gone. `ProxyCommand` is the case that bites: OpenSSH reaches +/// the server by running the command, TablePro dials the hostname directly, and the difference +/// shows up only as a connection that fails with nothing pointing at the config. +internal enum SSHUnsupportedDirective { + private static let routingKeys: Set = [ + "proxycommand", + "proxyusefdpass", + ] + + static func changesRouting(key: String) -> Bool { + routingKeys.contains(key.lowercased()) + } +} diff --git a/TablePro/Info.plist b/TablePro/Info.plist index a8716e9f2..2f8573f1c 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -4,6 +4,8 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) + NSLocalNetworkUsageDescription + TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames. SUFeedURL https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml SUPublicEDKey diff --git a/TableProTests/Core/SSH/SSHForwardChannelOpenPumpTests.swift b/TableProTests/Core/SSH/SSHForwardChannelOpenPumpTests.swift index 99f3c51f3..62ab9bde9 100644 --- a/TableProTests/Core/SSH/SSHForwardChannelOpenPumpTests.swift +++ b/TableProTests/Core/SSH/SSHForwardChannelOpenPumpTests.swift @@ -28,12 +28,12 @@ struct SSHForwardChannelOpenPumpTests { @Test("A non-EAGAIN failure returns immediately without polling") func failsFastWithoutPolling() { - let opener = FakeChannelOpener(attempts: [.failed(42)]) + let opener = FakeChannelOpener(attempts: [.failed(code: 42, message: "channel open failure")]) let polls = CountBox() let outcome = makePump(opener: opener, polls: polls).run() - #expect(outcome == .failed(42)) + #expect(outcome == .failed(code: 42, message: "channel open failure")) #expect(opener.attemptCount == 1) #expect(polls.value == 0) } @@ -154,7 +154,7 @@ struct HandleChannelOpenOutcomeTests { @Test("A libssh2 failure closes the local socket so the client fails fast") func failedClosesSocket() { - expectLocalSocketClosed(for: .failed(42)) + expectLocalSocketClosed(for: .failed(code: 42, message: "channel open failure")) } @Test("A channel open that hits the deadline closes the local socket") @@ -181,6 +181,64 @@ struct HandleChannelOpenOutcomeTests { } } +/// The mapping that carries a channel-open failure out to the user. Without it the reason is +/// computed and dropped, and the database driver reports a read timeout naming no cause (#1981). +@Suite("ChannelOpenOutcome.tunnelError") +struct ChannelOpenOutcomeTunnelErrorTests { + private static let tcp = SSHForwardDestination.tcp(host: "db.internal", port: 3_306) + private static let socket = SSHForwardDestination.unixSocket(path: "/var/run/mysqld/mysqld.sock") + + @Test("An opened channel has no failure to report") + func openedHasNoError() { + let channel = OpaquePointer(bitPattern: 0xBEEF)! + #expect(ChannelOpenOutcome.opened(channel).tunnelError(destination: Self.tcp, deadlineSeconds: 6) == nil) + } + + @Test("A cancelled open has no failure to report") + func cancelledHasNoError() { + #expect(ChannelOpenOutcome.cancelled.tunnelError(destination: Self.tcp, deadlineSeconds: 6) == nil) + } + + @Test("A refused TCP forward names the destination and carries the libssh2 detail") + func failedTCPMapsToForwardRefused() { + let outcome = ChannelOpenOutcome.failed(code: -21, message: "channel open failure") + + #expect( + outcome.tunnelError(destination: Self.tcp, deadlineSeconds: 6) + == .forwardRefused(destination: "db.internal:3306", detail: "channel open failure") + ) + } + + @Test("A refused socket forward keeps the socket-specific error and its path") + func failedSocketMapsToSocketForwardingRefused() { + let outcome = ChannelOpenOutcome.failed(code: -21, message: "channel open failure") + + #expect( + outcome.tunnelError(destination: Self.socket, deadlineSeconds: 6) + == .socketForwardingRefused( + path: "/var/run/mysqld/mysqld.sock", + detail: "channel open failure" + ) + ) + } + + @Test("A timed-out open reports the destination and the budget that expired") + func timedOutMapsToForwardTimedOut() { + #expect( + ChannelOpenOutcome.timedOut.tunnelError(destination: Self.tcp, deadlineSeconds: 6) + == .forwardTimedOut(destination: "db.internal:3306", seconds: 6) + ) + } + + @Test("A timed-out socket forward reports a timeout, not a refusal") + func timedOutSocketReportsTimeout() { + #expect( + ChannelOpenOutcome.timedOut.tunnelError(destination: Self.socket, deadlineSeconds: 10) + == .forwardTimedOut(destination: "/var/run/mysqld/mysqld.sock", seconds: 10) + ) + } +} + private final class CountBox: @unchecked Sendable { var value = 0 } diff --git a/TableProTests/Core/SSH/SSHForwardFailureRecorderTests.swift b/TableProTests/Core/SSH/SSHForwardFailureRecorderTests.swift new file mode 100644 index 000000000..154ff8648 --- /dev/null +++ b/TableProTests/Core/SSH/SSHForwardFailureRecorderTests.swift @@ -0,0 +1,126 @@ +// +// SSHForwardFailureRecorderTests.swift +// TableProTests +// +// Tests the seam that carries a forwarding failure out to the connect path. The tunnel +// computed the reason and then dropped it, so the database driver reported a greeting-read +// timeout naming no cause and the user had nothing to act on (#1981). +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSHForwardFailureRecorder") +struct SSHForwardFailureRecorderTests { + private static let tcp = SSHForwardDestination.tcp(host: "db.internal", port: 3_306) + private static let socket = SSHForwardDestination.unixSocket(path: "/var/run/mysqld/mysqld.sock") + + @Test("Nothing is recorded before a forward fails") + func startsEmpty() { + #expect(SSHForwardFailureRecorder().consume() == nil) + } + + @Test("An opened channel is not recorded as a failure") + func openedIsNotRecorded() { + let recorder = SSHForwardFailureRecorder() + let channel = OpaquePointer(bitPattern: 0xBEEF)! + + recorder.record(.opened(channel), destination: Self.tcp, deadlineSeconds: 6) + + #expect(recorder.consume() == nil) + } + + @Test("A cancelled open is not recorded as a failure") + func cancelledIsNotRecorded() { + let recorder = SSHForwardFailureRecorder() + + recorder.record(.cancelled, destination: Self.tcp, deadlineSeconds: 6) + + #expect(recorder.consume() == nil) + } + + @Test("A refused forward is recorded against its destination") + func refusedIsRecorded() { + let recorder = SSHForwardFailureRecorder() + + recorder.record( + .failed(code: -21, message: "channel open failure"), + destination: Self.tcp, + deadlineSeconds: 6 + ) + + #expect( + recorder.consume() == .forwardRefused( + destination: "db.internal:3306", + detail: "channel open failure" + ) + ) + } + + @Test("A refused socket forward keeps the socket-specific reason") + func refusedSocketIsRecorded() { + let recorder = SSHForwardFailureRecorder() + + recorder.record( + .failed(code: -21, message: "channel open failure"), + destination: Self.socket, + deadlineSeconds: 6 + ) + + #expect( + recorder.consume() == .socketForwardingRefused( + path: "/var/run/mysqld/mysqld.sock", + detail: "channel open failure" + ) + ) + } + + @Test("A timed-out open is recorded with the budget that expired") + func timedOutIsRecorded() { + let recorder = SSHForwardFailureRecorder() + + recorder.record(.timedOut, destination: Self.tcp, deadlineSeconds: 6) + + #expect(recorder.consume() == .forwardTimedOut(destination: "db.internal:3306", seconds: 6)) + } + + @Test("Consuming clears the reason so it cannot be blamed for a later failure") + func consumeClearsTheReason() { + let recorder = SSHForwardFailureRecorder() + recorder.record(.timedOut, destination: Self.tcp, deadlineSeconds: 6) + + #expect(recorder.consume() != nil) + #expect(recorder.consume() == nil) + } + + @Test("A later successful open does not clear a reason nobody has read yet") + func successDoesNotClearPendingFailure() { + let recorder = SSHForwardFailureRecorder() + let channel = OpaquePointer(bitPattern: 0xBEEF)! + + recorder.record(.timedOut, destination: Self.tcp, deadlineSeconds: 6) + recorder.record(.opened(channel), destination: Self.tcp, deadlineSeconds: 6) + + #expect(recorder.consume() == .forwardTimedOut(destination: "db.internal:3306", seconds: 6)) + } + + @Test("The most recent failure replaces an older one") + func latestFailureWins() { + let recorder = SSHForwardFailureRecorder() + + recorder.record(.timedOut, destination: Self.tcp, deadlineSeconds: 6) + recorder.record( + .failed(code: -21, message: "channel open failure"), + destination: Self.tcp, + deadlineSeconds: 6 + ) + + #expect( + recorder.consume() == .forwardRefused( + destination: "db.internal:3306", + detail: "channel open failure" + ) + ) + } +} diff --git a/TableProTests/Core/SSH/SSHKeepAliveResultTests.swift b/TableProTests/Core/SSH/SSHKeepAliveResultTests.swift new file mode 100644 index 000000000..19bd071b7 --- /dev/null +++ b/TableProTests/Core/SSH/SSHKeepAliveResultTests.swift @@ -0,0 +1,44 @@ +// +// SSHKeepAliveResultTests.swift +// TableProTests +// +// The keep-alive runs against a non-blocking session, so libssh2 can answer EAGAIN when the +// transport is busy. Treating that as fatal marked a healthy tunnel dead and dropped every +// connection through it. +// + +@testable import TablePro +import Testing + +@Suite("sshKeepAliveDidFail") +struct SSHKeepAliveResultTests { + @Test("A sent keep-alive is not a failure") + func successIsNotFailure() { + #expect(sshKeepAliveDidFail(SSHKeepAliveCode.sent) == false) + } + + @Test("A keep-alive that would block is not a failure") + func wouldBlockIsNotFailure() { + #expect(sshKeepAliveDidFail(SSHKeepAliveCode.wouldBlock) == false) + } + + @Test("A send error is a failure") + func sendErrorIsFailure() { + #expect(sshKeepAliveDidFail(SSHKeepAliveCode.socketSend)) + } + + @Test("A disconnected socket is a failure") + func disconnectIsFailure() { + #expect(sshKeepAliveDidFail(SSHKeepAliveCode.socketDisconnect)) + } + + @Test("Would-block is a distinct code, not an alias for success") + func wouldBlockIsNotSuccess() { + #expect(SSHKeepAliveCode.wouldBlock != SSHKeepAliveCode.sent) + } + + @Test("Any other libssh2 error is a failure") + func otherErrorsAreFailures() { + #expect(sshKeepAliveDidFail(-1)) + } +} diff --git a/TableProTests/Core/SSH/SSHTunnelDeadlineMarginTests.swift b/TableProTests/Core/SSH/SSHTunnelDeadlineMarginTests.swift new file mode 100644 index 000000000..b1b7edd3c --- /dev/null +++ b/TableProTests/Core/SSH/SSHTunnelDeadlineMarginTests.swift @@ -0,0 +1,40 @@ +// +// SSHTunnelDeadlineMarginTests.swift +// TableProTests +// +// Guards the margin that lets the tunnel name a forwarding failure before the database +// driver reports its own timeout. The previous budget matched the driver's 10 seconds +// exactly, and since the driver's clock starts when it dials while the tunnel's starts +// only once the accept is noticed, the tunnel could never win and the user saw an errno +// that names no cause (#1981, recurrence of #1883). +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSH tunnel deadline margin") +struct SSHTunnelDeadlineMarginTests { + /// Every bundled driver hardcodes a 10 second connect timeout. + private static let driverConnectTimeoutSeconds: TimeInterval = 10 + + @Test("The channel-open budget stays under every bundled driver's connect timeout") + func channelOpenBudgetStaysUnderDriverTimeout() { + #expect(LibSSH2Tunnel.channelOpenDeadlineSeconds < Self.driverConnectTimeoutSeconds) + } + + @Test("The margin is wide enough to absorb the accept poll and the scheduling hops") + func marginAbsorbsAcceptLatency() { + let margin = Self.driverConnectTimeoutSeconds - LibSSH2Tunnel.channelOpenDeadlineSeconds + let acceptPollSeconds = TimeInterval(LibSSH2Tunnel.acceptPollTimeoutMs) / 1_000 + + #expect(margin >= 2) + #expect(margin > acceptPollSeconds) + } + + @Test("The accept loop notices a waiting client well inside the margin") + func acceptPollIsShort() { + #expect(LibSSH2Tunnel.acceptPollTimeoutMs <= 200) + #expect(LibSSH2Tunnel.acceptPollTimeoutMs > 0) + } +} diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 1dd978f45..4ccae52aa 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -71,4 +71,32 @@ struct SSHTunnelErrorTests { #expect(error.errorDescription?.contains("AllowStreamLocalForwarding") == true) #expect(error.errorDescription?.contains("channel open failed") == true) } + + @Test("SSHTunnelError.forwardRefused names the destination, the sshd setting, and the detail") + func forwardRefusedDescription() { + let error = SSHTunnelError.forwardRefused( + destination: "db.internal:3306", + detail: "channel open failure" + ) + + #expect(error.errorDescription?.contains("db.internal:3306") == true) + #expect(error.errorDescription?.contains("AllowTcpForwarding") == true) + #expect(error.errorDescription?.contains("channel open failure") == true) + } + + @Test("SSHTunnelError.forwardRefused explains that the host resolves from the SSH server") + func forwardRefusedExplainsResolutionSide() { + let error = SSHTunnelError.forwardRefused(destination: "db.internal:3306", detail: "refused") + + #expect(error.errorDescription?.contains("127.0.0.1") == true) + #expect(error.errorDescription?.contains("localhost") == true) + } + + @Test("SSHTunnelError.forwardTimedOut names the destination and the seconds waited") + func forwardTimedOutDescription() { + let error = SSHTunnelError.forwardTimedOut(destination: "db.internal:3306", seconds: 6) + + #expect(error.errorDescription?.contains("db.internal:3306") == true) + #expect(error.errorDescription?.contains("6") == true) + } } diff --git a/TableProTests/Core/SSH/SSHUnsupportedDirectiveTests.swift b/TableProTests/Core/SSH/SSHUnsupportedDirectiveTests.swift new file mode 100644 index 000000000..d254fedab --- /dev/null +++ b/TableProTests/Core/SSH/SSHUnsupportedDirectiveTests.swift @@ -0,0 +1,89 @@ +// +// SSHUnsupportedDirectiveTests.swift +// TableProTests +// +// A ProxyCommand host connects through a command TablePro cannot run, so TablePro dials the +// hostname directly and reaches a different place than ssh would. That was dropped without a +// word, which leaves a "works in Terminal, fails here" report with nothing to go on. +// + +@testable import TablePro +import Testing + +@Suite("SSHUnsupportedDirective") +struct SSHUnsupportedDirectiveTests { + @Test("ProxyCommand is reported") + func proxyCommandChangesRouting() { + #expect(SSHUnsupportedDirective.changesRouting(key: "ProxyCommand")) + } + + @Test("The match ignores case, the way ssh_config keywords do") + func matchIsCaseInsensitive() { + #expect(SSHUnsupportedDirective.changesRouting(key: "proxycommand")) + #expect(SSHUnsupportedDirective.changesRouting(key: "PROXYCOMMAND")) + } + + @Test("ProxyUseFdpass is reported") + func proxyUseFdpassChangesRouting() { + #expect(SSHUnsupportedDirective.changesRouting(key: "ProxyUseFdpass")) + } + + @Test("Directives TablePro honours are not reported") + func supportedDirectivesAreNotReported() { + #expect(!SSHUnsupportedDirective.changesRouting(key: "ProxyJump")) + #expect(!SSHUnsupportedDirective.changesRouting(key: "HostName")) + #expect(!SSHUnsupportedDirective.changesRouting(key: "IdentityFile")) + } + + @Test("Directives that do not change where the connection lands stay quiet") + func harmlessDirectivesAreNotReported() { + #expect(!SSHUnsupportedDirective.changesRouting(key: "Compression")) + #expect(!SSHUnsupportedDirective.changesRouting(key: "ServerAliveInterval")) + #expect(!SSHUnsupportedDirective.changesRouting(key: "StrictHostKeyChecking")) + #expect(!SSHUnsupportedDirective.changesRouting(key: "")) + } +} + +@Suite("ProxyCommand parsing") +struct SSHProxyCommandParsingTests { + @Test("ProxyCommand parses as an unrecognized directive so it can be reported") + func proxyCommandIsUnrecognized() { + let document = SSHConfigParser.parseDocumentContent( + """ + Host bastioned + HostName db.internal + ProxyCommand ssh -W %h:%p bastion.example.com + """ + ) + + let directives = document.blocks.flatMap(\.directives) + let unrecognized = directives.compactMap { directive -> String? in + guard case .unrecognized(let key, _) = directive else { return nil } + return key + } + + #expect(unrecognized.contains("ProxyCommand")) + #expect(unrecognized.allSatisfy { SSHUnsupportedDirective.changesRouting(key: $0) }) + } + + @Test("ProxyJump stays a first-class directive and is not reported as unsupported") + func proxyJumpIsRecognized() { + let document = SSHConfigParser.parseDocumentContent( + """ + Host bastioned + HostName db.internal + ProxyJump bastion.example.com + """ + ) + + let directives = document.blocks.flatMap(\.directives) + #expect(directives.contains { directive in + guard case .proxyJump(let value) = directive else { return false } + return value == "bastion.example.com" + }) + #expect(!directives.contains { directive in + guard case .unrecognized = directive else { return false } + return true + }) + } +} diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index 1bd24b0a6..baea8f208 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -21,7 +21,7 @@ struct SwitchDatabaseTests { private func withConnectedCoordinator( _ body: (MainContentCoordinator, QueryTabManager) async throws -> Void ) async throws { - let connection = TestFixtures.makeConnection(type: .mysql, database: "db_a") + let connection = TestFixtures.makeConnection(database: "db_a", type: .mysql) let driver = MockDatabaseDriver(connection: connection) DatabaseManager.shared.injectSession( ConnectionSession(connection: connection, driver: driver), diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 5cdce9720..c2942b324 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -3,7 +3,7 @@ title: SSH Tunneling description: Route database connections through an SSH tunnel to reach servers in private networks --- -TablePro tunnels database connections through SSH using a built-in libssh2 client. No `ssh` binary or external setup is required. The tunnel opens a local forwarding port in the 60000-65000 range (this is why macOS may show a network permission prompt), sends a keep-alive every 30 seconds, and reconnects automatically if the tunnel dies. +TablePro tunnels database connections through SSH using a built-in libssh2 client. No `ssh` binary or external setup is required. The tunnel opens a local forwarding port in the 60000-65000 range (this is why macOS may show a network permission prompt), sends a keep-alive every 30 seconds, and reconnects automatically if the tunnel dies. Before handing that port to the database driver, TablePro checks that the SSH server can actually reach the destination, so a wrong host or a blocked forward fails with the real reason instead of a database timeout. The **SSH Tunnel** pane appears only for databases whose driver supports it. SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, DynamoDB, Elasticsearch, and Snowflake do not show it: they are reached over a local file, a loopback socket, or a vendor HTTP API. @@ -134,10 +134,14 @@ Paste a `scheme+ssh://` URL to create a connection with SSH pre-filled. The `+ss **Tunnel connects but the database fails**: the database host is resolved from the SSH server, not your Mac. Verify it from the server: `ssh user@server "nc -z db-host 5432"`. Also check that the database credentials differ from the SSH ones where they should. -**Tunnel connects but the database times out reading the server greeting**: the tunnel could not open its forwarding channel to the database. TablePro gives up after 10 seconds and closes the connection. Open Console.app and filter on `com.TablePro` to see the `LibSSH2Tunnel` message naming the destination and the reason. Check that the SSH server allows TCP forwarding (`AllowTcpForwarding yes`) and that it can reach the database host itself. +**"The SSH server could not reach \"**: the SSH connection is fine, but the SSH server could not open a connection to the database. Almost always the **Host** field. That address is resolved from the SSH server, not from your Mac, so if the database only listens on `127.0.0.1` (the default for MySQL and PostgreSQL), **Host** has to be `localhost`, not the server's public name or IP. Check what the database is bound to with `ss -lntp` on the server. If the host is right, check that `sshd_config` has `AllowTcpForwarding yes`. + +**"The SSH server did not open a forwarding channel to \"**: the destination took the connection attempt and never answered, which usually means a firewall or security group is dropping packets rather than refusing them. Test it from the SSH server itself: `ssh user@server "nc -zv db-host 3306"`. **Tunnel drops on idle networks**: TablePro already sends a libssh2 keep-alive every 30 seconds plus OS-level TCP keep-alives. If tunnels still drop, check the server's `ClientAliveInterval` and idle timeouts on firewalls or load balancers in between. **Firewall prompt on connect**: TablePro listens on a local port between 60000 and 65000 for the tunnel. Allow it. +**SSH fails right away against a server on your own network**: macOS 15 and later gate outbound connections to local network addresses behind a Local Network permission, and a denied app fails fast with "no route to host". Check TablePro under System Settings > Privacy & Security > Local Network. Servers reached over the internet, and anything on `127.0.0.1`, are not affected. + **SSH itself fails**: test the same host, user, and key in Terminal with `ssh -v user@server`. If that fails, the problem is server-side, not TablePro.