Skip to content

Commit d609e74

Browse files
authored
feat: add Test Connection button to SSH profile editor (#437)
1 parent ebcded6 commit d609e74

File tree

6 files changed

+224
-19
lines changed

6 files changed

+224
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Test Connection button in SSH profile editor to validate SSH connectivity independently
13+
1014
### Changed
1115

1216
- Improve performance: faster sorting, lower memory usage, adaptive tab eviction

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,85 @@ internal enum LibSSH2TunnelFactory {
4141
) throws -> LibSSH2Tunnel {
4242
_ = initialized
4343

44-
// Connect to the SSH server (or first jump host if jumps are configured)
44+
let chain = try buildAuthenticatedChain(
45+
config: config,
46+
credentials: credentials,
47+
queueLabel: "com.TablePro.ssh.hop.\(connectionId.uuidString)"
48+
)
49+
50+
do {
51+
// Bind local listening socket
52+
let listenFD = try bindListenSocket(port: localPort)
53+
54+
let tunnel = LibSSH2Tunnel(
55+
connectionId: connectionId,
56+
localPort: localPort,
57+
session: chain.session,
58+
socketFD: chain.socketFD,
59+
listenFD: listenFD,
60+
jumpChain: chain.jumpHops.map { hop in
61+
LibSSH2Tunnel.JumpHop(
62+
session: hop.session,
63+
socket: hop.socket,
64+
channel: hop.channel,
65+
relayTask: hop.relayTask
66+
)
67+
}
68+
)
69+
70+
logger.info(
71+
"Tunnel created: \(config.host):\(config.port) -> 127.0.0.1:\(localPort) -> \(remoteHost):\(remotePort)"
72+
)
73+
74+
return tunnel
75+
} catch {
76+
cleanupChain(chain, reason: "Error")
77+
throw error
78+
}
79+
}
80+
81+
/// Test SSH connectivity without creating a full tunnel.
82+
/// Connects, performs handshake, verifies host key, authenticates, then cleans up.
83+
static func testConnection(
84+
config: SSHConfiguration,
85+
credentials: SSHTunnelCredentials
86+
) throws {
87+
_ = initialized
88+
89+
let chain = try buildAuthenticatedChain(
90+
config: config,
91+
credentials: credentials,
92+
queueLabel: "com.TablePro.ssh.test-hop"
93+
)
94+
95+
logger.info("SSH test connection successful to \(config.host):\(config.port)")
96+
cleanupChain(chain, reason: "Test complete")
97+
}
98+
99+
// MARK: - Shared Chain Builder
100+
101+
/// Result of building an authenticated SSH chain (possibly through jump hosts).
102+
private struct AuthenticatedChain {
103+
let session: OpaquePointer
104+
let socketFD: Int32
105+
let initialSocketFD: Int32
106+
let jumpHops: [HopInfo]
107+
108+
struct HopInfo {
109+
let session: OpaquePointer
110+
let socket: Int32
111+
let channel: OpaquePointer
112+
let relayTask: Task<Void, Never>?
113+
}
114+
}
115+
116+
/// Connects to the SSH server (possibly through jump hosts), verifies host keys,
117+
/// and authenticates at each hop. Returns the final authenticated session.
118+
private static func buildAuthenticatedChain(
119+
config: SSHConfiguration,
120+
credentials: SSHTunnelCredentials,
121+
queueLabel: String
122+
) throws -> AuthenticatedChain {
45123
let targetHost: String
46124
let targetPort: Int
47125

@@ -57,7 +135,7 @@ internal enum LibSSH2TunnelFactory {
57135

58136
do {
59137
let session = try createSession(socketFD: socketFD)
60-
var jumpHops: [LibSSH2Tunnel.JumpHop] = []
138+
var jumpHops: [AuthenticatedChain.HopInfo] = []
61139
var currentSession = session
62140
var currentSocketFD = socketFD
63141

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

77155
if !config.jumpHosts.isEmpty {
78156
let jumps = config.jumpHosts
79-
// First hop session is already `session` above
80157

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

109186
// Each hop's session needs its own serial queue for libssh2 calls
110187
let hopSessionQueue = DispatchQueue(
111-
label: "com.TablePro.ssh.hop.\(connectionId.uuidString).\(jumpIndex)",
188+
label: "\(queueLabel).\(jumpIndex)",
112189
qos: .utility
113190
)
114191

@@ -121,7 +198,7 @@ internal enum LibSSH2TunnelFactory {
121198
sessionQueue: hopSessionQueue
122199
)
123200

124-
let hop = LibSSH2Tunnel.JumpHop(
201+
let hop = AuthenticatedChain.HopInfo(
125202
session: currentSession,
126203
socket: currentSocketFD,
127204
channel: channel,
@@ -173,23 +250,12 @@ internal enum LibSSH2TunnelFactory {
173250
}
174251
}
175252

176-
// Bind local listening socket
177-
let listenFD = try bindListenSocket(port: localPort)
178-
179-
let tunnel = LibSSH2Tunnel(
180-
connectionId: connectionId,
181-
localPort: localPort,
253+
return AuthenticatedChain(
182254
session: currentSession,
183255
socketFD: currentSocketFD,
184-
listenFD: listenFD,
185-
jumpChain: jumpHops
256+
initialSocketFD: socketFD,
257+
jumpHops: jumpHops
186258
)
187-
188-
logger.info(
189-
"Tunnel created: \(config.host):\(config.port) -> 127.0.0.1:\(localPort) -> \(remoteHost):\(remotePort)"
190-
)
191-
192-
return tunnel
193259
} catch {
194260
// Clean up currentSession if it differs from all hop sessions
195261
// (happens when a nextSession was created but failed auth/verify)
@@ -223,6 +289,32 @@ internal enum LibSSH2TunnelFactory {
223289
}
224290
}
225291

292+
/// Clean up all resources in an authenticated chain.
293+
private static func cleanupChain(_ chain: AuthenticatedChain, reason: String) {
294+
// Disconnect the final session
295+
tablepro_libssh2_session_disconnect(chain.session, reason)
296+
libssh2_session_free(chain.session)
297+
if chain.socketFD != chain.initialSocketFD {
298+
Darwin.close(chain.socketFD)
299+
}
300+
301+
// Clean up jump hops in reverse order:
302+
// First pass: cancel relays and shutdown sockets to break relay loops
303+
for hop in chain.jumpHops.reversed() {
304+
hop.relayTask?.cancel()
305+
shutdown(hop.socket, SHUT_RDWR)
306+
}
307+
// Second pass: free channels, sessions, and close sockets
308+
// Note: relay task owns fds[0] via defer, so we only close hop.socket
309+
// (which is the SSH socket for that hop, not the relay socketpair fd)
310+
for hop in chain.jumpHops.reversed() {
311+
libssh2_channel_free(hop.channel)
312+
tablepro_libssh2_session_disconnect(hop.session, reason)
313+
libssh2_session_free(hop.session)
314+
Darwin.close(hop.socket)
315+
}
316+
}
317+
226318
// MARK: - TCP Connection
227319

228320
private static func connectTCP(host: String, port: Int) throws -> Int32 {

TablePro/Core/SSH/SSHTunnelManager.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,19 @@ actor SSHTunnelManager {
171171
}
172172
}
173173

174+
/// Test SSH connectivity without creating a tunnel.
175+
func testSSHProfile(
176+
config: SSHConfiguration,
177+
credentials: SSHTunnelCredentials
178+
) async throws {
179+
try await Task.detached {
180+
try LibSSH2TunnelFactory.testConnection(
181+
config: config,
182+
credentials: credentials
183+
)
184+
}.value
185+
}
186+
174187
/// Check if a tunnel exists for a connection
175188
func hasTunnel(connectionId: UUID) -> Bool {
176189
guard let tunnel = tunnels[connectionId] else { return false }

TablePro/Views/Connection/SSHProfileEditorView.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ struct SSHProfileEditorView: View {
4848
// Deletion
4949
@State private var showingDeleteConfirmation = false
5050
@State private var connectionsUsingProfile = 0
51+
@State private var isTesting = false
52+
@State private var testSucceeded = false
53+
@State private var testTask: Task<Void, Never>?
5154

5255
private var isStoredProfile: Bool {
5356
guard let profile = existingProfile else { return false }
@@ -95,6 +98,21 @@ struct SSHProfileEditorView: View {
9598
sshConfigEntries = SSHConfigParser.parse()
9699
loadExistingProfile()
97100
}
101+
.onChange(of: host) { _, _ in testSucceeded = false }
102+
.onChange(of: port) { _, _ in testSucceeded = false }
103+
.onChange(of: username) { _, _ in testSucceeded = false }
104+
.onChange(of: authMethod) { _, _ in testSucceeded = false }
105+
.onChange(of: sshPassword) { _, _ in testSucceeded = false }
106+
.onChange(of: privateKeyPath) { _, _ in testSucceeded = false }
107+
.onChange(of: keyPassphrase) { _, _ in testSucceeded = false }
108+
.onChange(of: agentSocketOption) { _, _ in testSucceeded = false }
109+
.onChange(of: customAgentSocketPath) { _, _ in testSucceeded = false }
110+
.onChange(of: totpMode) { _, _ in testSucceeded = false }
111+
.onChange(of: totpSecret) { _, _ in testSucceeded = false }
112+
.onChange(of: jumpHosts) { _, _ in testSucceeded = false }
113+
.onDisappear {
114+
testTask?.cancel()
115+
}
98116
}
99117

100118
// MARK: - Server Section
@@ -297,6 +315,20 @@ struct SSHProfileEditorView: View {
297315
}
298316
}
299317

318+
Button(action: testSSHConnection) {
319+
HStack(spacing: 6) {
320+
if isTesting {
321+
ProgressView()
322+
.controlSize(.small)
323+
} else {
324+
Image(systemName: testSucceeded ? "checkmark.circle.fill" : "bolt.horizontal")
325+
.foregroundStyle(testSucceeded ? .green : .secondary)
326+
}
327+
Text("Test Connection")
328+
}
329+
}
330+
.disabled(isTesting || !isValid)
331+
300332
Spacer()
301333

302334
Button("Cancel") { dismiss() }
@@ -386,6 +418,62 @@ struct SSHProfileEditorView: View {
386418
dismiss()
387419
}
388420

421+
private func testSSHConnection() {
422+
isTesting = true
423+
testSucceeded = false
424+
let window = NSApp.keyWindow
425+
426+
// Use .none for promptAtConnect during test — avoids showing an uncontextualized
427+
// TOTP modal. The SSH connection is still tested (auth without TOTP).
428+
let testTotpMode: TOTPMode = totpMode == .promptAtConnect ? .none : totpMode
429+
430+
let config = SSHConfiguration(
431+
enabled: true,
432+
host: host,
433+
port: Int(port) ?? 22,
434+
username: username,
435+
authMethod: authMethod,
436+
privateKeyPath: privateKeyPath,
437+
agentSocketPath: resolvedAgentSocketPath,
438+
jumpHosts: jumpHosts,
439+
totpMode: testTotpMode,
440+
totpAlgorithm: totpAlgorithm,
441+
totpDigits: totpDigits,
442+
totpPeriod: totpPeriod
443+
)
444+
445+
let credentials = SSHTunnelCredentials(
446+
sshPassword: sshPassword.isEmpty ? nil : sshPassword,
447+
keyPassphrase: keyPassphrase.isEmpty ? nil : keyPassphrase,
448+
totpSecret: totpSecret.isEmpty ? nil : totpSecret,
449+
totpProvider: nil
450+
)
451+
452+
testTask = Task {
453+
do {
454+
try await SSHTunnelManager.shared.testSSHProfile(
455+
config: config,
456+
credentials: credentials
457+
)
458+
await MainActor.run {
459+
isTesting = false
460+
testSucceeded = true
461+
}
462+
} catch {
463+
guard !Task.isCancelled else { return }
464+
await MainActor.run {
465+
isTesting = false
466+
testSucceeded = false
467+
AlertHelper.showErrorSheet(
468+
title: String(localized: "SSH Connection Test Failed"),
469+
message: error.localizedDescription,
470+
window: window
471+
)
472+
}
473+
}
474+
}
475+
}
476+
389477
private func deleteProfile() {
390478
guard let profile = existingProfile else { return }
391479
SSHProfileStorage.shared.deleteProfile(profile)

docs/databases/ssh-tunneling.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ This format is compatible with TablePlus SSH connection URLs, so you can paste U
425425

426426
## Troubleshooting
427427

428+
<Tip>
429+
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.
430+
</Tip>
431+
428432
### Connection Refused
429433

430434
**Symptoms**: "Connection refused" when testing SSH tunnel

docs/features/ssh-profiles.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Create a profile in any connection's **SSH Tunnel** tab by clicking **Create New
1111

1212
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...**.
1313

14+
## Testing a Profile
15+
16+
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.
17+
1418
## iCloud Sync
1519

1620
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.

0 commit comments

Comments
 (0)