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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 50 additions & 2 deletions TablePro/Core/SSH/SSHConfigParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -85,7 +87,8 @@ final class SSHConfigParser {
port: currentPort,
user: currentUser,
identityFile: expandPath(currentIdentityFile),
identityAgent: expandPath(currentIdentityAgent)
identityAgent: expandPath(currentIdentityAgent),
proxyJump: currentProxyJump
))
}
}
Expand All @@ -97,6 +100,7 @@ final class SSHConfigParser {
currentUser = nil
currentIdentityFile = nil
currentIdentityAgent = nil
currentProxyJump = nil

case "hostname":
currentHostname = value
Expand All @@ -113,6 +117,9 @@ final class SSHConfigParser {
case "identityagent":
currentIdentityAgent = value

case "proxyjump":
currentProxyJump = value

default:
break // Ignore other directives
}
Expand All @@ -127,7 +134,8 @@ final class SSHConfigParser {
port: currentPort,
user: currentUser,
identityFile: expandPath(currentIdentityFile),
identityAgent: expandPath(currentIdentityAgent)
identityAgent: expandPath(currentIdentityAgent),
proxyJump: currentProxyJump
))
}

Expand All @@ -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..<atIndex])
remaining = String(remaining[remaining.index(after: atIndex)...])
}

// Extract host and port (supports bracketed IPv6, e.g. [::1]:22)
if remaining.hasPrefix("["),
let closeBracket = remaining.firstIndex(of: "]") {
jumpHost.host = String(remaining[remaining.index(after: remaining.startIndex)..<closeBracket])
let afterBracket = remaining.index(after: closeBracket)
if afterBracket < remaining.endIndex,
remaining[afterBracket] == ":",
let port = Int(String(remaining[remaining.index(after: afterBracket)...])) {
jumpHost.port = port
}
} else if let colonIndex = remaining.lastIndex(of: ":"),
let port = Int(String(remaining[remaining.index(after: colonIndex)...])) {
jumpHost.host = String(remaining[remaining.startIndex..<colonIndex])
jumpHost.port = port
} else {
jumpHost.host = remaining
}

jumpHosts.append(jumpHost)
}

return jumpHosts
}

/// Expand ~ to home directory in path
private static func expandPath(_ path: String?) -> String? {
guard let path = path else { return nil }
Expand Down
14 changes: 13 additions & 1 deletion TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
54 changes: 51 additions & 3 deletions TablePro/Models/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ?? []
}
}

Expand Down
24 changes: 21 additions & 3 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,9 @@
}
}
}
},
"Add Jump Host" : {

},
"Add Provider" : {
"localizations" : {
Expand Down Expand Up @@ -1049,6 +1052,9 @@
}
}
}
},
"admin" : {

},
"Agent Socket" : {
"localizations" : {
Expand Down Expand Up @@ -1359,6 +1365,9 @@
}
}
}
},
"Auth" : {

},
"Authentication" : {
"localizations" : {
Expand Down Expand Up @@ -1471,6 +1480,9 @@
}
}
}
},
"bastion.example.com" : {

},
"between" : {
"localizations" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -5220,9 +5238,6 @@
}
}
}
},
"Leave empty for SSH_AUTH_SOCK" : {

},
"Length" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -5773,6 +5788,9 @@
}
}
}
},
"New Jump Host" : {

},
"New query tab" : {
"extractionState" : "stale",
Expand Down
Loading