Skip to content

Commit cffb1c8

Browse files
authored
feat(ssh): add None auth method for passwordless tunnels (#1909)
1 parent 4899354 commit cffb1c8

17 files changed

Lines changed: 200 additions & 2 deletions

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+
- 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)
13+
1014
## [0.58.0] - 2026-07-18
1115

1216
### Added
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// NoneAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
import CLibSSH2
10+
11+
internal struct NoneAuthenticator: SSHAuthenticator {
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "NoneAuthenticator")
13+
14+
func authenticate(session: OpaquePointer, username: String) throws {
15+
let authList = libssh2_userauth_list(session, username, UInt32(username.utf8.count))
16+
guard authList == nil else {
17+
Self.logger.error("Passwordless auth rejected; server requires credentials")
18+
throw SSHTunnelError.authenticationFailed(reason: .passwordlessRejected)
19+
}
20+
21+
guard libssh2_userauth_authenticated(session) != 0 else {
22+
var msgPtr: UnsafeMutablePointer<CChar>?
23+
var msgLen: Int32 = 0
24+
libssh2_session_last_error(session, &msgPtr, &msgLen, 0)
25+
let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error"
26+
Self.logger.error("Passwordless auth failed: \(detail)")
27+
throw SSHTunnelError.authenticationFailed(reason: .passwordlessRejected)
28+
}
29+
30+
Self.logger.info("Passwordless authentication succeeded")
31+
}
32+
}

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,9 @@ internal enum LibSSH2TunnelFactory {
535535
password: credentials.sshPassword,
536536
totpProvider: totpProvider
537537
)
538+
539+
case .none:
540+
return NoneAuthenticator()
538541
}
539542
}
540543

TablePro/Core/SSH/SSHTunnelManager.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum AuthFailureReason: Sendable, Equatable {
1616
case verificationCode
1717
case privateKey
1818
case agentRejected
19+
case passwordlessRejected
1920
case generic
2021
}
2122

@@ -48,6 +49,8 @@ enum SSHTunnelError: Error, LocalizedError, Equatable {
4849
return String(localized: "SSH private key rejected. Check the key file or passphrase.")
4950
case .agentRejected:
5051
return String(localized: "SSH agent did not authenticate. Run ssh-add -l to check loaded keys.")
52+
case .passwordlessRejected:
53+
return String(localized: "The SSH server did not accept passwordless authentication. Choose Password, Private Key, or SSH Agent.")
5154
case .generic:
5255
return String(localized: "SSH authentication failed. Check your credentials or private key.")
5356
}

TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ struct ConnectionURLFormatter {
195195
}
196196
}
197197

198+
if ssh.enabled && ssh.authMethod == .none {
199+
params.append("sshNoAuth=true")
200+
}
201+
198202
if let sslParam = sslModeParam(connection.sslConfig.mode) {
199203
params.append("sslmode=\(sslParam)")
200204
}

TablePro/Core/Utilities/Connection/ConnectionURLParser.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct ParsedConnectionURL {
2121
let sshPassword: String?
2222
let usePrivateKey: Bool?
2323
let useSSHAgent: Bool?
24+
let sshNoAuth: Bool?
2425
let agentSocket: String?
2526
let connectionName: String?
2627
let redisDatabase: Int?
@@ -123,6 +124,7 @@ struct ConnectionURLParser {
123124
sshPassword: nil,
124125
usePrivateKey: nil,
125126
useSSHAgent: nil,
127+
sshNoAuth: nil,
126128
agentSocket: nil,
127129
connectionName: nil,
128130
redisDatabase: nil,
@@ -228,6 +230,7 @@ struct ConnectionURLParser {
228230
sshPassword: nil,
229231
usePrivateKey: nil,
230232
useSSHAgent: nil,
233+
sshNoAuth: nil,
231234
agentSocket: nil,
232235
connectionName: ext.connectionName,
233236
redisDatabase: redisDatabase,
@@ -408,6 +411,7 @@ struct ConnectionURLParser {
408411
sshPassword: sshPassword,
409412
usePrivateKey: ext.usePrivateKey,
410413
useSSHAgent: ext.useSSHAgent,
414+
sshNoAuth: ext.sshNoAuth,
411415
agentSocket: ext.agentSocket,
412416
connectionName: ext.connectionName,
413417
redisDatabase: nil,
@@ -506,6 +510,7 @@ struct ConnectionURLParser {
506510
sshPassword: nil,
507511
usePrivateKey: nil,
508512
useSSHAgent: nil,
513+
sshNoAuth: nil,
509514
agentSocket: nil,
510515
connectionName: ext.connectionName,
511516
redisDatabase: nil,
@@ -534,6 +539,7 @@ struct ConnectionURLParser {
534539
var connectionName: String?
535540
var usePrivateKey: Bool?
536541
var useSSHAgent: Bool?
542+
var sshNoAuth: Bool?
537543
var agentSocket: String?
538544
var statusColor: String?
539545
var envTag: String?
@@ -577,6 +583,10 @@ struct ConnectionURLParser {
577583
ext.useSSHAgent = value.lowercased() == "true"
578584
continue
579585
}
586+
if keyStr == "sshnoauth" {
587+
ext.sshNoAuth = value.lowercased() == "true"
588+
continue
589+
}
580590
if keyStr == "agentsocket" {
581591
ext.agentSocket = value.removingPercentEncoding ?? value
582592
continue

TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ internal enum TransientConnectionFactory {
2222
sshConfig.authMethod = .sshAgent
2323
sshConfig.agentSocketPath = parsed.agentSocket ?? ""
2424
}
25+
if parsed.sshNoAuth == true {
26+
sshConfig.authMethod = .none
27+
}
2528
}
2629

2730
var sslConfig = SSLConfiguration()

TablePro/Models/Connection/SSHTypes.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable {
1010
case privateKey = "Private Key"
1111
case sshAgent = "SSH Agent"
1212
case keyboardInteractive = "Keyboard Interactive"
13+
case none = "None"
1314

1415
var id: String { rawValue }
1516

@@ -19,6 +20,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable {
1920
case .privateKey: return String(localized: "Private Key")
2021
case .sshAgent: return String(localized: "SSH Agent")
2122
case .keyboardInteractive: return String(localized: "Keyboard Interactive")
23+
case .none: return String(localized: "None")
2224
}
2325
}
2426

@@ -28,6 +30,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable {
2830
case .privateKey: return "doc.text.fill"
2931
case .sshAgent: return "person.badge.key.fill"
3032
case .keyboardInteractive: return "keyboard"
33+
case .none: return "key.slash"
3134
}
3235
}
3336
}
@@ -140,7 +143,7 @@ extension SSHConfiguration {
140143
host = try container.decode(String.self, forKey: .host)
141144
port = try container.decodeIfPresent(Int.self, forKey: .port)
142145
username = try container.decode(String.self, forKey: .username)
143-
authMethod = try container.decode(SSHAuthMethod.self, forKey: .authMethod)
146+
authMethod = (try? container.decodeIfPresent(SSHAuthMethod.self, forKey: .authMethod)) ?? .password
144147
privateKeyPath = try container.decode(String.self, forKey: .privateKeyPath)
145148
agentSocketPath = try container.decode(String.self, forKey: .agentSocketPath)
146149
jumpHosts = try container.decodeIfPresent([SSHJumpHost].self, forKey: .jumpHosts) ?? []

TablePro/Views/Connection/ConnectionSSHTunnelView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ struct ConnectionSSHTunnelView: View {
202202
Text(String(localized: "Password is sent via keyboard-interactive challenge-response."))
203203
.font(.caption)
204204
.foregroundStyle(.secondary)
205+
} else if sshState.authMethod == .none {
206+
Text("No credentials are sent. Use this when the server handles authentication itself, such as a Tailscale SSH host.")
207+
.font(.caption)
208+
.foregroundStyle(.secondary)
205209
} else {
206210
LabeledContent(String(localized: "Key File")) {
207211
HStack {

TablePro/Views/Connection/SSHProfileEditorView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ struct SSHProfileEditorView: View {
5656
let hostValid = !host.trimmingCharacters(in: .whitespaces).isEmpty
5757
let portValid = port.isEmpty || (Int(port).map { (1...65_535).contains($0) } ?? false)
5858
let authValid = authMethod == .password || authMethod == .sshAgent
59-
|| authMethod == .keyboardInteractive || !privateKeyPath.isEmpty
59+
|| authMethod == .keyboardInteractive || authMethod == .none || !privateKeyPath.isEmpty
6060
let jumpValid = jumpHosts.allSatisfy(\.isValid)
6161
return nameValid && hostValid && portValid && authValid && jumpValid
6262
}
@@ -167,6 +167,10 @@ struct SSHProfileEditorView: View {
167167
Text(String(localized: "Password is sent via keyboard-interactive challenge-response."))
168168
.font(.caption)
169169
.foregroundStyle(.secondary)
170+
} else if authMethod == .none {
171+
Text("No credentials are sent. Use this when the server handles authentication itself, such as a Tailscale SSH host.")
172+
.font(.caption)
173+
.foregroundStyle(.secondary)
170174
} else {
171175
LabeledContent(String(localized: "Key File")) {
172176
HStack {

0 commit comments

Comments
 (0)