Skip to content

Commit 3049cb2

Browse files
authored
fix: resolve SSH tunnel failures with config profiles (#672) (#675)
* fix: resolve SSH tunnel failures with config profiles (#672) * fix: eliminate race in tunnel cleanup by detecting tunnel from connection state * refactor: extract PendingEntry to eliminate 3x duplication in SSH config parser * fix: SSH tunnel toggle showing OFF when editing profile-based connections * fix: disabling SSH tunnel toggle not clearing profile, allowing tunnel bypass * refactor: replace dual SSH state with SSHTunnelMode discriminated union * fix: address 5 review issues in SSH tunnel refactoring * docs: simplify SSH changelog entry
1 parent 909c977 commit 3049cb2

19 files changed

Lines changed: 1032 additions & 366 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Handoff support for cross-device continuity between iOS and macOS
1616
- State restoration across app lifecycle on iOS (selected connection, active tab, query text, database/schema selection)
1717

18+
### Fixed
19+
20+
- SSH Tunnel not working with `~/.ssh/config` profiles (#672): added `Include` directive support, SSH token expansion (`%d`, `%h`, `%u`, `%r`), multi-word `Host` filtering, and detailed handshake error messages
21+
1822
## [0.30.1] - 2026-04-10
1923

2024
### Added

TablePro/Core/Database/DatabaseManager+Queries.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ extension DatabaseManager {
7878
sshPasswordOverride: sshPassword
7979
)
8080

81+
// Detect whether buildEffectiveConnection created a tunnel by checking
82+
// if the returned connection was redirected to localhost (tunnel endpoint)
83+
let tunnelWasCreated = testConnection.host == "127.0.0.1" && testConnection.port != connection.port
84+
8185
let result: Bool
8286
do {
8387
let driver = try DatabaseDriverFactory.createDriver(
@@ -86,7 +90,7 @@ extension DatabaseManager {
8690
)
8791
result = try await driver.testConnection()
8892
} catch {
89-
if connection.sshConfig.enabled {
93+
if tunnelWasCreated {
9094
do {
9195
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
9296
} catch {
@@ -96,7 +100,7 @@ extension DatabaseManager {
96100
throw error
97101
}
98102

99-
if connection.sshConfig.enabled {
103+
if tunnelWasCreated {
100104
do {
101105
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
102106
} catch {

TablePro/Core/Database/DatabaseManager+SSH.swift

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,24 @@ extension DatabaseManager {
2424
for connection: DatabaseConnection,
2525
sshPasswordOverride: String? = nil
2626
) async throws -> DatabaseConnection {
27-
// Resolve SSH configuration: profile takes priority over inline
28-
let profile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) }
29-
let sshConfig = connection.effectiveSSHConfig(profile: profile)
30-
let isProfile = connection.sshProfileId != nil && profile != nil
31-
let secretOwnerId = (isProfile ? connection.sshProfileId : nil) ?? connection.id
32-
33-
guard sshConfig.enabled else {
34-
return connection
35-
}
27+
let sshConfig = connection.resolvedSSHConfig
28+
guard sshConfig.enabled else { return connection }
3629

3730
let storedSshPassword: String?
3831
let keyPassphrase: String?
3932
let totpSecret: String?
40-
if isProfile {
41-
storedSshPassword = SSHProfileStorage.shared.loadSSHPassword(for: secretOwnerId)
42-
keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: secretOwnerId)
43-
totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: secretOwnerId)
44-
} else {
45-
storedSshPassword = ConnectionStorage.shared.loadSSHPassword(for: secretOwnerId)
46-
keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: secretOwnerId)
47-
totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: secretOwnerId)
33+
34+
switch connection.sshTunnelMode {
35+
case .disabled:
36+
return connection
37+
case .profile(let profileId, _):
38+
storedSshPassword = SSHProfileStorage.shared.loadSSHPassword(for: profileId)
39+
keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profileId)
40+
totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: profileId)
41+
case .inline:
42+
storedSshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
43+
keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id)
44+
totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id)
4845
}
4946

5047
let sshPassword = sshPasswordOverride ?? storedSshPassword

TablePro/Core/Database/DatabaseManager+Sessions.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ extension DatabaseManager {
9292
)
9393
} catch {
9494
// Close tunnel if SSH was established
95-
if connection.sshConfig.enabled {
95+
if connection.resolvedSSHConfig.enabled {
9696
Task {
9797
do {
9898
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
@@ -157,7 +157,7 @@ extension DatabaseManager {
157157
}
158158
} catch {
159159
// Close tunnel if connection failed
160-
if connection.sshConfig.enabled {
160+
if connection.resolvedSSHConfig.enabled {
161161
Task {
162162
do {
163163
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
@@ -243,7 +243,7 @@ extension DatabaseManager {
243243
guard let session = activeSessions[sessionId] else { return }
244244

245245
// Close SSH tunnel if exists
246-
if session.connection.sshConfig.enabled {
246+
if session.connection.resolvedSSHConfig.enabled {
247247
do {
248248
try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
249249
} catch {

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,12 @@ internal enum LibSSH2TunnelFactory {
403403

404404
let rc = libssh2_session_handshake(session, socketFD)
405405
if rc != 0 {
406+
var msgPtr: UnsafeMutablePointer<CChar>?
407+
var msgLen: Int32 = 0
408+
libssh2_session_last_error(session, &msgPtr, &msgLen, 0)
409+
let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error"
406410
libssh2_session_free(session)
407-
throw SSHTunnelError.tunnelCreationFailed("SSH handshake failed (error \(rc))")
411+
throw SSHTunnelError.tunnelCreationFailed("SSH handshake failed: \(detail)")
408412
}
409413

410414
return session

TablePro/Core/SSH/SSHConfigParser.swift

Lines changed: 133 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Foundation
9+
import os
910

1011
/// Represents a parsed entry from ~/.ssh/config
1112
struct SSHConfigEntry: Identifiable, Hashable {
@@ -29,6 +30,9 @@ struct SSHConfigEntry: Identifiable, Hashable {
2930

3031
/// Parser for SSH config file (~/.ssh/config)
3132
final class SSHConfigParser {
33+
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHConfigParser")
34+
private static let maxIncludeDepth = 10
35+
3236
/// Default SSH config file path
3337
static let defaultConfigPath = FileManager.default.homeDirectoryForCurrentUser
3438
.appendingPathComponent(".ssh/config").path(percentEncoded: false)
@@ -37,25 +41,52 @@ final class SSHConfigParser {
3741
/// - Parameter path: Path to the SSH config file (defaults to ~/.ssh/config)
3842
/// - Returns: Array of SSHConfigEntry
3943
static func parse(path: String = defaultConfigPath) -> [SSHConfigEntry] {
40-
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
41-
return []
42-
}
43-
44-
return parseContent(content)
44+
var visitedPaths = Set<String>()
45+
return parseFile(path: path, visitedPaths: &visitedPaths, depth: 0)
4546
}
4647

4748
/// Parse SSH config content string
4849
/// - Parameter content: The content of the SSH config file
4950
/// - Returns: Array of SSHConfigEntry
5051
static func parseContent(_ content: String) -> [SSHConfigEntry] {
52+
var visited = Set<String>()
53+
return parseContent(content, visitedPaths: &visited, depth: 0)
54+
}
55+
56+
/// Parse SSH config file with Include support.
57+
private static func parseFile(
58+
path: String,
59+
visitedPaths: inout Set<String>,
60+
depth: Int
61+
) -> [SSHConfigEntry] {
62+
guard depth <= maxIncludeDepth else {
63+
logger.warning("SSH config Include depth exceeded at: \(path)")
64+
return []
65+
}
66+
67+
let canonicalPath = (path as NSString).standardizingPath
68+
69+
guard !visitedPaths.contains(canonicalPath) else {
70+
logger.warning("SSH config circular Include detected: \(path)")
71+
return []
72+
}
73+
74+
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
75+
return []
76+
}
77+
78+
visitedPaths.insert(canonicalPath)
79+
80+
return parseContent(content, visitedPaths: &visitedPaths, depth: depth)
81+
}
82+
83+
private static func parseContent(
84+
_ content: String,
85+
visitedPaths: inout Set<String>,
86+
depth: Int
87+
) -> [SSHConfigEntry] {
5188
var entries: [SSHConfigEntry] = []
52-
var currentHost: String?
53-
var currentHostname: String?
54-
var currentPort: Int?
55-
var currentUser: String?
56-
var currentIdentityFile: String?
57-
var currentIdentityAgent: String?
58-
var currentProxyJump: String?
89+
var pending = PendingEntry()
5990

6091
let lines = content.components(separatedBy: .newlines)
6192

@@ -76,70 +107,121 @@ final class SSHConfigParser {
76107

77108
switch key {
78109
case "host":
79-
// Save previous entry if exists
80-
if let host = currentHost {
81-
// Skip wildcard patterns like "*"
82-
if !host.contains("*") && !host.contains("?") {
83-
entries.append(
84-
SSHConfigEntry(
85-
host: host,
86-
hostname: currentHostname,
87-
port: currentPort,
88-
user: currentUser,
89-
identityFile: currentIdentityFile.map(SSHPathUtilities.expandTilde),
90-
identityAgent: currentIdentityAgent.map(SSHPathUtilities.expandTilde),
91-
proxyJump: currentProxyJump
92-
))
93-
}
94-
}
95-
96-
// Start new entry
97-
currentHost = value
98-
currentHostname = nil
99-
currentPort = nil
100-
currentUser = nil
101-
currentIdentityFile = nil
102-
currentIdentityAgent = nil
103-
currentProxyJump = nil
110+
pending.flush(into: &entries)
111+
pending.host = value
104112

105113
case "hostname":
106-
currentHostname = value
114+
pending.hostname = value
107115

108116
case "port":
109-
currentPort = Int(value)
117+
pending.port = Int(value)
110118

111119
case "user":
112-
currentUser = value
120+
pending.user = value
113121

114122
case "identityfile":
115-
currentIdentityFile = value
123+
pending.identityFile = value
116124

117125
case "identityagent":
118-
currentIdentityAgent = value
126+
pending.identityAgent = value
119127

120128
case "proxyjump":
121-
currentProxyJump = value
129+
pending.proxyJump = value
130+
131+
case "include":
132+
pending.flush(into: &entries)
133+
for includePath in resolveIncludePaths(value) {
134+
let includedEntries = parseFile(
135+
path: includePath,
136+
visitedPaths: &visitedPaths,
137+
depth: depth + 1
138+
)
139+
entries.append(contentsOf: includedEntries)
140+
}
122141

123142
default:
124143
break // Ignore other directives
125144
}
126145
}
127146

128147
// Don't forget the last entry
129-
if let host = currentHost, !host.contains("*"), !host.contains("?") {
148+
pending.flush(into: &entries)
149+
150+
return entries
151+
}
152+
153+
// MARK: - Pending Entry State
154+
155+
/// Accumulates directives for the current Host stanza during parsing.
156+
private struct PendingEntry {
157+
var host: String?
158+
var hostname: String?
159+
var port: Int?
160+
var user: String?
161+
var identityFile: String?
162+
var identityAgent: String?
163+
var proxyJump: String?
164+
165+
/// Flush the pending entry into the entries array and reset state.
166+
/// Skips wildcard patterns (`*`, `?`) and multi-word hosts.
167+
mutating func flush(into entries: inout [SSHConfigEntry]) {
168+
defer { self = PendingEntry() }
169+
170+
guard let host, !host.contains("*"), !host.contains("?"), !host.contains(" ") else {
171+
return
172+
}
173+
130174
entries.append(
131175
SSHConfigEntry(
132176
host: host,
133-
hostname: currentHostname,
134-
port: currentPort,
135-
user: currentUser,
136-
identityFile: currentIdentityFile.map(SSHPathUtilities.expandTilde),
137-
identityAgent: currentIdentityAgent.map(SSHPathUtilities.expandTilde),
138-
proxyJump: currentProxyJump
177+
hostname: hostname,
178+
port: port,
179+
user: user,
180+
identityFile: identityFile.map {
181+
SSHPathUtilities.expandSSHTokens($0, hostname: hostname, remoteUser: user)
182+
},
183+
identityAgent: identityAgent.map {
184+
SSHPathUtilities.expandSSHTokens($0, hostname: hostname, remoteUser: user)
185+
},
186+
proxyJump: proxyJump
139187
))
140188
}
189+
}
141190

142-
return entries
191+
/// Expand a glob pattern to matching file paths using POSIX glob(3).
192+
private static func globPaths(_ pattern: String) -> [String] {
193+
var gt = glob_t()
194+
defer { globfree(&gt) }
195+
196+
guard glob(pattern, GLOB_TILDE | GLOB_BRACE, nil, &gt) == 0 else {
197+
return []
198+
}
199+
200+
var paths: [String] = []
201+
for i in 0..<Int(gt.gl_matchc) {
202+
if let cStr = gt.gl_pathv[i] {
203+
paths.append(String(cString: cStr))
204+
}
205+
}
206+
return paths.sorted()
207+
}
208+
209+
/// Resolve an Include directive value to actual file paths.
210+
/// Relative paths are resolved against ~/.ssh/ per OpenSSH convention.
211+
private static func resolveIncludePaths(_ value: String) -> [String] {
212+
let expanded = SSHPathUtilities.expandTilde(value)
213+
214+
// Relative paths resolve against ~/.ssh/ per OpenSSH convention
215+
let resolved: String
216+
if expanded.hasPrefix("/") {
217+
resolved = expanded
218+
} else {
219+
let sshDir = FileManager.default.homeDirectoryForCurrentUser
220+
.appendingPathComponent(".ssh").path(percentEncoded: false)
221+
resolved = (sshDir as NSString).appendingPathComponent(expanded)
222+
}
223+
224+
return globPaths(resolved)
143225
}
144226

145227
/// Find a specific entry by host name

0 commit comments

Comments
 (0)