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

- 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
Expand Down
32 changes: 32 additions & 0 deletions TablePro/Core/SSH/Auth/NoneAuthenticator.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>?
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")
}
}
3 changes: 3 additions & 0 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,9 @@ internal enum LibSSH2TunnelFactory {
password: credentials.sshPassword,
totpProvider: totpProvider
)

case .none:
return NoneAuthenticator()
}
}

Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum AuthFailureReason: Sendable, Equatable {
case verificationCode
case privateKey
case agentRejected
case passwordlessRejected
case generic
}

Expand Down Expand Up @@ -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.")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -123,6 +124,7 @@ struct ConnectionURLParser {
sshPassword: nil,
usePrivateKey: nil,
useSSHAgent: nil,
sshNoAuth: nil,
agentSocket: nil,
connectionName: nil,
redisDatabase: nil,
Expand Down Expand Up @@ -228,6 +230,7 @@ struct ConnectionURLParser {
sshPassword: nil,
usePrivateKey: nil,
useSSHAgent: nil,
sshNoAuth: nil,
agentSocket: nil,
connectionName: ext.connectionName,
redisDatabase: redisDatabase,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -506,6 +510,7 @@ struct ConnectionURLParser {
sshPassword: nil,
usePrivateKey: nil,
useSSHAgent: nil,
sshNoAuth: nil,
agentSocket: nil,
connectionName: ext.connectionName,
redisDatabase: nil,
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal enum TransientConnectionFactory {
sshConfig.authMethod = .sshAgent
sshConfig.agentSocketPath = parsed.agentSocket ?? ""
}
if parsed.sshNoAuth == true {
sshConfig.authMethod = .none
}
}

var sslConfig = SSLConfiguration()
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Models/Connection/SSHTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add None to the shared SSH auth model

This introduces a new persisted raw value ("None") for Mac connections, but the shared model used by TableProMobile/TableProSync (Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift) still only recognizes password/privateKey/sshAgent/keyboardInteractive and falls back to password for anything else. A no-auth SSH connection synced or imported into the mobile/shared path will therefore be decoded as password auth, and a later mobile sync/export can write it back that way, silently downgrading a Tailscale/no-auth tunnel so it fails on Mac. Please preserve/add the None case in the shared model and mobile handling, even if mobile only reports it as unsupported.

Useful? React with 👍 / 👎.


var id: String { rawValue }

Expand All @@ -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")
}
}

Expand All @@ -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"
}
}
}
Expand Down Expand Up @@ -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) ?? []
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Connection/ConnectionSSHTunnelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Views/Connection/SSHProfileEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ?? ""
]

Expand Down
50 changes: 50 additions & 0 deletions TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions TableProTests/Core/SSH/SSHConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
17 changes: 17 additions & 0 deletions TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions TableProTests/Core/Utilities/ConnectionURLParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions docs/databases/ssh-tunneling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ To reuse one SSH config across several connections, save it as a profile with **
<Tab title="Keyboard Interactive">
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.
</Tab>
<Tab title="None">
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.
</Tab>
</Tabs>

### Two-Factor Authentication (TOTP)
Expand Down
Loading