Skip to content

Commit a7d9a69

Browse files
authored
feat: add reusable SSH tunnel profiles (#385)
* feat: add reusable SSH tunnel profiles (#381) * fix: prevent SSHProfileStorage from overwriting data on decode failure * fix: remove unrelated DynamoDB target from pbxproj * fix: remove unrelated DynamoDB type from PR * fix: remove DynamoDB from changelog, localize labels, cache profile list, fix indentation * feat: add SSH profile editor with create, edit, save-as, and delete * fix: address review issues in SSH profile integration - Fix testConnection leaking Keychain secrets and overriding profile passwords - Clean up all temporary Keychain entries (password, passphrase, TOTP) after test - Skip inline SSH validation when a profile is selected (isValid) - Move profile list loading from section onAppear to loadConnectionData - Separate onSave/onDelete callbacks in SSHProfileEditorView - Pass inline secrets to Save as Profile flow so TOTP secret is preserved * feat: add iCloud sync for SSH profiles and documentation - Add SyncRecordType.sshProfile with CKRecord mapping - Add syncSSHProfiles toggle to SyncSettings - Add sync tracking (markDirty/markDeleted) in SSHProfileStorage - Add saveProfilesWithoutSync for applying remote changes - Update SyncCoordinator: push, pull, delete, conflict handling - Handle .sshProfile in ConflictResolutionView - Add SSH Profiles feature docs (EN/VI/ZH) - Update SSH Tunneling docs with link to profiles - Update docs.json navigation * docs: rewrite SSH profiles docs for accuracy and clarity * fix: address PR review - profile validation, secret cleanup, edit vs prefill * fix: cache selected profile lookup, fix sshInlineFields indentation
1 parent ae17f84 commit a7d9a69

File tree

20 files changed

+1510
-212
lines changed

20 files changed

+1510
-212
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections
13+
1014
### Fixed
1115

1216
- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`)

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -426,39 +426,61 @@ final class DatabaseManager {
426426
for connection: DatabaseConnection,
427427
sshPasswordOverride: String? = nil
428428
) async throws -> DatabaseConnection {
429-
guard connection.sshConfig.enabled else {
429+
// Resolve SSH configuration: profile takes priority over inline
430+
let sshConfig: SSHConfiguration
431+
let isProfile: Bool
432+
let secretOwnerId: UUID
433+
434+
if let profileId = connection.sshProfileId,
435+
let profile = SSHProfileStorage.shared.profile(for: profileId) {
436+
sshConfig = profile.toSSHConfiguration()
437+
secretOwnerId = profileId
438+
isProfile = true
439+
} else {
440+
sshConfig = connection.sshConfig
441+
secretOwnerId = connection.id
442+
isProfile = false
443+
}
444+
445+
guard sshConfig.enabled else {
430446
return connection
431447
}
432448

433449
// Load Keychain credentials off the main thread to avoid blocking UI
434-
let connectionId = connection.id
435450
let (storedSshPassword, keyPassphrase, totpSecret) = await Task.detached {
436-
let pwd = ConnectionStorage.shared.loadSSHPassword(for: connectionId)
437-
let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: connectionId)
438-
let totp = ConnectionStorage.shared.loadTOTPSecret(for: connectionId)
439-
return (pwd, phrase, totp)
451+
if isProfile {
452+
let pwd = SSHProfileStorage.shared.loadSSHPassword(for: secretOwnerId)
453+
let phrase = SSHProfileStorage.shared.loadKeyPassphrase(for: secretOwnerId)
454+
let totp = SSHProfileStorage.shared.loadTOTPSecret(for: secretOwnerId)
455+
return (pwd, phrase, totp)
456+
} else {
457+
let pwd = ConnectionStorage.shared.loadSSHPassword(for: secretOwnerId)
458+
let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: secretOwnerId)
459+
let totp = ConnectionStorage.shared.loadTOTPSecret(for: secretOwnerId)
460+
return (pwd, phrase, totp)
461+
}
440462
}.value
441463

442464
let sshPassword = sshPasswordOverride ?? storedSshPassword
443465

444466
let tunnelPort = try await SSHTunnelManager.shared.createTunnel(
445467
connectionId: connection.id,
446-
sshHost: connection.sshConfig.host,
447-
sshPort: connection.sshConfig.port,
448-
sshUsername: connection.sshConfig.username,
449-
authMethod: connection.sshConfig.authMethod,
450-
privateKeyPath: connection.sshConfig.privateKeyPath,
468+
sshHost: sshConfig.host,
469+
sshPort: sshConfig.port,
470+
sshUsername: sshConfig.username,
471+
authMethod: sshConfig.authMethod,
472+
privateKeyPath: sshConfig.privateKeyPath,
451473
keyPassphrase: keyPassphrase,
452474
sshPassword: sshPassword,
453-
agentSocketPath: connection.sshConfig.agentSocketPath,
475+
agentSocketPath: sshConfig.agentSocketPath,
454476
remoteHost: connection.host,
455477
remotePort: connection.port,
456-
jumpHosts: connection.sshConfig.jumpHosts,
457-
totpMode: connection.sshConfig.totpMode,
478+
jumpHosts: sshConfig.jumpHosts,
479+
totpMode: sshConfig.totpMode,
458480
totpSecret: totpSecret,
459-
totpAlgorithm: connection.sshConfig.totpAlgorithm,
460-
totpDigits: connection.sshConfig.totpDigits,
461-
totpPeriod: connection.sshConfig.totpPeriod
481+
totpAlgorithm: sshConfig.totpAlgorithm,
482+
totpDigits: sshConfig.totpDigits,
483+
totpPeriod: sshConfig.totpPeriod
462484
)
463485

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

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ final class ConnectionStorage {
122122
color: connection.color,
123123
tagId: connection.tagId,
124124
groupId: connection.groupId,
125+
sshProfileId: connection.sshProfileId,
125126
safeModeLevel: connection.safeModeLevel,
126127
aiPolicy: connection.aiPolicy,
127128
redisDatabase: connection.redisDatabase,
@@ -259,6 +260,7 @@ private struct StoredConnection: Codable {
259260
let color: String
260261
let tagId: String?
261262
let groupId: String?
263+
let sshProfileId: String?
262264

263265
// Safe mode level
264266
let safeModeLevel: String
@@ -327,6 +329,7 @@ private struct StoredConnection: Codable {
327329
self.color = connection.color.rawValue
328330
self.tagId = connection.tagId?.uuidString
329331
self.groupId = connection.groupId?.uuidString
332+
self.sshProfileId = connection.sshProfileId?.uuidString
330333

331334
// Safe mode level
332335
self.safeModeLevel = connection.safeModeLevel.rawValue
@@ -361,7 +364,7 @@ private struct StoredConnection: Codable {
361364
case sshUseSSHConfig, sshAgentSocketPath
362365
case totpMode, totpAlgorithm, totpDigits, totpPeriod
363366
case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath
364-
case color, tagId, groupId
367+
case color, tagId, groupId, sshProfileId
365368
case safeModeLevel
366369
case isReadOnly // Legacy key for migration reading only
367370
case aiPolicy
@@ -398,6 +401,7 @@ private struct StoredConnection: Codable {
398401
try container.encode(color, forKey: .color)
399402
try container.encodeIfPresent(tagId, forKey: .tagId)
400403
try container.encodeIfPresent(groupId, forKey: .groupId)
404+
try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId)
401405
try container.encode(safeModeLevel, forKey: .safeModeLevel)
402406
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
403407
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
@@ -448,6 +452,7 @@ private struct StoredConnection: Codable {
448452
color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue
449453
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
450454
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
455+
sshProfileId = try container.decodeIfPresent(String.self, forKey: .sshProfileId)
451456
// Migration: read new safeModeLevel first, fall back to old isReadOnly boolean
452457
if let levelString = try container.decodeIfPresent(String.self, forKey: .safeModeLevel) {
453458
safeModeLevel = levelString
@@ -492,6 +497,7 @@ private struct StoredConnection: Codable {
492497
let parsedColor = ConnectionColor(rawValue: color) ?? .none
493498
let parsedTagId = tagId.flatMap { UUID(uuidString: $0) }
494499
let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) }
500+
let parsedSSHProfileId = sshProfileId.flatMap { UUID(uuidString: $0) }
495501
let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }
496502

497503
// Merge legacy named keys into additionalFields as fallback
@@ -524,6 +530,7 @@ private struct StoredConnection: Codable {
524530
color: parsedColor,
525531
tagId: parsedTagId,
526532
groupId: parsedGroupId,
533+
sshProfileId: parsedSSHProfileId,
527534
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
528535
aiPolicy: parsedAIPolicy,
529536
redisDatabase: redisDatabase,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//
2+
// SSHProfileStorage.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
final class SSHProfileStorage {
10+
static let shared = SSHProfileStorage()
11+
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHProfileStorage")
12+
13+
private let profilesKey = "com.TablePro.sshProfiles"
14+
private let defaults = UserDefaults.standard
15+
private let encoder = JSONEncoder()
16+
private let decoder = JSONDecoder()
17+
private var lastLoadFailed = false
18+
19+
private init() {}
20+
21+
// MARK: - Profile CRUD
22+
23+
func loadProfiles() -> [SSHProfile] {
24+
guard let data = defaults.data(forKey: profilesKey) else {
25+
lastLoadFailed = false
26+
return []
27+
}
28+
29+
do {
30+
let profiles = try decoder.decode([SSHProfile].self, from: data)
31+
lastLoadFailed = false
32+
return profiles
33+
} catch {
34+
Self.logger.error("Failed to load SSH profiles: \(error)")
35+
lastLoadFailed = true
36+
return []
37+
}
38+
}
39+
40+
func saveProfiles(_ profiles: [SSHProfile]) {
41+
guard !lastLoadFailed else {
42+
Self.logger.warning("Refusing to save SSH profiles: previous load failed (would overwrite existing data)")
43+
return
44+
}
45+
do {
46+
let data = try encoder.encode(profiles)
47+
defaults.set(data, forKey: profilesKey)
48+
SyncChangeTracker.shared.markDirty(.sshProfile, ids: profiles.map { $0.id.uuidString })
49+
} catch {
50+
Self.logger.error("Failed to save SSH profiles: \(error)")
51+
}
52+
}
53+
54+
func saveProfilesWithoutSync(_ profiles: [SSHProfile]) {
55+
guard !lastLoadFailed else { return }
56+
do {
57+
let data = try encoder.encode(profiles)
58+
defaults.set(data, forKey: profilesKey)
59+
} catch {
60+
Self.logger.error("Failed to save SSH profiles: \(error)")
61+
}
62+
}
63+
64+
func addProfile(_ profile: SSHProfile) {
65+
var profiles = loadProfiles()
66+
guard !lastLoadFailed else { return }
67+
profiles.append(profile)
68+
saveProfiles(profiles)
69+
}
70+
71+
func updateProfile(_ profile: SSHProfile) {
72+
var profiles = loadProfiles()
73+
guard !lastLoadFailed else { return }
74+
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
75+
profiles[index] = profile
76+
saveProfiles(profiles)
77+
}
78+
}
79+
80+
func deleteProfile(_ profile: SSHProfile) {
81+
SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString)
82+
var profiles = loadProfiles()
83+
guard !lastLoadFailed else { return }
84+
profiles.removeAll { $0.id == profile.id }
85+
saveProfiles(profiles)
86+
87+
deleteSSHPassword(for: profile.id)
88+
deleteKeyPassphrase(for: profile.id)
89+
deleteTOTPSecret(for: profile.id)
90+
}
91+
92+
func profile(for id: UUID) -> SSHProfile? {
93+
loadProfiles().first { $0.id == id }
94+
}
95+
96+
// MARK: - SSH Password Storage
97+
98+
func saveSSHPassword(_ password: String, for profileId: UUID) {
99+
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
100+
KeychainHelper.shared.saveString(password, forKey: key)
101+
}
102+
103+
func loadSSHPassword(for profileId: UUID) -> String? {
104+
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
105+
return KeychainHelper.shared.loadString(forKey: key)
106+
}
107+
108+
func deleteSSHPassword(for profileId: UUID) {
109+
let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)"
110+
KeychainHelper.shared.delete(key: key)
111+
}
112+
113+
// MARK: - Key Passphrase Storage
114+
115+
func saveKeyPassphrase(_ passphrase: String, for profileId: UUID) {
116+
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
117+
KeychainHelper.shared.saveString(passphrase, forKey: key)
118+
}
119+
120+
func loadKeyPassphrase(for profileId: UUID) -> String? {
121+
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
122+
return KeychainHelper.shared.loadString(forKey: key)
123+
}
124+
125+
func deleteKeyPassphrase(for profileId: UUID) {
126+
let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)"
127+
KeychainHelper.shared.delete(key: key)
128+
}
129+
130+
// MARK: - TOTP Secret Storage
131+
132+
func saveTOTPSecret(_ secret: String, for profileId: UUID) {
133+
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
134+
KeychainHelper.shared.saveString(secret, forKey: key)
135+
}
136+
137+
func loadTOTPSecret(for profileId: UUID) -> String? {
138+
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
139+
return KeychainHelper.shared.loadString(forKey: key)
140+
}
141+
142+
func deleteTOTPSecret(for profileId: UUID) {
143+
let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)"
144+
KeychainHelper.shared.delete(key: key)
145+
}
146+
}

0 commit comments

Comments
 (0)