diff --git a/CHANGELOG.md b/CHANGELOG.md index f917a931..7f81fb06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle Database support via OCI (Oracle Call Interface) - 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) - SSH Agent authentication method for SSH tunnels (compatible with 1Password SSH Agent, Secretive, ssh-agent) +- Multi-jump SSH support — chain multiple SSH hops (ProxyJump) to reach databases through bastion hosts ### Changed diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index a14eef29..78c3944e 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -394,7 +394,8 @@ final class DatabaseManager { sshPassword: sshPassword, agentSocketPath: connection.sshConfig.agentSocketPath, remoteHost: connection.host, - remotePort: connection.port + remotePort: connection.port, + jumpHosts: connection.sshConfig.jumpHosts ) // Adapt SSL config for tunnel: SSH already authenticates the server, diff --git a/TablePro/Core/SSH/SSHConfigParser.swift b/TablePro/Core/SSH/SSHConfigParser.swift index cd4c1ded..93ae5e46 100644 --- a/TablePro/Core/SSH/SSHConfigParser.swift +++ b/TablePro/Core/SSH/SSHConfigParser.swift @@ -16,6 +16,7 @@ struct SSHConfigEntry: Identifiable, Hashable { let user: String? // Username let identityFile: String? // Path to private key let identityAgent: String? // Path to SSH agent socket + let proxyJump: String? // ProxyJump directive /// Display name for UI var displayName: String { @@ -54,6 +55,7 @@ final class SSHConfigParser { var currentUser: String? var currentIdentityFile: String? var currentIdentityAgent: String? + var currentProxyJump: String? let lines = content.components(separatedBy: .newlines) @@ -85,7 +87,8 @@ final class SSHConfigParser { port: currentPort, user: currentUser, identityFile: expandPath(currentIdentityFile), - identityAgent: expandPath(currentIdentityAgent) + identityAgent: expandPath(currentIdentityAgent), + proxyJump: currentProxyJump )) } } @@ -97,6 +100,7 @@ final class SSHConfigParser { currentUser = nil currentIdentityFile = nil currentIdentityAgent = nil + currentProxyJump = nil case "hostname": currentHostname = value @@ -113,6 +117,9 @@ final class SSHConfigParser { case "identityagent": currentIdentityAgent = value + case "proxyjump": + currentProxyJump = value + default: break // Ignore other directives } @@ -127,7 +134,8 @@ final class SSHConfigParser { port: currentPort, user: currentUser, identityFile: expandPath(currentIdentityFile), - identityAgent: expandPath(currentIdentityAgent) + identityAgent: expandPath(currentIdentityAgent), + proxyJump: currentProxyJump )) } @@ -144,6 +152,46 @@ final class SSHConfigParser { return entries.first { $0.host.lowercased() == host.lowercased() } } + /// Parse a ProxyJump value (e.g., "user@host:port,user2@host2") into SSHJumpHost array + static func parseProxyJump(_ value: String) -> [SSHJumpHost] { + let hops = value.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var jumpHosts: [SSHJumpHost] = [] + + for hop in hops where !hop.isEmpty { + var jumpHost = SSHJumpHost() + + var remaining = hop + + // Extract user@ prefix + if let atIndex = remaining.firstIndex(of: "@") { + jumpHost.username = String(remaining[remaining.startIndex.. String? { guard let path = path else { return nil } diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index fd3d1402..0dc539e2 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -119,7 +119,8 @@ actor SSHTunnelManager { sshPassword: String? = nil, agentSocketPath: String? = nil, remoteHost: String, - remotePort: Int + remotePort: Int, + jumpHosts: [SSHJumpHost] = [] ) async throws -> Int { // Check if tunnel already exists if tunnels[connectionId] != nil { @@ -181,6 +182,17 @@ actor SSHTunnelManager { arguments.append(contentsOf: ["-o", "PreferredAuthentications=publickey"]) } + // Jump host identity files + for jumpHost in jumpHosts where jumpHost.authMethod == .privateKey && !jumpHost.privateKeyPath.isEmpty { + arguments.append(contentsOf: ["-i", expandPath(jumpHost.privateKeyPath)]) + } + + // ProxyJump chain + if !jumpHosts.isEmpty { + let jumpString = jumpHosts.map(\.proxyJumpString).joined(separator: ",") + arguments.append(contentsOf: ["-J", jumpString]) + } + arguments.append("\(sshUsername)@\(sshHost)") process.arguments = arguments diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index bb5733dd..afe8820a 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -82,6 +82,31 @@ enum SSHAgentSocketOption: String, CaseIterable, Identifiable { } } +enum SSHJumpAuthMethod: String, CaseIterable, Identifiable, Codable { + case privateKey = "Private Key" + case sshAgent = "SSH Agent" + + var id: String { rawValue } +} + +struct SSHJumpHost: Codable, Hashable, Identifiable { + var id = UUID() + var host: String = "" + var port: Int = 22 + var username: String = "" + var authMethod: SSHJumpAuthMethod = .sshAgent + var privateKeyPath: String = "" + + var isValid: Bool { + !host.isEmpty && !username.isEmpty && + (authMethod == .sshAgent || !privateKeyPath.isEmpty) + } + + var proxyJumpString: String { + "\(username)@\(host):\(port)" + } +} + /// SSH tunnel configuration for database connections struct SSHConfiguration: Codable, Hashable { var enabled: Bool = false @@ -92,20 +117,43 @@ struct SSHConfiguration: Codable, Hashable { var privateKeyPath: String = "" // Path to identity file (e.g., ~/.ssh/id_rsa) var useSSHConfig: Bool = true // Auto-fill from ~/.ssh/config when selecting host var agentSocketPath: String = "" // Custom SSH_AUTH_SOCK path (empty = use system default) + var jumpHosts: [SSHJumpHost] = [] /// Check if SSH configuration is complete enough for connection var isValid: Bool { guard enabled else { return true } // Not enabled = valid (skip SSH) guard !host.isEmpty, !username.isEmpty else { return false } + let authValid: Bool switch authMethod { case .password: - return true // Password will be provided separately + authValid = true case .privateKey: - return !privateKeyPath.isEmpty + authValid = !privateKeyPath.isEmpty case .sshAgent: - return true + authValid = true } + + return authValid && jumpHosts.allSatisfy(\.isValid) + } +} + +extension SSHConfiguration { + enum CodingKeys: String, CodingKey { + case enabled, host, port, username, authMethod, privateKeyPath, useSSHConfig, agentSocketPath, jumpHosts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decode(Bool.self, forKey: .enabled) + host = try container.decode(String.self, forKey: .host) + port = try container.decode(Int.self, forKey: .port) + username = try container.decode(String.self, forKey: .username) + authMethod = try container.decode(SSHAuthMethod.self, forKey: .authMethod) + privateKeyPath = try container.decode(String.self, forKey: .privateKeyPath) + useSSHConfig = try container.decode(Bool.self, forKey: .useSSHConfig) + agentSocketPath = try container.decode(String.self, forKey: .agentSocketPath) + jumpHosts = try container.decodeIfPresent([SSHJumpHost].self, forKey: .jumpHosts) ?? [] } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 485fa7c0..22b577b1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1019,6 +1019,9 @@ } } } + }, + "Add Jump Host" : { + }, "Add Provider" : { "localizations" : { @@ -1049,6 +1052,9 @@ } } } + }, + "admin" : { + }, "Agent Socket" : { "localizations" : { @@ -1359,6 +1365,9 @@ } } } + }, + "Auth" : { + }, "Authentication" : { "localizations" : { @@ -1471,6 +1480,9 @@ } } } + }, + "bastion.example.com" : { + }, "between" : { "localizations" : { @@ -5130,6 +5142,12 @@ } } } + }, + "Jump Hosts" : { + + }, + "Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." : { + }, "Keep entries for:" : { "localizations" : { @@ -5220,9 +5238,6 @@ } } } - }, - "Leave empty for SSH_AUTH_SOCK" : { - }, "Length" : { "extractionState" : "stale", @@ -5773,6 +5788,9 @@ } } } + }, + "New Jump Host" : { + }, "New query tab" : { "extractionState" : "stale", diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 155915a8..39c5e4c6 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -48,6 +48,7 @@ struct ConnectionFormView: View { @State private var keyPassphrase: String = "" @State private var sshConfigEntries: [SSHConfigEntry] = [] @State private var selectedSSHConfigHost: String = "" + @State private var jumpHosts: [SSHJumpHost] = [] // SSL Configuration @State private var sslMode: SSLMode = .disabled @@ -378,6 +379,81 @@ struct ConnectionFormView: View { SecureField(String(localized: "Passphrase"), text: $keyPassphrase) } } + + Section { + DisclosureGroup(String(localized: "Jump Hosts")) { + ForEach($jumpHosts) { $jumpHost in + DisclosureGroup { + TextField( + String(localized: "Host"), + text: $jumpHost.host, + prompt: Text("bastion.example.com") + ) + HStack { + TextField( + String(localized: "Port"), + text: Binding( + get: { String(jumpHost.port) }, + set: { jumpHost.port = Int($0) ?? 22 } + ), + prompt: Text("22") + ) + .frame(width: 80) + TextField( + String(localized: "Username"), + text: $jumpHost.username, + prompt: Text("admin") + ) + } + Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + ForEach(SSHJumpAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if jumpHost.authMethod == .privateKey { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { + browseForJumpHostKey(jumpHost: $jumpHost) + } + .controlSize(.small) + } + } + } + } label: { + HStack { + Text(jumpHost.host.isEmpty ? String(localized: "New Jump Host") : "\(jumpHost.username)@\(jumpHost.host)") + .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) + Spacer() + Button { + let idToRemove = jumpHost.id + withAnimation { + jumpHosts.removeAll { $0.id == idToRemove } + } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + .onMove { indices, destination in + jumpHosts.move(fromOffsets: indices, toOffset: destination) + } + + Button { + jumpHosts.append(SSHJumpHost()) + } label: { + Label(String(localized: "Add Jump Host"), systemImage: "plus") + } + + Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") + .font(.caption) + .foregroundStyle(.secondary) + } + } } } .formStyle(.grouped) @@ -601,7 +677,8 @@ struct ConnectionFormView: View { if sshEnabled { let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty let authValid = sshAuthMethod == .password || sshAuthMethod == .sshAgent || !sshPrivateKeyPath.isEmpty - return basicValid && sshValid && authValid + let jumpValid = jumpHosts.allSatisfy(\.isValid) + return basicValid && sshValid && authValid && jumpValid } return basicValid } @@ -642,6 +719,7 @@ struct ConnectionFormView: View { sshAuthMethod = existing.sshConfig.authMethod sshPrivateKeyPath = existing.sshConfig.privateKeyPath applySSHAgentSocketPath(existing.sshConfig.agentSocketPath) + jumpHosts = existing.sshConfig.jumpHosts // Load SSL configuration sslMode = existing.sslConfig.mode @@ -691,7 +769,8 @@ struct ConnectionFormView: View { authMethod: sshAuthMethod, privateKeyPath: sshPrivateKeyPath, useSSHConfig: !selectedSSHConfigHost.isEmpty, - agentSocketPath: resolvedSSHAgentSocketPath + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts ) let sslConfig = SSLConfiguration( @@ -793,7 +872,8 @@ struct ConnectionFormView: View { authMethod: sshAuthMethod, privateKeyPath: sshPrivateKeyPath, useSSHConfig: !selectedSSHConfigHost.isEmpty, - agentSocketPath: resolvedSSHAgentSocketPath + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts ) let sslConfig = SSLConfiguration( @@ -884,6 +964,20 @@ struct ConnectionFormView: View { } } + private func browseForJumpHostKey(jumpHost: Binding) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + panel.showsHiddenFiles = true + + panel.begin { response in + if response == .OK, let url = panel.url { + jumpHost.wrappedValue.privateKeyPath = url.path(percentEncoded: false) + } + } + } + private func browseForCertificate(binding: Binding) { let panel = NSOpenPanel() panel.allowsMultipleSelection = false @@ -961,6 +1055,9 @@ struct ConnectionFormView: View { sshPrivateKeyPath = keyPath sshAuthMethod = .privateKey } + if let proxyJump = entry.proxyJump { + jumpHosts = SSHConfigParser.parseProxyJump(proxyJump) + } } private func applySSHAgentSocketPath(_ socketPath: String) { diff --git a/TableProTests/Core/SSH/SSHConfigParserTests.swift b/TableProTests/Core/SSH/SSHConfigParserTests.swift index 3cbc963f..aa225fdc 100644 --- a/TableProTests/Core/SSH/SSHConfigParserTests.swift +++ b/TableProTests/Core/SSH/SSHConfigParserTests.swift @@ -6,12 +6,11 @@ // import Foundation -import Testing @testable import TablePro +import Testing @Suite("SSH Config Parser") struct SSHConfigParserTests { - @Test("Empty content returns empty array") func testEmptyContent() { let result = SSHConfigParser.parseContent("") @@ -34,7 +33,7 @@ struct SSHConfigParserTests { let entry = result[0] #expect(entry.host == "myserver") #expect(entry.hostname == "example.com") - #expect(entry.port == 2222) + #expect(entry.port == 2_222) #expect(entry.user == "admin") #expect(entry.identityFile != nil) #expect(entry.identityFile?.contains(".ssh/id_rsa") == true) @@ -61,7 +60,7 @@ struct SSHConfigParserTests { #expect(result[1].host == "server2") #expect(result[2].host == "server3") #expect(result[0].port == 22) - #expect(result[1].port == 2222) + #expect(result[1].port == 2_222) } @Test("Comments are skipped") @@ -80,7 +79,7 @@ struct SSHConfigParserTests { #expect(result.count == 1) #expect(result[0].host == "myserver") #expect(result[0].hostname == "example.com") - #expect(result[0].port == 2222) + #expect(result[0].port == 2_222) } @Test("Wildcard hosts with asterisk are skipped") @@ -146,7 +145,7 @@ struct SSHConfigParserTests { #expect(result.count == 1) #expect(result[0].host == "myserver") #expect(result[0].hostname == nil) - #expect(result[0].port == 2222) + #expect(result[0].port == 2_222) #expect(result[0].user == "admin") } @@ -178,7 +177,7 @@ struct SSHConfigParserTests { #expect(result.count == 1) #expect(result[0].host == "myserver") #expect(result[0].hostname == "example.com") - #expect(result[0].port == 2222) + #expect(result[0].port == 2_222) #expect(result[0].user == nil) } @@ -227,9 +226,9 @@ struct SSHConfigParserTests { let result = SSHConfigParser.parseContent(content) #expect(result.count == 2) #expect(result[0].hostname == "example1.com") - #expect(result[0].port == 2222) + #expect(result[0].port == 2_222) #expect(result[1].hostname == "example2.com") - #expect(result[1].port == 3333) + #expect(result[1].port == 3_333) #expect(result[1].user == "admin") } @@ -246,7 +245,7 @@ struct SSHConfigParserTests { #expect(result.count == 1) #expect(result[0].host == "myserver") #expect(result[0].hostname == "example.com") - #expect(result[0].port == 2222) + #expect(result[0].port == 2_222) #expect(result[0].user == "admin") } @@ -332,4 +331,117 @@ struct SSHConfigParserTests { #expect(result[0].identityAgent != nil) #expect(result[1].identityAgent == nil) } + + @Test("ProxyJump directive is parsed") + func testProxyJumpParsed() { + let content = """ + Host myserver + HostName example.com + ProxyJump admin@bastion.com:2222 + """ + + let result = SSHConfigParser.parseContent(content) + #expect(result.count == 1) + #expect(result[0].proxyJump == "admin@bastion.com:2222") + } + + @Test("ProxyJump with multiple hops") + func testProxyJumpMultipleHops() { + let content = """ + Host myserver + HostName example.com + ProxyJump user1@hop1.com,user2@hop2.com:2222 + """ + + let result = SSHConfigParser.parseContent(content) + #expect(result.count == 1) + #expect(result[0].proxyJump == "user1@hop1.com,user2@hop2.com:2222") + } + + @Test("ProxyJump resets between host entries") + func testProxyJumpResetsBetweenEntries() { + let content = """ + Host server1 + HostName host1.com + ProxyJump admin@bastion.com + + Host server2 + HostName host2.com + """ + + let result = SSHConfigParser.parseContent(content) + #expect(result.count == 2) + #expect(result[0].proxyJump == "admin@bastion.com") + #expect(result[1].proxyJump == nil) + } + + @Test("Entry without ProxyJump has nil") + func testNoProxyJump() { + let content = """ + Host myserver + HostName example.com + User admin + """ + + let result = SSHConfigParser.parseContent(content) + #expect(result.count == 1) + #expect(result[0].proxyJump == nil) + } + + @Test("parseProxyJump single hop with user and port") + func testParseProxyJumpSingleHop() { + let jumpHosts = SSHConfigParser.parseProxyJump("admin@bastion.com:2222") + #expect(jumpHosts.count == 1) + #expect(jumpHosts[0].username == "admin") + #expect(jumpHosts[0].host == "bastion.com") + #expect(jumpHosts[0].port == 2_222) + } + + @Test("parseProxyJump multi-hop") + func testParseProxyJumpMultiHop() { + let jumpHosts = SSHConfigParser.parseProxyJump("user1@hop1.com,user2@hop2.com:2222") + #expect(jumpHosts.count == 2) + #expect(jumpHosts[0].username == "user1") + #expect(jumpHosts[0].host == "hop1.com") + #expect(jumpHosts[0].port == 22) + #expect(jumpHosts[1].username == "user2") + #expect(jumpHosts[1].host == "hop2.com") + #expect(jumpHosts[1].port == 2_222) + } + + @Test("parseProxyJump without user") + func testParseProxyJumpWithoutUser() { + let jumpHosts = SSHConfigParser.parseProxyJump("bastion.com:2222") + #expect(jumpHosts.count == 1) + #expect(jumpHosts[0].username == "") + #expect(jumpHosts[0].host == "bastion.com") + #expect(jumpHosts[0].port == 2_222) + } + + @Test("parseProxyJump without port") + func testParseProxyJumpWithoutPort() { + let jumpHosts = SSHConfigParser.parseProxyJump("admin@bastion.com") + #expect(jumpHosts.count == 1) + #expect(jumpHosts[0].username == "admin") + #expect(jumpHosts[0].host == "bastion.com") + #expect(jumpHosts[0].port == 22) + } + + @Test("parseProxyJump with bracketed IPv6 and port") + func testParseProxyJumpIPv6WithPort() { + let jumpHosts = SSHConfigParser.parseProxyJump("admin@[::1]:2222") + #expect(jumpHosts.count == 1) + #expect(jumpHosts[0].username == "admin") + #expect(jumpHosts[0].host == "::1") + #expect(jumpHosts[0].port == 2_222) + } + + @Test("parseProxyJump with bracketed IPv6 without port") + func testParseProxyJumpIPv6WithoutPort() { + let jumpHosts = SSHConfigParser.parseProxyJump("admin@[fe80::1]") + #expect(jumpHosts.count == 1) + #expect(jumpHosts[0].username == "admin") + #expect(jumpHosts[0].host == "fe80::1") + #expect(jumpHosts[0].port == 22) + } } diff --git a/TableProTests/Core/SSH/SSHConfigurationTests.swift b/TableProTests/Core/SSH/SSHConfigurationTests.swift index d9c9a02c..65f778c3 100644 --- a/TableProTests/Core/SSH/SSHConfigurationTests.swift +++ b/TableProTests/Core/SSH/SSHConfigurationTests.swift @@ -6,12 +6,11 @@ // import Foundation -import Testing @testable import TablePro +import Testing @Suite("SSH Configuration") struct SSHConfigurationTests { - @Test("Disabled SSH config is always valid") func testDisabledIsValid() { let config = SSHConfiguration(enabled: false) @@ -124,4 +123,52 @@ struct SSHConfigurationTests { == "/tmp/custom.sock" ) } + + @Test("Jump hosts validation passes when all valid") + func testJumpHostsValidationPasses() { + let config = SSHConfiguration( + enabled: true, host: "example.com", username: "admin", + authMethod: .sshAgent, + jumpHosts: [ + SSHJumpHost(host: "bastion1.com", username: "user1"), + SSHJumpHost(host: "bastion2.com", username: "user2"), + ] + ) + #expect(config.isValid == true) + } + + @Test("Jump hosts validation fails when any invalid") + func testJumpHostsValidationFails() { + let config = SSHConfiguration( + enabled: true, host: "example.com", username: "admin", + authMethod: .sshAgent, + jumpHosts: [ + SSHJumpHost(host: "bastion1.com", username: "user1"), + SSHJumpHost(host: "", username: "user2"), + ] + ) + #expect(config.isValid == false) + } + + @Test("Backward-compatible decoding without jumpHosts key") + func testBackwardCompatibleDecoding() throws { + let jsonString = """ + { + "enabled": true, + "host": "example.com", + "port": 22, + "username": "admin", + "authMethod": "Password", + "privateKeyPath": "", + "useSSHConfig": true, + "agentSocketPath": "" + } + """ + let json = Data(jsonString.utf8) + + let config = try JSONDecoder().decode(SSHConfiguration.self, from: json) + #expect(config.jumpHosts.isEmpty) + #expect(config.host == "example.com") + #expect(config.enabled == true) + } } diff --git a/TableProTests/Core/SSH/SSHJumpHostTests.swift b/TableProTests/Core/SSH/SSHJumpHostTests.swift new file mode 100644 index 00000000..cf9c5674 --- /dev/null +++ b/TableProTests/Core/SSH/SSHJumpHostTests.swift @@ -0,0 +1,88 @@ +// +// SSHJumpHostTests.swift +// TableProTests +// +// Tests for SSHJumpHost model +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SSH Jump Host") +struct SSHJumpHostTests { + @Test("proxyJumpString formats correctly") + func testProxyJumpString() { + let jumpHost = SSHJumpHost(host: "bastion.example.com", port: 2_222, username: "admin") + #expect(jumpHost.proxyJumpString == "admin@bastion.example.com:2222") + } + + @Test("proxyJumpString with default port") + func testProxyJumpStringDefaultPort() { + let jumpHost = SSHJumpHost(host: "bastion.example.com", username: "admin") + #expect(jumpHost.proxyJumpString == "admin@bastion.example.com:22") + } + + @Test("isValid with SSH Agent auth") + func testIsValidWithSSHAgent() { + let jumpHost = SSHJumpHost(host: "bastion.example.com", username: "admin", authMethod: .sshAgent) + #expect(jumpHost.isValid == true) + } + + @Test("isValid with Private Key auth and key path") + func testIsValidWithPrivateKey() { + let jumpHost = SSHJumpHost( + host: "bastion.example.com", username: "admin", + authMethod: .privateKey, privateKeyPath: "~/.ssh/id_rsa" + ) + #expect(jumpHost.isValid == true) + } + + @Test("isValid fails with Private Key auth and empty key path") + func testIsInvalidWithPrivateKeyNoPath() { + let jumpHost = SSHJumpHost( + host: "bastion.example.com", username: "admin", + authMethod: .privateKey, privateKeyPath: "" + ) + #expect(jumpHost.isValid == false) + } + + @Test("isValid fails with empty host") + func testIsInvalidWithEmptyHost() { + let jumpHost = SSHJumpHost(host: "", username: "admin") + #expect(jumpHost.isValid == false) + } + + @Test("isValid fails with empty username") + func testIsInvalidWithEmptyUsername() { + let jumpHost = SSHJumpHost(host: "bastion.example.com", username: "") + #expect(jumpHost.isValid == false) + } + + @Test("Codable round-trip preserves all fields") + func testCodableRoundTrip() throws { + let original = SSHJumpHost( + host: "bastion.example.com", port: 2_222, username: "admin", + authMethod: .privateKey, privateKeyPath: "~/.ssh/bastion_key" + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(SSHJumpHost.self, from: data) + + #expect(decoded.host == original.host) + #expect(decoded.port == original.port) + #expect(decoded.username == original.username) + #expect(decoded.authMethod == original.authMethod) + #expect(decoded.privateKeyPath == original.privateKeyPath) + } + + @Test("Default values are correct") + func testDefaultValues() { + let jumpHost = SSHJumpHost() + #expect(jumpHost.host == "") + #expect(jumpHost.port == 22) + #expect(jumpHost.username == "") + #expect(jumpHost.authMethod == .sshAgent) + #expect(jumpHost.privateKeyPath == "") + } +} diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 6dfe4d92..c8faf24c 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -226,6 +226,82 @@ Database Host: mydb.abc123.us-east-1.rds.amazonaws.com Database Port: 5432 ``` +## Multi-Jump SSH (ProxyJump) + +When a database server sits behind multiple bastion hosts, TablePro can chain SSH hops using OpenSSH's `-J` (ProxyJump) flag. A single `ssh` process handles all intermediate jumps. + +```mermaid +flowchart LR + subgraph mac ["Your Mac"] + TablePro["TablePro"] + end + + subgraph hop1 ["Bastion 1"] + B1["Jump Host 1"] + end + + subgraph hop2 ["Bastion 2"] + B2["Jump Host 2"] + end + + subgraph db ["Database Server"] + Database["MySQL
PostgreSQL"] + end + + TablePro -->|"Jump 1"| B1 -->|"Jump 2"| B2 -->|"Final Hop"| Database +``` + +### Setting Up Multi-Jump + +1. Open the connection form and switch to the **SSH Tunnel** tab +2. Enable SSH and configure the **final SSH server** (the one that can reach the database) +3. Expand the **Jump Hosts** section below the authentication settings +4. Click **Add Jump Host** and fill in each intermediate bastion host in order +5. Hosts are connected in sequence: first jump host is reached from your Mac, each subsequent host is reached through the previous one + +### Jump Host Settings + +Each jump host has: + +| Field | Description | +|-------|-------------| +| **Host** | Hostname or IP of the jump host | +| **Port** | SSH port (default `22`) | +| **Username** | SSH username for this hop | +| **Auth Method** | **Private Key** or **SSH Agent** (password auth is not supported for jump hosts) | +| **Key File** | Path to private key (if using Private Key auth) | + +### Example: Two Bastion Hosts + +``` +Jump Host 1: admin@bastion1.example.com:22 (SSH Agent) +Jump Host 2: tunnel@bastion2.internal:2222 (Private Key) + +SSH Server: deploy@final-ssh.internal:22 +Database Host: db.internal:5432 +``` + +This produces the equivalent of: +```bash +ssh -J admin@bastion1.example.com:22,tunnel@bastion2.internal:2222 deploy@final-ssh.internal +``` + +### SSH Config Integration + +TablePro reads `ProxyJump` directives from `~/.ssh/config`. When you select a config host that has `ProxyJump` set, the jump hosts are auto-filled. + +``` +# ~/.ssh/config +Host production-db + HostName final-ssh.internal + User deploy + ProxyJump admin@bastion1.example.com,tunnel@bastion2.internal:2222 +``` + + +Jump hosts only support **Private Key** and **SSH Agent** authentication. Password authentication is not available for intermediate hops because OpenSSH's `-J` flag does not support interactive password prompts for jump hosts. + + ## SSH Key Setup ### Generating SSH Keys diff --git a/docs/vi/databases/ssh-tunneling.mdx b/docs/vi/databases/ssh-tunneling.mdx index 9052379d..8a625a86 100644 --- a/docs/vi/databases/ssh-tunneling.mdx +++ b/docs/vi/databases/ssh-tunneling.mdx @@ -226,6 +226,82 @@ Database Host: mydb.abc123.us-east-1.rds.amazonaws.com Database Port: 5432 ``` +## Multi-Jump SSH (ProxyJump) + +Khi database server nằm sau nhiều bastion host, TablePro có thể nối chuỗi các bước nhảy SSH bằng cờ `-J` (ProxyJump) của OpenSSH. Một tiến trình `ssh` duy nhất xử lý tất cả các hop trung gian. + +```mermaid +flowchart LR + subgraph mac ["Your Mac"] + TablePro["TablePro"] + end + + subgraph hop1 ["Bastion 1"] + B1["Jump Host 1"] + end + + subgraph hop2 ["Bastion 2"] + B2["Jump Host 2"] + end + + subgraph db ["Database Server"] + Database["MySQL
PostgreSQL"] + end + + TablePro -->|"Jump 1"| B1 -->|"Jump 2"| B2 -->|"Final Hop"| Database +``` + +### Thiết lập Multi-Jump + +1. Mở form kết nối và chuyển sang tab **SSH Tunnel** +2. Bật SSH và cấu hình **SSH server cuối cùng** (server có thể truy cập database) +3. Mở rộng phần **Jump Hosts** bên dưới cài đặt xác thực +4. Nhấp **Add Jump Host** và điền thông tin từng bastion host trung gian theo thứ tự +5. Các host được kết nối tuần tự: jump host đầu tiên được truy cập từ Mac, mỗi host tiếp theo được truy cập qua host trước đó + +### Cài đặt Jump Host + +Mỗi jump host có: + +| Trường | Mô tả | +|-------|-------------| +| **Host** | Hostname hoặc IP của jump host | +| **Port** | Cổng SSH (mặc định `22`) | +| **Username** | Tên người dùng SSH cho hop này | +| **Auth Method** | **Private Key** hoặc **SSH Agent** (không hỗ trợ password cho jump host) | +| **Key File** | Đường dẫn private key (nếu dùng Private Key) | + +### Ví dụ: Hai Bastion Host + +``` +Jump Host 1: admin@bastion1.example.com:22 (SSH Agent) +Jump Host 2: tunnel@bastion2.internal:2222 (Private Key) + +SSH Server: deploy@final-ssh.internal:22 +Database Host: db.internal:5432 +``` + +Tương đương lệnh: +```bash +ssh -J admin@bastion1.example.com:22,tunnel@bastion2.internal:2222 deploy@final-ssh.internal +``` + +### Tích hợp SSH Config + +TablePro đọc directive `ProxyJump` từ `~/.ssh/config`. Khi chọn config host có `ProxyJump`, các jump host tự động được điền. + +``` +# ~/.ssh/config +Host production-db + HostName final-ssh.internal + User deploy + ProxyJump admin@bastion1.example.com,tunnel@bastion2.internal:2222 +``` + + +Jump host chỉ hỗ trợ xác thực **Private Key** và **SSH Agent**. Xác thực password không khả dụng cho các hop trung gian vì cờ `-J` của OpenSSH không hỗ trợ nhập password tương tác cho jump host. + + ## Thiết lập SSH Key ### Tạo SSH Key