Skip to content

Commit 7be6563

Browse files
authored
Merge pull request #203 from datlechin/feat/multi-jump-ssh
feat: add multi-jump SSH support via ProxyJump
2 parents d06a73d + 9187e26 commit 7be6563

File tree

12 files changed

+649
-25
lines changed

12 files changed

+649
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Oracle Database support via OCI (Oracle Call Interface)
1313
- Add database URL scheme support — open connections directly from terminal with `open "mysql://user@host/db" -a TablePro` (supports MySQL, PostgreSQL, SQLite, MongoDB, Redis, MSSQL, Oracle)
1414
- SSH Agent authentication method for SSH tunnels (compatible with 1Password SSH Agent, Secretive, ssh-agent)
15+
- Multi-jump SSH support — chain multiple SSH hops (ProxyJump) to reach databases through bastion hosts
1516

1617
### Changed
1718

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,8 @@ final class DatabaseManager {
394394
sshPassword: sshPassword,
395395
agentSocketPath: connection.sshConfig.agentSocketPath,
396396
remoteHost: connection.host,
397-
remotePort: connection.port
397+
remotePort: connection.port,
398+
jumpHosts: connection.sshConfig.jumpHosts
398399
)
399400

400401
// Adapt SSL config for tunnel: SSH already authenticates the server,

TablePro/Core/SSH/SSHConfigParser.swift

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct SSHConfigEntry: Identifiable, Hashable {
1616
let user: String? // Username
1717
let identityFile: String? // Path to private key
1818
let identityAgent: String? // Path to SSH agent socket
19+
let proxyJump: String? // ProxyJump directive
1920

2021
/// Display name for UI
2122
var displayName: String {
@@ -54,6 +55,7 @@ final class SSHConfigParser {
5455
var currentUser: String?
5556
var currentIdentityFile: String?
5657
var currentIdentityAgent: String?
58+
var currentProxyJump: String?
5759

5860
let lines = content.components(separatedBy: .newlines)
5961

@@ -85,7 +87,8 @@ final class SSHConfigParser {
8587
port: currentPort,
8688
user: currentUser,
8789
identityFile: expandPath(currentIdentityFile),
88-
identityAgent: expandPath(currentIdentityAgent)
90+
identityAgent: expandPath(currentIdentityAgent),
91+
proxyJump: currentProxyJump
8992
))
9093
}
9194
}
@@ -97,6 +100,7 @@ final class SSHConfigParser {
97100
currentUser = nil
98101
currentIdentityFile = nil
99102
currentIdentityAgent = nil
103+
currentProxyJump = nil
100104

101105
case "hostname":
102106
currentHostname = value
@@ -113,6 +117,9 @@ final class SSHConfigParser {
113117
case "identityagent":
114118
currentIdentityAgent = value
115119

120+
case "proxyjump":
121+
currentProxyJump = value
122+
116123
default:
117124
break // Ignore other directives
118125
}
@@ -127,7 +134,8 @@ final class SSHConfigParser {
127134
port: currentPort,
128135
user: currentUser,
129136
identityFile: expandPath(currentIdentityFile),
130-
identityAgent: expandPath(currentIdentityAgent)
137+
identityAgent: expandPath(currentIdentityAgent),
138+
proxyJump: currentProxyJump
131139
))
132140
}
133141

@@ -144,6 +152,46 @@ final class SSHConfigParser {
144152
return entries.first { $0.host.lowercased() == host.lowercased() }
145153
}
146154

155+
/// Parse a ProxyJump value (e.g., "user@host:port,user2@host2") into SSHJumpHost array
156+
static func parseProxyJump(_ value: String) -> [SSHJumpHost] {
157+
let hops = value.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
158+
var jumpHosts: [SSHJumpHost] = []
159+
160+
for hop in hops where !hop.isEmpty {
161+
var jumpHost = SSHJumpHost()
162+
163+
var remaining = hop
164+
165+
// Extract user@ prefix
166+
if let atIndex = remaining.firstIndex(of: "@") {
167+
jumpHost.username = String(remaining[remaining.startIndex..<atIndex])
168+
remaining = String(remaining[remaining.index(after: atIndex)...])
169+
}
170+
171+
// Extract host and port (supports bracketed IPv6, e.g. [::1]:22)
172+
if remaining.hasPrefix("["),
173+
let closeBracket = remaining.firstIndex(of: "]") {
174+
jumpHost.host = String(remaining[remaining.index(after: remaining.startIndex)..<closeBracket])
175+
let afterBracket = remaining.index(after: closeBracket)
176+
if afterBracket < remaining.endIndex,
177+
remaining[afterBracket] == ":",
178+
let port = Int(String(remaining[remaining.index(after: afterBracket)...])) {
179+
jumpHost.port = port
180+
}
181+
} else if let colonIndex = remaining.lastIndex(of: ":"),
182+
let port = Int(String(remaining[remaining.index(after: colonIndex)...])) {
183+
jumpHost.host = String(remaining[remaining.startIndex..<colonIndex])
184+
jumpHost.port = port
185+
} else {
186+
jumpHost.host = remaining
187+
}
188+
189+
jumpHosts.append(jumpHost)
190+
}
191+
192+
return jumpHosts
193+
}
194+
147195
/// Expand ~ to home directory in path
148196
private static func expandPath(_ path: String?) -> String? {
149197
guard let path = path else { return nil }

TablePro/Core/SSH/SSHTunnelManager.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ actor SSHTunnelManager {
119119
sshPassword: String? = nil,
120120
agentSocketPath: String? = nil,
121121
remoteHost: String,
122-
remotePort: Int
122+
remotePort: Int,
123+
jumpHosts: [SSHJumpHost] = []
123124
) async throws -> Int {
124125
// Check if tunnel already exists
125126
if tunnels[connectionId] != nil {
@@ -181,6 +182,17 @@ actor SSHTunnelManager {
181182
arguments.append(contentsOf: ["-o", "PreferredAuthentications=publickey"])
182183
}
183184

185+
// Jump host identity files
186+
for jumpHost in jumpHosts where jumpHost.authMethod == .privateKey && !jumpHost.privateKeyPath.isEmpty {
187+
arguments.append(contentsOf: ["-i", expandPath(jumpHost.privateKeyPath)])
188+
}
189+
190+
// ProxyJump chain
191+
if !jumpHosts.isEmpty {
192+
let jumpString = jumpHosts.map(\.proxyJumpString).joined(separator: ",")
193+
arguments.append(contentsOf: ["-J", jumpString])
194+
}
195+
184196
arguments.append("\(sshUsername)@\(sshHost)")
185197

186198
process.arguments = arguments

TablePro/Models/DatabaseConnection.swift

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,31 @@ enum SSHAgentSocketOption: String, CaseIterable, Identifiable {
8282
}
8383
}
8484

85+
enum SSHJumpAuthMethod: String, CaseIterable, Identifiable, Codable {
86+
case privateKey = "Private Key"
87+
case sshAgent = "SSH Agent"
88+
89+
var id: String { rawValue }
90+
}
91+
92+
struct SSHJumpHost: Codable, Hashable, Identifiable {
93+
var id = UUID()
94+
var host: String = ""
95+
var port: Int = 22
96+
var username: String = ""
97+
var authMethod: SSHJumpAuthMethod = .sshAgent
98+
var privateKeyPath: String = ""
99+
100+
var isValid: Bool {
101+
!host.isEmpty && !username.isEmpty &&
102+
(authMethod == .sshAgent || !privateKeyPath.isEmpty)
103+
}
104+
105+
var proxyJumpString: String {
106+
"\(username)@\(host):\(port)"
107+
}
108+
}
109+
85110
/// SSH tunnel configuration for database connections
86111
struct SSHConfiguration: Codable, Hashable {
87112
var enabled: Bool = false
@@ -92,20 +117,43 @@ struct SSHConfiguration: Codable, Hashable {
92117
var privateKeyPath: String = "" // Path to identity file (e.g., ~/.ssh/id_rsa)
93118
var useSSHConfig: Bool = true // Auto-fill from ~/.ssh/config when selecting host
94119
var agentSocketPath: String = "" // Custom SSH_AUTH_SOCK path (empty = use system default)
120+
var jumpHosts: [SSHJumpHost] = []
95121

96122
/// Check if SSH configuration is complete enough for connection
97123
var isValid: Bool {
98124
guard enabled else { return true } // Not enabled = valid (skip SSH)
99125
guard !host.isEmpty, !username.isEmpty else { return false }
100126

127+
let authValid: Bool
101128
switch authMethod {
102129
case .password:
103-
return true // Password will be provided separately
130+
authValid = true
104131
case .privateKey:
105-
return !privateKeyPath.isEmpty
132+
authValid = !privateKeyPath.isEmpty
106133
case .sshAgent:
107-
return true
134+
authValid = true
108135
}
136+
137+
return authValid && jumpHosts.allSatisfy(\.isValid)
138+
}
139+
}
140+
141+
extension SSHConfiguration {
142+
enum CodingKeys: String, CodingKey {
143+
case enabled, host, port, username, authMethod, privateKeyPath, useSSHConfig, agentSocketPath, jumpHosts
144+
}
145+
146+
init(from decoder: Decoder) throws {
147+
let container = try decoder.container(keyedBy: CodingKeys.self)
148+
enabled = try container.decode(Bool.self, forKey: .enabled)
149+
host = try container.decode(String.self, forKey: .host)
150+
port = try container.decode(Int.self, forKey: .port)
151+
username = try container.decode(String.self, forKey: .username)
152+
authMethod = try container.decode(SSHAuthMethod.self, forKey: .authMethod)
153+
privateKeyPath = try container.decode(String.self, forKey: .privateKeyPath)
154+
useSSHConfig = try container.decode(Bool.self, forKey: .useSSHConfig)
155+
agentSocketPath = try container.decode(String.self, forKey: .agentSocketPath)
156+
jumpHosts = try container.decodeIfPresent([SSHJumpHost].self, forKey: .jumpHosts) ?? []
109157
}
110158
}
111159

TablePro/Resources/Localizable.xcstrings

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,9 @@
10191019
}
10201020
}
10211021
}
1022+
},
1023+
"Add Jump Host" : {
1024+
10221025
},
10231026
"Add Provider" : {
10241027
"localizations" : {
@@ -1049,6 +1052,9 @@
10491052
}
10501053
}
10511054
}
1055+
},
1056+
"admin" : {
1057+
10521058
},
10531059
"Agent Socket" : {
10541060
"localizations" : {
@@ -1359,6 +1365,9 @@
13591365
}
13601366
}
13611367
}
1368+
},
1369+
"Auth" : {
1370+
13621371
},
13631372
"Authentication" : {
13641373
"localizations" : {
@@ -1471,6 +1480,9 @@
14711480
}
14721481
}
14731482
}
1483+
},
1484+
"bastion.example.com" : {
1485+
14741486
},
14751487
"between" : {
14761488
"localizations" : {
@@ -5130,6 +5142,12 @@
51305142
}
51315143
}
51325144
}
5145+
},
5146+
"Jump Hosts" : {
5147+
5148+
},
5149+
"Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." : {
5150+
51335151
},
51345152
"Keep entries for:" : {
51355153
"localizations" : {
@@ -5220,9 +5238,6 @@
52205238
}
52215239
}
52225240
}
5223-
},
5224-
"Leave empty for SSH_AUTH_SOCK" : {
5225-
52265241
},
52275242
"Length" : {
52285243
"extractionState" : "stale",
@@ -5773,6 +5788,9 @@
57735788
}
57745789
}
57755790
}
5791+
},
5792+
"New Jump Host" : {
5793+
57765794
},
57775795
"New query tab" : {
57785796
"extractionState" : "stale",

0 commit comments

Comments
 (0)