diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc88a2db..2cfc64703 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 + +- SSH tunnels can authenticate with no password or key, for hosts that handle SSH auth themselves like Tailscale SSH. Pick **None** as the SSH auth method. (#1907) + ## [0.58.0] - 2026-07-18 ### Added diff --git a/TablePro/Core/SSH/Auth/NoneAuthenticator.swift b/TablePro/Core/SSH/Auth/NoneAuthenticator.swift new file mode 100644 index 000000000..e9d656be7 --- /dev/null +++ b/TablePro/Core/SSH/Auth/NoneAuthenticator.swift @@ -0,0 +1,32 @@ +// +// NoneAuthenticator.swift +// TablePro +// + +import Foundation +import os + +import CLibSSH2 + +internal struct NoneAuthenticator: SSHAuthenticator { + private static let logger = Logger(subsystem: "com.TablePro", category: "NoneAuthenticator") + + func authenticate(session: OpaquePointer, username: String) throws { + let authList = libssh2_userauth_list(session, username, UInt32(username.utf8.count)) + guard authList == nil else { + Self.logger.error("Passwordless auth rejected; server requires credentials") + throw SSHTunnelError.authenticationFailed(reason: .passwordlessRejected) + } + + guard libssh2_userauth_authenticated(session) != 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" + Self.logger.error("Passwordless auth failed: \(detail)") + throw SSHTunnelError.authenticationFailed(reason: .passwordlessRejected) + } + + Self.logger.info("Passwordless authentication succeeded") + } +} diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 6e451111c..cd20f0a92 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -535,6 +535,9 @@ internal enum LibSSH2TunnelFactory { password: credentials.sshPassword, totpProvider: totpProvider ) + + case .none: + return NoneAuthenticator() } } diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index d21b46754..a7da9f36d 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -16,6 +16,7 @@ enum AuthFailureReason: Sendable, Equatable { case verificationCode case privateKey case agentRejected + case passwordlessRejected case generic } @@ -48,6 +49,8 @@ enum SSHTunnelError: Error, LocalizedError, Equatable { return String(localized: "SSH private key rejected. Check the key file or passphrase.") case .agentRejected: return String(localized: "SSH agent did not authenticate. Run ssh-add -l to check loaded keys.") + case .passwordlessRejected: + return String(localized: "The SSH server did not accept passwordless authentication. Choose Password, Private Key, or SSH Agent.") case .generic: return String(localized: "SSH authentication failed. Check your credentials or private key.") } diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 3a9fe17a9..ab36eeb29 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -195,6 +195,10 @@ struct ConnectionURLFormatter { } } + if ssh.enabled && ssh.authMethod == .none { + params.append("sshNoAuth=true") + } + if let sslParam = sslModeParam(connection.sslConfig.mode) { params.append("sslmode=\(sslParam)") } diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 51badce98..c7ed235e5 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -21,6 +21,7 @@ struct ParsedConnectionURL { let sshPassword: String? let usePrivateKey: Bool? let useSSHAgent: Bool? + let sshNoAuth: Bool? let agentSocket: String? let connectionName: String? let redisDatabase: Int? @@ -123,6 +124,7 @@ struct ConnectionURLParser { sshPassword: nil, usePrivateKey: nil, useSSHAgent: nil, + sshNoAuth: nil, agentSocket: nil, connectionName: nil, redisDatabase: nil, @@ -228,6 +230,7 @@ struct ConnectionURLParser { sshPassword: nil, usePrivateKey: nil, useSSHAgent: nil, + sshNoAuth: nil, agentSocket: nil, connectionName: ext.connectionName, redisDatabase: redisDatabase, @@ -408,6 +411,7 @@ struct ConnectionURLParser { sshPassword: sshPassword, usePrivateKey: ext.usePrivateKey, useSSHAgent: ext.useSSHAgent, + sshNoAuth: ext.sshNoAuth, agentSocket: ext.agentSocket, connectionName: ext.connectionName, redisDatabase: nil, @@ -506,6 +510,7 @@ struct ConnectionURLParser { sshPassword: nil, usePrivateKey: nil, useSSHAgent: nil, + sshNoAuth: nil, agentSocket: nil, connectionName: ext.connectionName, redisDatabase: nil, @@ -534,6 +539,7 @@ struct ConnectionURLParser { var connectionName: String? var usePrivateKey: Bool? var useSSHAgent: Bool? + var sshNoAuth: Bool? var agentSocket: String? var statusColor: String? var envTag: String? @@ -577,6 +583,10 @@ struct ConnectionURLParser { ext.useSSHAgent = value.lowercased() == "true" continue } + if keyStr == "sshnoauth" { + ext.sshNoAuth = value.lowercased() == "true" + continue + } if keyStr == "agentsocket" { ext.agentSocket = value.removingPercentEncoding ?? value continue diff --git a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift index 0b81dc289..de0672399 100644 --- a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift +++ b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift @@ -22,6 +22,9 @@ internal enum TransientConnectionFactory { sshConfig.authMethod = .sshAgent sshConfig.agentSocketPath = parsed.agentSocket ?? "" } + if parsed.sshNoAuth == true { + sshConfig.authMethod = .none + } } var sslConfig = SSLConfiguration() diff --git a/TablePro/Models/Connection/SSHTypes.swift b/TablePro/Models/Connection/SSHTypes.swift index 14d0e4dfa..20485cad6 100644 --- a/TablePro/Models/Connection/SSHTypes.swift +++ b/TablePro/Models/Connection/SSHTypes.swift @@ -10,6 +10,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case privateKey = "Private Key" case sshAgent = "SSH Agent" case keyboardInteractive = "Keyboard Interactive" + case none = "None" var id: String { rawValue } @@ -19,6 +20,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case .privateKey: return String(localized: "Private Key") case .sshAgent: return String(localized: "SSH Agent") case .keyboardInteractive: return String(localized: "Keyboard Interactive") + case .none: return String(localized: "None") } } @@ -28,6 +30,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case .privateKey: return "doc.text.fill" case .sshAgent: return "person.badge.key.fill" case .keyboardInteractive: return "keyboard" + case .none: return "key.slash" } } } @@ -140,7 +143,7 @@ extension SSHConfiguration { host = try container.decode(String.self, forKey: .host) port = try container.decodeIfPresent(Int.self, forKey: .port) username = try container.decode(String.self, forKey: .username) - authMethod = try container.decode(SSHAuthMethod.self, forKey: .authMethod) + authMethod = (try? container.decodeIfPresent(SSHAuthMethod.self, forKey: .authMethod)) ?? .password privateKeyPath = try container.decode(String.self, forKey: .privateKeyPath) agentSocketPath = try container.decode(String.self, forKey: .agentSocketPath) jumpHosts = try container.decodeIfPresent([SSHJumpHost].self, forKey: .jumpHosts) ?? [] diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index 7fd66791b..52bd72da8 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -202,6 +202,10 @@ struct ConnectionSSHTunnelView: View { Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) .font(.caption) .foregroundStyle(.secondary) + } else if sshState.authMethod == .none { + Text("No credentials are sent. Use this when the server handles authentication itself, such as a Tailscale SSH host.") + .font(.caption) + .foregroundStyle(.secondary) } else { LabeledContent(String(localized: "Key File")) { HStack { diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 44e26d424..9bd7903e4 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -56,7 +56,7 @@ struct SSHProfileEditorView: View { let hostValid = !host.trimmingCharacters(in: .whitespaces).isEmpty let portValid = port.isEmpty || (Int(port).map { (1...65_535).contains($0) } ?? false) let authValid = authMethod == .password || authMethod == .sshAgent - || authMethod == .keyboardInteractive || !privateKeyPath.isEmpty + || authMethod == .keyboardInteractive || authMethod == .none || !privateKeyPath.isEmpty let jumpValid = jumpHosts.allSatisfy(\.isValid) return nameValid && hostValid && portValid && authValid && jumpValid } @@ -167,6 +167,10 @@ struct SSHProfileEditorView: View { Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) .font(.caption) .foregroundStyle(.secondary) + } else if authMethod == .none { + Text("No credentials are sent. Use this when the server handles authentication itself, such as a Tailscale SSH host.") + .font(.caption) + .foregroundStyle(.secondary) } else { LabeledContent(String(localized: "Key File")) { HStack { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 27df8f2be..e115e41d9 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -786,6 +786,9 @@ final class ConnectionFormCoordinator { ssh.state.authMethod = .sshAgent ssh.state.applyAgentSocketPath(parsed.agentSocket ?? "") } + if parsed.sshNoAuth == true { + ssh.state.authMethod = .none + } } if let multiHost = parsed.multiHost, !multiHost.isEmpty { diff --git a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift index dce266895..1229047bb 100644 --- a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift +++ b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift @@ -51,6 +51,15 @@ struct AuthFailureReasonTests { #expect(description.localizedCaseInsensitiveContains("agent")) } + @Test("Passwordless reason points at the server, not the user's credentials") + func passwordlessRejectedMessage() { + let error = SSHTunnelError.authenticationFailed(reason: .passwordlessRejected) + let description = error.errorDescription ?? "" + + #expect(description.localizedCaseInsensitiveContains("passwordless")) + #expect(!description.localizedCaseInsensitiveContains("verification code")) + } + @Test("Generic reason keeps the original wording for unknown cases") func genericMessage() { let error = SSHTunnelError.authenticationFailed(reason: .generic) @@ -64,6 +73,7 @@ struct AuthFailureReasonTests { SSHTunnelError.authenticationFailed(reason: .verificationCode).errorDescription ?? "", SSHTunnelError.authenticationFailed(reason: .privateKey).errorDescription ?? "", SSHTunnelError.authenticationFailed(reason: .agentRejected).errorDescription ?? "", + SSHTunnelError.authenticationFailed(reason: .passwordlessRejected).errorDescription ?? "", SSHTunnelError.authenticationFailed(reason: .generic).errorDescription ?? "" ] diff --git a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift index 70be6ecc0..f4324692b 100644 --- a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift +++ b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift @@ -118,6 +118,56 @@ struct BuildAuthenticatorTests { #expect(kbdint.totpProvider == nil) } + @Test("None auth method returns a NoneAuthenticator") + func noneReturnsNoneAuthenticator() throws { + var config = SSHConfiguration( + enabled: true, + host: "ssh.example.com", + username: "alice", + authMethod: .none + ) + config.totpMode = .none + let credentials = SSHTunnelCredentials( + sshPassword: nil, + keyPassphrase: nil, + totpSecret: nil, + totpProvider: nil + ) + + let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( + config: config, + resolved: resolved(), + credentials: credentials + ) + + #expect(authenticator is NoneAuthenticator) + } + + @Test("Password auth method with no password throws before any libssh2 call") + func passwordWithoutCredentialThrows() { + var config = SSHConfiguration( + enabled: true, + host: "ssh.example.com", + username: "alice", + authMethod: .password + ) + config.totpMode = .none + let credentials = SSHTunnelCredentials( + sshPassword: nil, + keyPassphrase: nil, + totpSecret: nil, + totpProvider: nil + ) + + #expect(throws: SSHTunnelError.authenticationFailed(reason: .password)) { + try LibSSH2TunnelFactory.buildAuthenticator( + config: config, + resolved: resolved(), + credentials: credentials + ) + } + } + @Test("Keyboard-Interactive auth method passes the password through directly") func keyboardInteractivePassesPassword() throws { var config = SSHConfiguration( diff --git a/TableProTests/Core/SSH/SSHConfigurationTests.swift b/TableProTests/Core/SSH/SSHConfigurationTests.swift index 8699635fd..c46186b80 100644 --- a/TableProTests/Core/SSH/SSHConfigurationTests.swift +++ b/TableProTests/Core/SSH/SSHConfigurationTests.swift @@ -200,6 +200,42 @@ struct SSHConfigurationTests { #expect(config.enabled == true) } + @Test("None auth method is valid and round-trips through Codable") + func testNoneAuthRoundTrips() throws { + let config = SSHConfiguration( + enabled: true, host: "tailscale.example.com", username: "admin", + authMethod: .none + ) + #expect(config.isValid == true) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(SSHConfiguration.self, from: data) + #expect(decoded.authMethod == .none) + #expect(decoded.host == "tailscale.example.com") + } + + @Test("Unknown authMethod falls back to Password and preserves the tunnel config") + func testUnknownAuthMethodFallsBackToPassword() throws { + let jsonString = """ + { + "enabled": true, + "host": "example.com", + "port": 2222, + "username": "admin", + "authMethod": "FutureMethodFromNewerApp", + "privateKeyPath": "", + "agentSocketPath": "" + } + """ + let json = Data(jsonString.utf8) + + let config = try JSONDecoder().decode(SSHConfiguration.self, from: json) + #expect(config.authMethod == .password) + #expect(config.host == "example.com") + #expect(config.port == 2_222) + #expect(config.enabled == true) + } + @Test("Decoding ignores legacy useSSHConfig field") func testLegacyUseSSHConfigIgnored() throws { let jsonString = """ diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index 330470e6b..d806edd60 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -223,6 +223,23 @@ struct ConnectionURLFormatterTests { #expect(url.contains("usePrivateKey=true")) } + @Test("SSH with no auth adds query param") + func testSSHNoAuth() { + var sshConfig = SSHConfiguration() + sshConfig.enabled = true + sshConfig.host = "sshhost" + sshConfig.port = 22 + sshConfig.username = "root" + sshConfig.authMethod = .none + + let conn = DatabaseConnection( + name: "", host: "localhost", port: 3_306, database: "db", + username: "user", type: .mysql, sshConfig: sshConfig + ) + let url = ConnectionURLFormatter.format(conn, password: "pass", sshPassword: nil) + #expect(url.contains("sshNoAuth=true")) + } + // MARK: - SSL Mode @Test("SSL mode included in query string") diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index bf38ef48c..8aefcace0 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -445,6 +445,15 @@ struct ConnectionURLParserTests { #expect(parsed.usePrivateKey == true) } + @Test("SSH URL with sshNoAuth") + func testSSHURLWithNoAuth() { + let result = ConnectionURLParser.parse("mysql+ssh://root@host:22/user:pass@localhost/db?sshNoAuth=true") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.sshNoAuth == true) + } + @Test("SSH URL with SSH password") func testSSHURLWithSSHPassword() { let result = ConnectionURLParser.parse("mysql+ssh://root:sshpass@jumphost:22/dbuser:dbpass@localhost/db") diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 62b707adf..978870f2d 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -57,6 +57,9 @@ To reuse one SSH config across several connections, save it as a profile with ** Sends your password through SSH's keyboard-interactive challenge-response. Use this when the server rejects plain password auth, which is common with PAM-based setups. + + Sends no password or key. Use this when the server authenticates the connection itself, such as a [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) host or a bastion configured for passwordless access. If the server still requires credentials, the connection fails with a message telling you to pick another method. + ### Two-Factor Authentication (TOTP)