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

Filter by extension

Filter by extension

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

## [Unreleased]

### Added

- Test Connection button in SSH profile editor to validate SSH connectivity independently

### Changed

- Improve performance: faster sorting, lower memory usage, adaptive tab eviction
Expand Down
130 changes: 111 additions & 19 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,85 @@ internal enum LibSSH2TunnelFactory {
) throws -> LibSSH2Tunnel {
_ = initialized

// Connect to the SSH server (or first jump host if jumps are configured)
let chain = try buildAuthenticatedChain(
config: config,
credentials: credentials,
queueLabel: "com.TablePro.ssh.hop.\(connectionId.uuidString)"
)

do {
// Bind local listening socket
let listenFD = try bindListenSocket(port: localPort)

let tunnel = LibSSH2Tunnel(
connectionId: connectionId,
localPort: localPort,
session: chain.session,
socketFD: chain.socketFD,
listenFD: listenFD,
jumpChain: chain.jumpHops.map { hop in
LibSSH2Tunnel.JumpHop(
session: hop.session,
socket: hop.socket,
channel: hop.channel,
relayTask: hop.relayTask
)
}
)

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

return tunnel
} catch {
cleanupChain(chain, reason: "Error")
throw error
}
}

/// Test SSH connectivity without creating a full tunnel.
/// Connects, performs handshake, verifies host key, authenticates, then cleans up.
static func testConnection(
config: SSHConfiguration,
credentials: SSHTunnelCredentials
) throws {
_ = initialized

let chain = try buildAuthenticatedChain(
config: config,
credentials: credentials,
queueLabel: "com.TablePro.ssh.test-hop"
)

logger.info("SSH test connection successful to \(config.host):\(config.port)")
cleanupChain(chain, reason: "Test complete")
}

// MARK: - Shared Chain Builder

/// Result of building an authenticated SSH chain (possibly through jump hosts).
private struct AuthenticatedChain {
let session: OpaquePointer
let socketFD: Int32
let initialSocketFD: Int32
let jumpHops: [HopInfo]

struct HopInfo {
let session: OpaquePointer
let socket: Int32
let channel: OpaquePointer
let relayTask: Task<Void, Never>?
}
}

/// Connects to the SSH server (possibly through jump hosts), verifies host keys,
/// and authenticates at each hop. Returns the final authenticated session.
private static func buildAuthenticatedChain(
config: SSHConfiguration,
credentials: SSHTunnelCredentials,
queueLabel: String
) throws -> AuthenticatedChain {
let targetHost: String
let targetPort: Int

Expand All @@ -57,7 +135,7 @@ internal enum LibSSH2TunnelFactory {

do {
let session = try createSession(socketFD: socketFD)
var jumpHops: [LibSSH2Tunnel.JumpHop] = []
var jumpHops: [AuthenticatedChain.HopInfo] = []
var currentSession = session
var currentSocketFD = socketFD

Expand All @@ -76,7 +154,6 @@ internal enum LibSSH2TunnelFactory {

if !config.jumpHosts.isEmpty {
let jumps = config.jumpHosts
// First hop session is already `session` above

for jumpIndex in 0..<jumps.count {
// Determine next hop target
Expand Down Expand Up @@ -108,7 +185,7 @@ internal enum LibSSH2TunnelFactory {

// Each hop's session needs its own serial queue for libssh2 calls
let hopSessionQueue = DispatchQueue(
label: "com.TablePro.ssh.hop.\(connectionId.uuidString).\(jumpIndex)",
label: "\(queueLabel).\(jumpIndex)",
qos: .utility
)

Expand All @@ -121,7 +198,7 @@ internal enum LibSSH2TunnelFactory {
sessionQueue: hopSessionQueue
)

let hop = LibSSH2Tunnel.JumpHop(
let hop = AuthenticatedChain.HopInfo(
session: currentSession,
socket: currentSocketFD,
channel: channel,
Expand Down Expand Up @@ -173,23 +250,12 @@ internal enum LibSSH2TunnelFactory {
}
}

// Bind local listening socket
let listenFD = try bindListenSocket(port: localPort)

let tunnel = LibSSH2Tunnel(
connectionId: connectionId,
localPort: localPort,
return AuthenticatedChain(
session: currentSession,
socketFD: currentSocketFD,
listenFD: listenFD,
jumpChain: jumpHops
initialSocketFD: socketFD,
jumpHops: jumpHops
)

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

return tunnel
} catch {
// Clean up currentSession if it differs from all hop sessions
// (happens when a nextSession was created but failed auth/verify)
Expand Down Expand Up @@ -223,6 +289,32 @@ internal enum LibSSH2TunnelFactory {
}
}

/// Clean up all resources in an authenticated chain.
private static func cleanupChain(_ chain: AuthenticatedChain, reason: String) {
// Disconnect the final session
tablepro_libssh2_session_disconnect(chain.session, reason)
libssh2_session_free(chain.session)
if chain.socketFD != chain.initialSocketFD {
Darwin.close(chain.socketFD)
}

// Clean up jump hops in reverse order:
// First pass: cancel relays and shutdown sockets to break relay loops
for hop in chain.jumpHops.reversed() {
hop.relayTask?.cancel()
shutdown(hop.socket, SHUT_RDWR)
}
// Second pass: free channels, sessions, and close sockets
// Note: relay task owns fds[0] via defer, so we only close hop.socket
// (which is the SSH socket for that hop, not the relay socketpair fd)
for hop in chain.jumpHops.reversed() {
libssh2_channel_free(hop.channel)
tablepro_libssh2_session_disconnect(hop.session, reason)
libssh2_session_free(hop.session)
Darwin.close(hop.socket)
}
}

// MARK: - TCP Connection

private static func connectTCP(host: String, port: Int) throws -> Int32 {
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ actor SSHTunnelManager {
}
}

/// Test SSH connectivity without creating a tunnel.
func testSSHProfile(
config: SSHConfiguration,
credentials: SSHTunnelCredentials
) async throws {
try await Task.detached {
try LibSSH2TunnelFactory.testConnection(
config: config,
credentials: credentials
)
}.value
}

/// Check if a tunnel exists for a connection
func hasTunnel(connectionId: UUID) -> Bool {
guard let tunnel = tunnels[connectionId] else { return false }
Expand Down
88 changes: 88 additions & 0 deletions TablePro/Views/Connection/SSHProfileEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ struct SSHProfileEditorView: View {
// Deletion
@State private var showingDeleteConfirmation = false
@State private var connectionsUsingProfile = 0
@State private var isTesting = false
@State private var testSucceeded = false
@State private var testTask: Task<Void, Never>?

private var isStoredProfile: Bool {
guard let profile = existingProfile else { return false }
Expand Down Expand Up @@ -95,6 +98,21 @@ struct SSHProfileEditorView: View {
sshConfigEntries = SSHConfigParser.parse()
loadExistingProfile()
}
.onChange(of: host) { _, _ in testSucceeded = false }
.onChange(of: port) { _, _ in testSucceeded = false }
.onChange(of: username) { _, _ in testSucceeded = false }
.onChange(of: authMethod) { _, _ in testSucceeded = false }
.onChange(of: sshPassword) { _, _ in testSucceeded = false }
.onChange(of: privateKeyPath) { _, _ in testSucceeded = false }
.onChange(of: keyPassphrase) { _, _ in testSucceeded = false }
.onChange(of: agentSocketOption) { _, _ in testSucceeded = false }
.onChange(of: customAgentSocketPath) { _, _ in testSucceeded = false }
.onChange(of: totpMode) { _, _ in testSucceeded = false }
.onChange(of: totpSecret) { _, _ in testSucceeded = false }
.onChange(of: jumpHosts) { _, _ in testSucceeded = false }
.onDisappear {
testTask?.cancel()
}
}

// MARK: - Server Section
Expand Down Expand Up @@ -297,6 +315,20 @@ struct SSHProfileEditorView: View {
}
}

Button(action: testSSHConnection) {
HStack(spacing: 6) {
if isTesting {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: testSucceeded ? "checkmark.circle.fill" : "bolt.horizontal")
.foregroundStyle(testSucceeded ? .green : .secondary)
}
Text("Test Connection")
}
}
.disabled(isTesting || !isValid)

Spacer()

Button("Cancel") { dismiss() }
Expand Down Expand Up @@ -386,6 +418,62 @@ struct SSHProfileEditorView: View {
dismiss()
}

private func testSSHConnection() {
isTesting = true
testSucceeded = false
let window = NSApp.keyWindow

// Use .none for promptAtConnect during test — avoids showing an uncontextualized
// TOTP modal. The SSH connection is still tested (auth without TOTP).
let testTotpMode: TOTPMode = totpMode == .promptAtConnect ? .none : totpMode

let config = SSHConfiguration(
enabled: true,
host: host,
port: Int(port) ?? 22,
username: username,
authMethod: authMethod,
privateKeyPath: privateKeyPath,
agentSocketPath: resolvedAgentSocketPath,
jumpHosts: jumpHosts,
totpMode: testTotpMode,
totpAlgorithm: totpAlgorithm,
totpDigits: totpDigits,
totpPeriod: totpPeriod
)

let credentials = SSHTunnelCredentials(
sshPassword: sshPassword.isEmpty ? nil : sshPassword,
keyPassphrase: keyPassphrase.isEmpty ? nil : keyPassphrase,
totpSecret: totpSecret.isEmpty ? nil : totpSecret,
totpProvider: nil
)

testTask = Task {
do {
try await SSHTunnelManager.shared.testSSHProfile(
config: config,
credentials: credentials
)
await MainActor.run {
isTesting = false
testSucceeded = true
}
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
isTesting = false
testSucceeded = false
AlertHelper.showErrorSheet(
title: String(localized: "SSH Connection Test Failed"),
message: error.localizedDescription,
window: window
)
}
}
}
}

private func deleteProfile() {
guard let profile = existingProfile else { return }
SSHProfileStorage.shared.deleteProfile(profile)
Expand Down
4 changes: 4 additions & 0 deletions docs/databases/ssh-tunneling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ This format is compatible with TablePlus SSH connection URLs, so you can paste U

## Troubleshooting

<Tip>
If you use SSH profiles, click **Test Connection** in the profile editor to verify SSH connectivity independently from the database connection. This helps isolate whether the problem is SSH or database-level.
</Tip>

### Connection Refused

**Symptoms**: "Connection refused" when testing SSH tunnel
Expand Down
4 changes: 4 additions & 0 deletions docs/features/ssh-profiles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Create a profile in any connection's **SSH Tunnel** tab by clicking **Create New

To edit a profile, select it and click **Edit Profile...**. To delete, click **Edit Profile...** then **Delete Profile**. You can also save an existing inline SSH config as a profile by clicking **Save Current as Profile...**.

## Testing a Profile

Click **Test Connection** in the profile editor to verify your SSH settings without using the profile in a database connection. TablePro connects to the SSH server, performs the handshake, verifies the host key, and authenticates. A green checkmark appears on success; an error dialog shows the failure reason otherwise.

## iCloud Sync

SSH profiles sync across Macs when iCloud Sync is enabled with the **SSH Profiles** toggle on in **Settings > Sync**. SSH passwords and key passphrases stay local by default. Turn on **Password sync** in **Settings > Sync** to sync credentials via iCloud Keychain.
Loading