diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a271d8..ced2f005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Test Connection button in SSH profile editor to validate SSH connectivity independently + ### Changed - Improve performance: faster sorting, lower memory usage, adaptive tab eviction diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 9df69e7a..12b7210a 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -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? + } + } + + /// 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 @@ -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 @@ -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.. 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) @@ -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 { diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 7c8f3f3c..81c16ae4 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -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 } diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 80069f2c..3bb6fdfc 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -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? private var isStoredProfile: Bool { guard let profile = existingProfile else { return false } @@ -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 @@ -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() } @@ -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) diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index c36ea62c..459128ba 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -425,6 +425,10 @@ This format is compatible with TablePlus SSH connection URLs, so you can paste U ## Troubleshooting + +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. + + ### Connection Refused **Symptoms**: "Connection refused" when testing SSH tunnel diff --git a/docs/features/ssh-profiles.mdx b/docs/features/ssh-profiles.mdx index c2d76076..2d30d540 100644 --- a/docs/features/ssh-profiles.mdx +++ b/docs/features/ssh-profiles.mdx @@ -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.