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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections

### Fixed

- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`)
Expand Down
56 changes: 39 additions & 17 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -426,39 +426,61 @@ final class DatabaseManager {
for connection: DatabaseConnection,
sshPasswordOverride: String? = nil
) async throws -> DatabaseConnection {
guard connection.sshConfig.enabled else {
// Resolve SSH configuration: profile takes priority over inline
let sshConfig: SSHConfiguration
let isProfile: Bool
let secretOwnerId: UUID

if let profileId = connection.sshProfileId,
let profile = SSHProfileStorage.shared.profile(for: profileId) {
sshConfig = profile.toSSHConfiguration()
secretOwnerId = profileId
isProfile = true
} else {
sshConfig = connection.sshConfig
secretOwnerId = connection.id
isProfile = false
}

guard sshConfig.enabled else {
return connection
}

// Load Keychain credentials off the main thread to avoid blocking UI
let connectionId = connection.id
let (storedSshPassword, keyPassphrase, totpSecret) = await Task.detached {
let pwd = ConnectionStorage.shared.loadSSHPassword(for: connectionId)
let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: connectionId)
let totp = ConnectionStorage.shared.loadTOTPSecret(for: connectionId)
return (pwd, phrase, totp)
if isProfile {
let pwd = SSHProfileStorage.shared.loadSSHPassword(for: secretOwnerId)
let phrase = SSHProfileStorage.shared.loadKeyPassphrase(for: secretOwnerId)
let totp = SSHProfileStorage.shared.loadTOTPSecret(for: secretOwnerId)
return (pwd, phrase, totp)
} else {
let pwd = ConnectionStorage.shared.loadSSHPassword(for: secretOwnerId)
let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: secretOwnerId)
let totp = ConnectionStorage.shared.loadTOTPSecret(for: secretOwnerId)
return (pwd, phrase, totp)
}
}.value

let sshPassword = sshPasswordOverride ?? storedSshPassword

let tunnelPort = try await SSHTunnelManager.shared.createTunnel(
connectionId: connection.id,
sshHost: connection.sshConfig.host,
sshPort: connection.sshConfig.port,
sshUsername: connection.sshConfig.username,
authMethod: connection.sshConfig.authMethod,
privateKeyPath: connection.sshConfig.privateKeyPath,
sshHost: sshConfig.host,
sshPort: sshConfig.port,
sshUsername: sshConfig.username,
authMethod: sshConfig.authMethod,
privateKeyPath: sshConfig.privateKeyPath,
keyPassphrase: keyPassphrase,
sshPassword: sshPassword,
agentSocketPath: connection.sshConfig.agentSocketPath,
agentSocketPath: sshConfig.agentSocketPath,
remoteHost: connection.host,
remotePort: connection.port,
jumpHosts: connection.sshConfig.jumpHosts,
totpMode: connection.sshConfig.totpMode,
jumpHosts: sshConfig.jumpHosts,
totpMode: sshConfig.totpMode,
totpSecret: totpSecret,
totpAlgorithm: connection.sshConfig.totpAlgorithm,
totpDigits: connection.sshConfig.totpDigits,
totpPeriod: connection.sshConfig.totpPeriod
totpAlgorithm: sshConfig.totpAlgorithm,
totpDigits: sshConfig.totpDigits,
totpPeriod: sshConfig.totpPeriod
)

// Adapt SSL config for tunnel: SSH already authenticates the server,
Expand Down
9 changes: 8 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ final class ConnectionStorage {
color: connection.color,
tagId: connection.tagId,
groupId: connection.groupId,
sshProfileId: connection.sshProfileId,
safeModeLevel: connection.safeModeLevel,
aiPolicy: connection.aiPolicy,
redisDatabase: connection.redisDatabase,
Expand Down Expand Up @@ -259,6 +260,7 @@ private struct StoredConnection: Codable {
let color: String
let tagId: String?
let groupId: String?
let sshProfileId: String?

// Safe mode level
let safeModeLevel: String
Expand Down Expand Up @@ -327,6 +329,7 @@ private struct StoredConnection: Codable {
self.color = connection.color.rawValue
self.tagId = connection.tagId?.uuidString
self.groupId = connection.groupId?.uuidString
self.sshProfileId = connection.sshProfileId?.uuidString

// Safe mode level
self.safeModeLevel = connection.safeModeLevel.rawValue
Expand Down Expand Up @@ -361,7 +364,7 @@ private struct StoredConnection: Codable {
case sshUseSSHConfig, sshAgentSocketPath
case totpMode, totpAlgorithm, totpDigits, totpPeriod
case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath
case color, tagId, groupId
case color, tagId, groupId, sshProfileId
case safeModeLevel
case isReadOnly // Legacy key for migration reading only
case aiPolicy
Expand Down Expand Up @@ -398,6 +401,7 @@ private struct StoredConnection: Codable {
try container.encode(color, forKey: .color)
try container.encodeIfPresent(tagId, forKey: .tagId)
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
Expand Down Expand Up @@ -448,6 +452,7 @@ private struct StoredConnection: Codable {
color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
sshProfileId = try container.decodeIfPresent(String.self, forKey: .sshProfileId)
// Migration: read new safeModeLevel first, fall back to old isReadOnly boolean
if let levelString = try container.decodeIfPresent(String.self, forKey: .safeModeLevel) {
safeModeLevel = levelString
Expand Down Expand Up @@ -492,6 +497,7 @@ private struct StoredConnection: Codable {
let parsedColor = ConnectionColor(rawValue: color) ?? .none
let parsedTagId = tagId.flatMap { UUID(uuidString: $0) }
let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) }
let parsedSSHProfileId = sshProfileId.flatMap { UUID(uuidString: $0) }
let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }

// Merge legacy named keys into additionalFields as fallback
Expand Down Expand Up @@ -524,6 +530,7 @@ private struct StoredConnection: Codable {
color: parsedColor,
tagId: parsedTagId,
groupId: parsedGroupId,
sshProfileId: parsedSSHProfileId,
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
aiPolicy: parsedAIPolicy,
redisDatabase: redisDatabase,
Expand Down
146 changes: 146 additions & 0 deletions TablePro/Core/Storage/SSHProfileStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//
// SSHProfileStorage.swift
// TablePro
//

import Foundation
import os

final class SSHProfileStorage {
static let shared = SSHProfileStorage()
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHProfileStorage")

private let profilesKey = "com.TablePro.sshProfiles"
private let defaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lastLoadFailed = false

private init() {}

// MARK: - Profile CRUD

func loadProfiles() -> [SSHProfile] {
guard let data = defaults.data(forKey: profilesKey) else {
lastLoadFailed = false
return []
}

do {
let profiles = try decoder.decode([SSHProfile].self, from: data)
lastLoadFailed = false
return profiles
} catch {
Self.logger.error("Failed to load SSH profiles: \(error)")
lastLoadFailed = true
return []
}
}

func saveProfiles(_ profiles: [SSHProfile]) {
guard !lastLoadFailed else {
Self.logger.warning("Refusing to save SSH profiles: previous load failed (would overwrite existing data)")
return
}
do {
let data = try encoder.encode(profiles)
defaults.set(data, forKey: profilesKey)
SyncChangeTracker.shared.markDirty(.sshProfile, ids: profiles.map { $0.id.uuidString })
} catch {
Self.logger.error("Failed to save SSH profiles: \(error)")
}
}

func saveProfilesWithoutSync(_ profiles: [SSHProfile]) {
guard !lastLoadFailed else { return }
do {
let data = try encoder.encode(profiles)
defaults.set(data, forKey: profilesKey)
} catch {
Self.logger.error("Failed to save SSH profiles: \(error)")
}
}

func addProfile(_ profile: SSHProfile) {
var profiles = loadProfiles()
guard !lastLoadFailed else { return }
profiles.append(profile)
saveProfiles(profiles)
}

func updateProfile(_ profile: SSHProfile) {
var profiles = loadProfiles()
guard !lastLoadFailed else { return }
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
profiles[index] = profile
saveProfiles(profiles)
}
}

func deleteProfile(_ profile: SSHProfile) {
SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString)
var profiles = loadProfiles()
guard !lastLoadFailed else { return }
profiles.removeAll { $0.id == profile.id }
saveProfiles(profiles)

deleteSSHPassword(for: profile.id)
deleteKeyPassphrase(for: profile.id)
deleteTOTPSecret(for: profile.id)
}

func profile(for id: UUID) -> SSHProfile? {
loadProfiles().first { $0.id == id }
}

// MARK: - SSH Password Storage

func saveSSHPassword(_ password: String, for profileId: UUID) {
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
KeychainHelper.shared.saveString(password, forKey: key)
}

func loadSSHPassword(for profileId: UUID) -> String? {
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
return KeychainHelper.shared.loadString(forKey: key)
}

func deleteSSHPassword(for profileId: UUID) {
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
KeychainHelper.shared.delete(key: key)
}

// MARK: - Key Passphrase Storage

func saveKeyPassphrase(_ passphrase: String, for profileId: UUID) {
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
KeychainHelper.shared.saveString(passphrase, forKey: key)
}

func loadKeyPassphrase(for profileId: UUID) -> String? {
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
return KeychainHelper.shared.loadString(forKey: key)
}

func deleteKeyPassphrase(for profileId: UUID) {
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
KeychainHelper.shared.delete(key: key)
}

// MARK: - TOTP Secret Storage

func saveTOTPSecret(_ secret: String, for profileId: UUID) {
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
KeychainHelper.shared.saveString(secret, forKey: key)
}

func loadTOTPSecret(for profileId: UUID) -> String? {
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
return KeychainHelper.shared.loadString(forKey: key)
}

func deleteTOTPSecret(for profileId: UUID) {
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
KeychainHelper.shared.delete(key: key)
}
}
Loading
Loading