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 @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions TablePro/Core/SSH/LibSSH2ForwardChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
}
Expand Down Expand Up @@ -76,5 +80,15 @@ internal enum LibSSH2ForwardChannel {
}
}

static func lastErrorMessage(session: OpaquePointer) -> String {
var messagePointer: UnsafeMutablePointer<CChar>?
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"
}
82 changes: 60 additions & 22 deletions TablePro/Core/SSH/LibSSH2Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)")
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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)
}
}
}
Expand All @@ -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,
Expand All @@ -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 }
Expand Down
76 changes: 52 additions & 24 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)

Expand Down Expand Up @@ -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<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)
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(
Expand Down
Loading
Loading