diff --git a/CHANGELOG.md b/CHANGELOG.md index 121c26fc3..719082e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index e851c8538..f2c133571 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -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, diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index b4001e05d..af31dfd71 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -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, @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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, diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift new file mode 100644 index 000000000..73a1ff1c2 --- /dev/null +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -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) + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 94d57d48c..3d64c2121 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -146,12 +146,17 @@ final class SyncCoordinator { changeTracker.markDirty(.tag, id: tag.id.uuidString) } + let sshProfiles = SSHProfileStorage.shared.loadProfiles() + for profile in sshProfiles { + changeTracker.markDirty(.sshProfile, id: profile.id.uuidString) + } + // Mark all settings categories as dirty for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] { changeTracker.markDirty(.settings, id: category) } - Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, 8 settings categories") + Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, \(sshProfiles.count) SSH profiles, 8 settings categories") } /// Called when user disables sync in settings @@ -254,6 +259,11 @@ final class SyncCoordinator { collectDirtyTags(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) } + // Collect dirty SSH profiles + if settings.syncSSHProfiles { + collectDirtySSHProfiles(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + // Collect unsynced query history if settings.syncQueryHistory { let limit = settings.historySyncLimit.limit ?? Int.max @@ -301,6 +311,9 @@ final class SyncCoordinator { changeTracker.clearAllDirty(.group) changeTracker.clearAllDirty(.tag) } + if settings.syncSSHProfiles { + changeTracker.clearAllDirty(.sshProfile) + } if settings.syncSettings { changeTracker.clearAllDirty(.settings) } @@ -322,6 +335,11 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .tag, id: tombstone.id) } } + if settings.syncSSHProfiles { + for tombstone in metadataStorage.tombstones(for: .sshProfile) { + metadataStorage.removeTombstone(type: .sshProfile, id: tombstone.id) + } + } if settings.syncSettings { for tombstone in metadataStorage.tombstones(for: .settings) { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) @@ -416,6 +434,8 @@ final class SyncCoordinator { case SyncRecordType.tag.rawValue where settings.syncGroupsAndTags: applyRemoteTag(record) groupsOrTagsChanged = true + case SyncRecordType.sshProfile.rawValue where settings.syncSSHProfiles: + applyRemoteSSHProfile(record) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) case SyncRecordType.queryHistory.rawValue where settings.syncQueryHistory: @@ -494,6 +514,18 @@ final class SyncCoordinator { TagStorage.shared.saveTags(tags) } + private func applyRemoteSSHProfile(_ record: CKRecord) { + guard let remoteProfile = SyncRecordMapper.toSSHProfile(record) else { return } + + var profiles = SSHProfileStorage.shared.loadProfiles() + if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) { + profiles[index] = remoteProfile + } else { + profiles.append(remoteProfile) + } + SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) + } + private func applyRemoteSettings(_ record: CKRecord) { guard let category = SyncRecordMapper.settingsCategory(from: record), let data = SyncRecordMapper.settingsData(from: record) @@ -561,6 +593,15 @@ final class SyncCoordinator { TagStorage.shared.saveTags(tags) } } + + if recordName.hasPrefix("SSHProfile_") { + let uuidString = String(recordName.dropFirst("SSHProfile_".count)) + if let uuid = UUID(uuidString: uuidString) { + var profiles = SSHProfileStorage.shared.loadProfiles() + profiles.removeAll { $0.id == uuid } + SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) + } + } } // MARK: - Observers @@ -654,6 +695,7 @@ final class SyncCoordinator { case SyncRecordType.tag.rawValue: syncRecordType = .tag case SyncRecordType.settings.rawValue: syncRecordType = .settings case SyncRecordType.queryHistory.rawValue: syncRecordType = .queryHistory + case SyncRecordType.sshProfile.rawValue: syncRecordType = .sshProfile default: continue } @@ -786,4 +828,26 @@ final class SyncCoordinator { ) } } + + private func collectDirtySSHProfiles( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyProfileIds = changeTracker.dirtyRecords(for: .sshProfile) + if !dirtyProfileIds.isEmpty { + let profiles = SSHProfileStorage.shared.loadProfiles() + for id in dirtyProfileIds { + if let profile = profiles.first(where: { $0.id.uuidString == id }) { + records.append(SyncRecordMapper.toCKRecord(profile, in: zoneID)) + } + } + } + + for tombstone in metadataStorage.tombstones(for: .sshProfile) { + deletions.append( + SyncRecordMapper.recordID(type: .sshProfile, id: tombstone.id, in: zoneID) + ) + } + } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index f72f7df4e..f3e554d17 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -18,6 +18,7 @@ enum SyncRecordType: String, CaseIterable { case queryHistory = "QueryHistory" case favorite = "SQLFavorite" case favoriteFolder = "SQLFavoriteFolder" + case sshProfile = "SSHProfile" } /// Pure-function mapper between local models and CKRecord @@ -44,6 +45,7 @@ struct SyncRecordMapper { case .queryHistory: recordName = "History_\(id)" case .favorite: recordName = "Favorite_\(id)" case .favoriteFolder: recordName = "FavoriteFolder_\(id)" + case .sshProfile: recordName = "SSHProfile_\(id)" } return CKRecord.ID(recordName: recordName, zoneID: zone) } @@ -311,4 +313,82 @@ struct SyncRecordMapper { return record } + + // MARK: - SSH Profile + + static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { + let recordID = recordID(type: .sshProfile, id: profile.id.uuidString, in: zone) + let record = CKRecord(recordType: SyncRecordType.sshProfile.rawValue, recordID: recordID) + + record["profileId"] = profile.id.uuidString as CKRecordValue + record["name"] = profile.name as CKRecordValue + record["host"] = profile.host as CKRecordValue + record["port"] = Int64(profile.port) as CKRecordValue + record["username"] = profile.username as CKRecordValue + record["authMethod"] = profile.authMethod.rawValue as CKRecordValue + record["privateKeyPath"] = profile.privateKeyPath as CKRecordValue + record["useSSHConfig"] = Int64(profile.useSSHConfig ? 1 : 0) as CKRecordValue + record["agentSocketPath"] = profile.agentSocketPath as CKRecordValue + record["totpMode"] = profile.totpMode.rawValue as CKRecordValue + record["totpAlgorithm"] = profile.totpAlgorithm.rawValue as CKRecordValue + record["totpDigits"] = Int64(profile.totpDigits) as CKRecordValue + record["totpPeriod"] = Int64(profile.totpPeriod) as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + if !profile.jumpHosts.isEmpty { + do { + let jumpHostsData = try encoder.encode(profile.jumpHosts) + record["jumpHostsJson"] = jumpHostsData as CKRecordValue + } catch { + logger.warning("Failed to encode jump hosts for sync: \(error.localizedDescription)") + } + } + + return record + } + + static func toSSHProfile(_ record: CKRecord) -> SSHProfile? { + guard let profileIdString = record["profileId"] as? String, + let profileId = UUID(uuidString: profileIdString), + let name = record["name"] as? String + else { + logger.warning("Failed to decode SSH profile from CKRecord: missing required fields") + return nil + } + + let host = record["host"] as? String ?? "" + let port = (record["port"] as? Int64).map { Int($0) } ?? 22 + let username = record["username"] as? String ?? "" + let authMethodRaw = record["authMethod"] as? String ?? SSHAuthMethod.password.rawValue + let privateKeyPath = record["privateKeyPath"] as? String ?? "" + let useSSHConfig = (record["useSSHConfig"] as? Int64 ?? 1) != 0 + let agentSocketPath = record["agentSocketPath"] as? String ?? "" + let totpModeRaw = record["totpMode"] as? String ?? TOTPMode.none.rawValue + let totpAlgorithmRaw = record["totpAlgorithm"] as? String ?? TOTPAlgorithm.sha1.rawValue + let totpDigits = (record["totpDigits"] as? Int64).map { Int($0) } ?? 6 + let totpPeriod = (record["totpPeriod"] as? Int64).map { Int($0) } ?? 30 + + var jumpHosts: [SSHJumpHost] = [] + if let jumpHostsData = record["jumpHostsJson"] as? Data { + jumpHosts = (try? decoder.decode([SSHJumpHost].self, from: jumpHostsData)) ?? [] + } + + return SSHProfile( + id: profileId, + name: name, + host: host, + port: port, + username: username, + authMethod: SSHAuthMethod(rawValue: authMethodRaw) ?? .password, + privateKeyPath: privateKeyPath, + useSSHConfig: useSSHConfig, + agentSocketPath: agentSocketPath, + jumpHosts: jumpHosts, + totpMode: TOTPMode(rawValue: totpModeRaw) ?? .none, + totpAlgorithm: TOTPAlgorithm(rawValue: totpAlgorithmRaw) ?? .sha1, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 9da5d1654..f53f77498 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -375,6 +375,7 @@ struct DatabaseConnection: Identifiable, Hashable { var color: ConnectionColor var tagId: UUID? var groupId: UUID? + var sshProfileId: UUID? var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var additionalFields: [String: String] = [:] @@ -429,6 +430,7 @@ struct DatabaseConnection: Identifiable, Hashable { color: ConnectionColor = .none, tagId: UUID? = nil, groupId: UUID? = nil, + sshProfileId: UUID? = nil, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, mongoAuthSource: String? = nil, @@ -452,6 +454,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.color = color self.tagId = tagId self.groupId = groupId + self.sshProfileId = sshProfileId self.safeModeLevel = safeModeLevel self.aiPolicy = aiPolicy self.redisDatabase = redisDatabase diff --git a/TablePro/Models/Connection/SSHProfile.swift b/TablePro/Models/Connection/SSHProfile.swift new file mode 100644 index 000000000..db5ff29de --- /dev/null +++ b/TablePro/Models/Connection/SSHProfile.swift @@ -0,0 +1,91 @@ +// +// SSHProfile.swift +// TablePro +// + +import Foundation + +struct SSHProfile: Identifiable, Hashable, Codable, Sendable { + let id: UUID + var name: String + var host: String + var port: Int + var username: String + var authMethod: SSHAuthMethod + var privateKeyPath: String + var useSSHConfig: Bool + var agentSocketPath: String + var jumpHosts: [SSHJumpHost] + var totpMode: TOTPMode + var totpAlgorithm: TOTPAlgorithm + var totpDigits: Int + var totpPeriod: Int + + init( + id: UUID = UUID(), + name: String, + host: String = "", + port: Int = 22, + username: String = "", + authMethod: SSHAuthMethod = .password, + privateKeyPath: String = "", + useSSHConfig: Bool = true, + agentSocketPath: String = "", + jumpHosts: [SSHJumpHost] = [], + totpMode: TOTPMode = .none, + totpAlgorithm: TOTPAlgorithm = .sha1, + totpDigits: Int = 6, + totpPeriod: Int = 30 + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.privateKeyPath = privateKeyPath + self.useSSHConfig = useSSHConfig + self.agentSocketPath = agentSocketPath + self.jumpHosts = jumpHosts + self.totpMode = totpMode + self.totpAlgorithm = totpAlgorithm + self.totpDigits = totpDigits + self.totpPeriod = totpPeriod + } + + func toSSHConfiguration() -> SSHConfiguration { + var config = SSHConfiguration() + config.enabled = true + config.host = host + config.port = port + config.username = username + config.authMethod = authMethod + config.privateKeyPath = privateKeyPath + config.useSSHConfig = useSSHConfig + config.agentSocketPath = agentSocketPath + config.jumpHosts = jumpHosts + config.totpMode = totpMode + config.totpAlgorithm = totpAlgorithm + config.totpDigits = totpDigits + config.totpPeriod = totpPeriod + return config + } + + static func fromSSHConfiguration(_ config: SSHConfiguration, name: String) -> SSHProfile { + SSHProfile( + name: name, + host: config.host, + port: config.port, + username: config.username, + authMethod: config.authMethod, + privateKeyPath: config.privateKeyPath, + useSSHConfig: config.useSSHConfig, + agentSocketPath: config.agentSocketPath, + jumpHosts: config.jumpHosts, + totpMode: config.totpMode, + totpAlgorithm: config.totpAlgorithm, + totpDigits: config.totpDigits, + totpPeriod: config.totpPeriod + ) + } +} diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index 02fc6ebd6..b1e742015 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -16,6 +16,7 @@ struct SyncSettings: Codable, Equatable { var syncQueryHistory: Bool var historySyncLimit: HistorySyncLimit var syncPasswords: Bool + var syncSSHProfiles: Bool init( enabled: Bool, @@ -24,7 +25,8 @@ struct SyncSettings: Codable, Equatable { syncSettings: Bool, syncQueryHistory: Bool, historySyncLimit: HistorySyncLimit, - syncPasswords: Bool = false + syncPasswords: Bool = false, + syncSSHProfiles: Bool = true ) { self.enabled = enabled self.syncConnections = syncConnections @@ -33,6 +35,7 @@ struct SyncSettings: Codable, Equatable { self.syncQueryHistory = syncQueryHistory self.historySyncLimit = historySyncLimit self.syncPasswords = syncPasswords + self.syncSSHProfiles = syncSSHProfiles } init(from decoder: Decoder) throws { @@ -44,6 +47,7 @@ struct SyncSettings: Codable, Equatable { syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory) historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit) syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false + syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true } static let `default` = SyncSettings( @@ -53,7 +57,8 @@ struct SyncSettings: Codable, Equatable { syncSettings: true, syncQueryHistory: true, historySyncLimit: .entries500, - syncPasswords: false + syncPasswords: false, + syncSSHProfiles: true ) } diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 867edf007..09704871e 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -142,6 +142,13 @@ struct ConflictResolutionView: View { if let name = record["name"] as? String { fieldRow(label: String(localized: "Name"), value: name) } + case .sshProfile: + if let name = record["name"] as? String { + fieldRow(label: String(localized: "Name"), value: name) + } + if let host = record["host"] as? String { + fieldRow(label: "Host", value: host) + } } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 3375fb5e9..8e8d47912 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -56,6 +56,11 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length @State private var hasLoadedData = false // SSH Configuration + @State private var sshProfileId: UUID? + @State private var sshProfiles: [SSHProfile] = [] + @State private var showingCreateProfile = false + @State private var editingProfile: SSHProfile? + @State private var showingSaveAsProfile = false @State private var sshEnabled: Bool = false @State private var sshHost: String = "" @State private var sshPort: String = "22" @@ -447,207 +452,290 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } if sshEnabled { - Section(String(localized: "Server")) { - if !sshConfigEntries.isEmpty { - Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) - { - Text(String(localized: "Manual")).tag("") - ForEach(sshConfigEntries) { entry in - Text(entry.displayName).tag(entry.host) - } + sshProfileSection + + if let profile = selectedSSHProfile { + sshProfileSummarySection(profile) + } else if sshProfileId != nil { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text("Selected SSH profile no longer exists.") } - .onChange(of: selectedSSHConfigHost) { - applySSHConfigEntry(selectedSSHConfigHost) + Button("Switch to Inline Configuration") { + sshProfileId = nil } } - if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { - TextField( - String(localized: "SSH Host"), - text: $sshHost, - prompt: Text("ssh.example.com") - ) - } - TextField( - String(localized: "SSH Port"), - text: $sshPort, - prompt: Text("22") - ) - TextField( - String(localized: "SSH User"), - text: $sshUsername, - prompt: Text("username") - ) + } else { + sshInlineFields } - Section(String(localized: "Authentication")) { - Picker(String(localized: "Method"), selection: $sshAuthMethod) { - ForEach(SSHAuthMethod.allCases) { method in - Text(method.rawValue).tag(method) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var sshProfileSection: some View { + Section(String(localized: "SSH Profile")) { + Picker(String(localized: "Profile"), selection: $sshProfileId) { + Text("Inline Configuration").tag(UUID?.none) + ForEach(sshProfiles) { profile in + Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) + } + } + + HStack(spacing: 12) { + Button("Create New Profile...") { + showingCreateProfile = true + } + + if sshProfileId != nil { + Button("Edit Profile...") { + if let profileId = sshProfileId { + editingProfile = SSHProfileStorage.shared.profile(for: profileId) } } - if sshAuthMethod == .password { - SecureField(String(localized: "Password"), text: $sshPassword) - } else if sshAuthMethod == .sshAgent { - Picker("Agent Socket", selection: $sshAgentSocketOption) { - ForEach(SSHAgentSocketOption.allCases) { option in - Text(option.displayName).tag(option) - } + } + + if sshProfileId == nil && sshEnabled && !sshHost.isEmpty { + Button("Save Current as Profile...") { + showingSaveAsProfile = true + } + } + } + .controlSize(.small) + } + .sheet(isPresented: $showingCreateProfile) { + SSHProfileEditorView(existingProfile: nil, onSave: { _ in + reloadProfiles() + }) + } + .sheet(item: $editingProfile) { profile in + SSHProfileEditorView(existingProfile: profile, onSave: { _ in + reloadProfiles() + }, onDelete: { + reloadProfiles() + }) + } + .sheet(isPresented: $showingSaveAsProfile) { + SSHProfileEditorView( + existingProfile: buildProfileFromInlineConfig(), + initialPassword: sshPassword, + initialKeyPassphrase: keyPassphrase, + initialTOTPSecret: totpSecret, + onSave: { savedProfile in + sshProfileId = savedProfile.id + reloadProfiles() + } + ) + } + } + + private var selectedSSHProfile: SSHProfile? { + guard let id = sshProfileId else { return nil } + return sshProfiles.first { $0.id == id } + } + + private func reloadProfiles() { + sshProfiles = SSHProfileStorage.shared.loadProfiles() + // If the edited/deleted profile no longer exists, clear the selection + if let id = sshProfileId, !sshProfiles.contains(where: { $0.id == id }) { + sshProfileId = nil + } + } + + private func buildProfileFromInlineConfig() -> SSHProfile { + SSHProfile( + name: "", + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshPrivateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + } + + private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { + Section(String(localized: "Profile Settings")) { + LabeledContent(String(localized: "Host"), value: profile.host) + LabeledContent(String(localized: "Port"), value: String(profile.port)) + LabeledContent(String(localized: "Username"), value: profile.username) + LabeledContent(String(localized: "Auth Method"), value: profile.authMethod.rawValue) + if !profile.privateKeyPath.isEmpty { + LabeledContent(String(localized: "Key File"), value: profile.privateKeyPath) + } + if !profile.jumpHosts.isEmpty { + LabeledContent(String(localized: "Jump Hosts"), value: "\(profile.jumpHosts.count)") + } + } + } + + private var sshInlineFields: some View { + Group { + Section(String(localized: "Server")) { + if !sshConfigEntries.isEmpty { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { + Text(String(localized: "Manual")).tag("") + ForEach(sshConfigEntries) { entry in + Text(entry.displayName).tag(entry.host) } - if sshAgentSocketOption == .custom { - TextField( - "Custom Path", - text: $customSSHAgentSocketPath, - prompt: Text("/path/to/agent.sock") - ) + } + .onChange(of: selectedSSHConfigHost) { + applySSHConfigEntry(selectedSSHConfigHost) + } + } + if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { + TextField(String(localized: "SSH Host"), text: $sshHost, prompt: Text("ssh.example.com")) + } + TextField(String(localized: "SSH Port"), text: $sshPort, prompt: Text("22")) + TextField(String(localized: "SSH User"), text: $sshUsername, prompt: Text("username")) + } + + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $sshAuthMethod) { + ForEach(SSHAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if sshAuthMethod == .password { + SecureField(String(localized: "Password"), text: $sshPassword) + } else if sshAuthMethod == .sshAgent { + Picker("Agent Socket", selection: $sshAgentSocketOption) { + ForEach(SSHAgentSocketOption.allCases) { option in + Text(option.displayName).tag(option) } - Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") - .font(.caption) - .foregroundStyle(.secondary) - } else if sshAuthMethod == .keyboardInteractive { - SecureField(String(localized: "Password"), text: $sshPassword) - Text( - String(localized: "Password is sent via keyboard-interactive challenge-response.") - ) + } + if sshAgentSocketOption == .custom { + TextField("Custom Path", text: $customSSHAgentSocketPath, prompt: Text("/path/to/agent.sock")) + } + Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") .font(.caption) .foregroundStyle(.secondary) - } else { - LabeledContent(String(localized: "Key File")) { - HStack { - TextField( - "", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) - Button(String(localized: "Browse")) { browseForPrivateKey() } - .controlSize(.small) - } + } else if sshAuthMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { browseForPrivateKey() } + .controlSize(.small) } - SecureField(String(localized: "Passphrase"), text: $keyPassphrase) } + SecureField(String(localized: "Passphrase"), text: $keyPassphrase) } + } - if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { - Section(String(localized: "Two-Factor Authentication")) { - Picker(String(localized: "TOTP"), selection: $totpMode) { - ForEach(TOTPMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } + if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) } + } - if totpMode == .autoGenerate { - SecureField(String(localized: "TOTP Secret"), text: $totpSecret) - .help(String(localized: "Base32-encoded secret from your authenticator setup")) - - Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { - ForEach(TOTPAlgorithm.allCases) { algo in - Text(algo.rawValue).tag(algo) - } - } - - Picker(String(localized: "Digits"), selection: $totpDigits) { - Text("6").tag(6) - Text("8").tag(8) - } - - Picker(String(localized: "Period"), selection: $totpPeriod) { - Text("30s").tag(30) - Text("60s").tag(60) + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) } - } else if totpMode == .promptAtConnect { - Text( - String( - localized: - "You will be prompted for a verification code each time you connect." - ) - ) + } + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) + } + } else if totpMode == .promptAtConnect { + Text(String(localized: "You will be prompted for a verification code each time you connect.")) .font(.caption) .foregroundStyle(.secondary) - } } } + } - Section { - DisclosureGroup(String(localized: "Jump Hosts")) { - ForEach($jumpHosts) { $jumpHost in - DisclosureGroup { + 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: "Host"), - text: $jumpHost.host, - prompt: Text("bastion.example.com") + String(localized: "Port"), + text: Binding( + get: { String(jumpHost.port) }, + set: { jumpHost.port = Int($0) ?? 22 } + ), + prompt: Text("22") ) - 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) - } + .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) + } + 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) + } + } 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) - } + } + .onMove { indices, destination in + jumpHosts.move(fromOffsets: indices, toOffset: destination) + } - Button { - jumpHosts.append(SSHJumpHost()) - } label: { - Label(String(localized: "Add Jump Host"), systemImage: "plus") - } + 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." - ) + 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) - .scrollContentBackground(.hidden) } // MARK: - SSL/TLS Tab @@ -881,7 +969,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .allSatisfy { !(additionalFieldValues[$0.id] ?? "").isEmpty } basicValid = basicValid && hasRequiredFields && !password.isEmpty } - if sshEnabled { + if sshEnabled && sshProfileId == nil { let sshPortValid = sshPort.isEmpty || (Int(sshPort).map { (1...65_535).contains($0) } ?? false) let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty && sshPortValid let authValid = @@ -907,6 +995,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func loadConnectionData() { + sshProfiles = SSHProfileStorage.shared.loadProfiles() // If editing, load from storage if let id = connectionId, let existing = storage.loadConnections().first(where: { $0.id == id }) @@ -920,7 +1009,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length type = existing.type // Load SSH configuration + sshProfileId = existing.sshProfileId sshEnabled = existing.sshConfig.enabled + sshHost = existing.sshConfig.host sshPort = String(existing.sshConfig.port) sshUsername = existing.sshConfig.username @@ -1036,6 +1127,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, + sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, @@ -1048,17 +1140,25 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if !password.isEmpty { storage.savePassword(password, for: connectionToSave.id) } - if sshEnabled && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) - && !sshPassword.isEmpty - { - storage.saveSSHPassword(sshPassword, for: connectionToSave.id) - } - if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { - storage.saveKeyPassphrase(keyPassphrase, for: connectionToSave.id) - } - if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { - storage.saveTOTPSecret(totpSecret, for: connectionToSave.id) + // Only save SSH secrets per-connection when using inline config (not a profile) + if sshEnabled && sshProfileId == nil { + if (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { + storage.saveSSHPassword(sshPassword, for: connectionToSave.id) + } + if sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { + storage.saveKeyPassphrase(keyPassphrase, for: connectionToSave.id) + } + if totpMode == .autoGenerate && !totpSecret.isEmpty { + storage.saveTOTPSecret(totpSecret, for: connectionToSave.id) + } else { + storage.deleteTOTPSecret(for: connectionToSave.id) + } } else { + // Clean up stale per-connection SSH secrets when using a profile or SSH disabled + storage.deleteSSHPassword(for: connectionToSave.id) + storage.deleteKeyPassphrase(for: connectionToSave.id) storage.deleteTOTPSecret(for: connectionToSave.id) } @@ -1199,6 +1299,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, + sshProfileId: sshProfileId, redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, @@ -1211,22 +1312,25 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if !password.isEmpty { ConnectionStorage.shared.savePassword(password, for: testConn.id) } - if sshEnabled - && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) - && !sshPassword.isEmpty - { - ConnectionStorage.shared.saveSSHPassword(sshPassword, for: testConn.id) - } - if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { - ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: testConn.id) - } - if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { - ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: testConn.id) + // Only write inline SSH secrets when not using a profile + if sshEnabled && sshProfileId == nil { + if (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { + ConnectionStorage.shared.saveSSHPassword(sshPassword, for: testConn.id) + } + if sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { + ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: testConn.id) + } + if totpMode == .autoGenerate && !totpSecret.isEmpty { + ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: testConn.id) + } } + let sshPasswordForTest = sshProfileId == nil ? sshPassword : nil let success = try await DatabaseManager.shared.testConnection( - testConn, sshPassword: sshPassword) - ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) + testConn, sshPassword: sshPasswordForTest) + cleanupTestSecrets(for: testConn.id) await MainActor.run { isTesting = false if success { @@ -1240,7 +1344,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } } catch { - ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) + cleanupTestSecrets(for: testConn.id) await MainActor.run { isTesting = false testSucceeded = false @@ -1271,6 +1375,13 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } + private func cleanupTestSecrets(for testId: UUID) { + ConnectionStorage.shared.deletePassword(for: testId) + ConnectionStorage.shared.deleteSSHPassword(for: testId) + ConnectionStorage.shared.deleteKeyPassphrase(for: testId) + ConnectionStorage.shared.deleteTOTPSecret(for: testId) + } + private func browseForPrivateKey() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift new file mode 100644 index 000000000..80069f2c4 --- /dev/null +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -0,0 +1,449 @@ +// +// SSHProfileEditorView.swift +// TablePro +// + +import SwiftUI + +struct SSHProfileEditorView: View { + @Environment(\.dismiss) private var dismiss + + let existingProfile: SSHProfile? + var initialPassword: String? + var initialKeyPassphrase: String? + var initialTOTPSecret: String? + var onSave: ((SSHProfile) -> Void)? + var onDelete: (() -> Void)? + + // Profile identity + @State private var profileName: String = "" + + // Server + @State private var host: String = "" + @State private var port: String = "22" + @State private var username: String = "" + + // Authentication + @State private var authMethod: SSHAuthMethod = .password + @State private var sshPassword: String = "" + @State private var privateKeyPath: String = "" + @State private var keyPassphrase: String = "" + @State private var agentSocketOption: SSHAgentSocketOption = .systemDefault + @State private var customAgentSocketPath: String = "" + + // TOTP + @State private var totpMode: TOTPMode = .none + @State private var totpSecret: String = "" + @State private var totpAlgorithm: TOTPAlgorithm = .sha1 + @State private var totpDigits: Int = 6 + @State private var totpPeriod: Int = 30 + + // Jump hosts + @State private var jumpHosts: [SSHJumpHost] = [] + + // SSH config auto-fill + @State private var sshConfigEntries: [SSHConfigEntry] = [] + @State private var selectedSSHConfigHost: String = "" + + // Deletion + @State private var showingDeleteConfirmation = false + @State private var connectionsUsingProfile = 0 + + private var isStoredProfile: Bool { + guard let profile = existingProfile else { return false } + return SSHProfileStorage.shared.profile(for: profile.id) != nil + } + + private var isValid: Bool { + let nameValid = !profileName.trimmingCharacters(in: .whitespaces).isEmpty + let hostValid = !host.trimmingCharacters(in: .whitespaces).isEmpty + let portValid = port.isEmpty || (Int(port).map { (1...65_535).contains($0) } ?? false) + let authValid = authMethod == .password || authMethod == .sshAgent + || authMethod == .keyboardInteractive || !privateKeyPath.isEmpty + let jumpValid = jumpHosts.allSatisfy(\.isValid) + return nameValid && hostValid && portValid && authValid && jumpValid + } + + private var resolvedAgentSocketPath: String { + agentSocketOption.resolvedPath(customPath: customAgentSocketPath) + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section(String(localized: "Profile")) { + TextField(String(localized: "Name"), text: $profileName, prompt: Text("My Server")) + } + + serverSection + authenticationSection + + if authMethod == .keyboardInteractive || authMethod == .password { + totpSection + } + + jumpHostsSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + + Divider() + bottomBar + } + .frame(minWidth: 480, minHeight: 500) + .onAppear { + sshConfigEntries = SSHConfigParser.parse() + loadExistingProfile() + } + } + + // MARK: - Server Section + + private var serverSection: some View { + Section(String(localized: "Server")) { + if !sshConfigEntries.isEmpty { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { + Text(String(localized: "Manual")).tag("") + ForEach(sshConfigEntries) { entry in + Text(entry.displayName).tag(entry.host) + } + } + .onChange(of: selectedSSHConfigHost) { + applySSHConfigEntry(selectedSSHConfigHost) + } + } + if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { + TextField(String(localized: "SSH Host"), text: $host, prompt: Text("ssh.example.com")) + } + TextField(String(localized: "SSH Port"), text: $port, prompt: Text("22")) + TextField(String(localized: "SSH User"), text: $username, prompt: Text("username")) + } + } + + // MARK: - Authentication Section + + private var authenticationSection: some View { + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $authMethod) { + ForEach(SSHAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if authMethod == .password { + SecureField(String(localized: "Password"), text: $sshPassword) + } else if authMethod == .sshAgent { + Picker("Agent Socket", selection: $agentSocketOption) { + ForEach(SSHAgentSocketOption.allCases) { option in + Text(option.displayName).tag(option) + } + } + if agentSocketOption == .custom { + TextField("Custom Path", text: $customAgentSocketPath, prompt: Text("/path/to/agent.sock")) + } + Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") + .font(.caption) + .foregroundStyle(.secondary) + } else if authMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { browseForPrivateKey() } + .controlSize(.small) + } + } + SecureField(String(localized: "Passphrase"), text: $keyPassphrase) + } + } + } + + // MARK: - TOTP Section + + private var totpSection: some View { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) + + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) + } + } + + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } + + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) + } + } else if totpMode == .promptAtConnect { + Text(String(localized: "You will be prompted for a verification code each time you connect.")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Jump Hosts Section + + private var jumpHostsSection: some View { + 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) + } + } + } + + // MARK: - Bottom Bar + + private var bottomBar: some View { + HStack { + if isStoredProfile { + Button(role: .destructive) { + connectionsUsingProfile = ConnectionStorage.shared.loadConnections() + .filter { $0.sshProfileId == existingProfile?.id }.count + showingDeleteConfirmation = true + } label: { + Text("Delete Profile") + } + .confirmationDialog( + "Delete SSH Profile?", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { deleteProfile() } + } message: { + if connectionsUsingProfile > 0 { + Text("\(connectionsUsingProfile) connection(s) use this profile. They will fall back to no SSH tunnel.") + } else { + Text("This profile will be permanently deleted.") + } + } + } + + Spacer() + + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + + Button(isStoredProfile ? "Save" : "Create") { saveProfile() } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .padding() + } + + // MARK: - Actions + + private func loadExistingProfile() { + guard let profile = existingProfile else { return } + profileName = profile.name + host = profile.host + port = String(profile.port) + username = profile.username + authMethod = profile.authMethod + privateKeyPath = profile.privateKeyPath + jumpHosts = profile.jumpHosts + totpMode = profile.totpMode + totpAlgorithm = profile.totpAlgorithm + totpDigits = profile.totpDigits + totpPeriod = profile.totpPeriod + + let option = SSHAgentSocketOption(socketPath: profile.agentSocketPath) + agentSocketOption = option + if option == .custom { + customAgentSocketPath = profile.agentSocketPath + } + + // Load secrets from Keychain, falling back to initial values (e.g. from "Save as Profile") + sshPassword = SSHProfileStorage.shared.loadSSHPassword(for: profile.id) ?? initialPassword ?? "" + keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profile.id) ?? initialKeyPassphrase ?? "" + totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: profile.id) ?? initialTOTPSecret ?? "" + } + + private func saveProfile() { + let profileId = existingProfile?.id ?? UUID() + + let profile = SSHProfile( + id: profileId, + name: profileName.trimmingCharacters(in: .whitespaces), + host: host, + port: Int(port) ?? 22, + username: username, + authMethod: authMethod, + privateKeyPath: privateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + + if isStoredProfile { + SSHProfileStorage.shared.updateProfile(profile) + } else { + SSHProfileStorage.shared.addProfile(profile) + } + + // Save secrets to Keychain + if (authMethod == .password || authMethod == .keyboardInteractive) && !sshPassword.isEmpty { + SSHProfileStorage.shared.saveSSHPassword(sshPassword, for: profileId) + } else { + SSHProfileStorage.shared.deleteSSHPassword(for: profileId) + } + + if authMethod == .privateKey && !keyPassphrase.isEmpty { + SSHProfileStorage.shared.saveKeyPassphrase(keyPassphrase, for: profileId) + } else { + SSHProfileStorage.shared.deleteKeyPassphrase(for: profileId) + } + + if totpMode == .autoGenerate && !totpSecret.isEmpty { + SSHProfileStorage.shared.saveTOTPSecret(totpSecret, for: profileId) + } else { + SSHProfileStorage.shared.deleteTOTPSecret(for: profileId) + } + + onSave?(profile) + dismiss() + } + + private func deleteProfile() { + guard let profile = existingProfile else { return } + SSHProfileStorage.shared.deleteProfile(profile) + onDelete?() + dismiss() + } + + // MARK: - SSH Config Helpers + + private func applySSHConfigEntry(_ configHost: String) { + guard let entry = sshConfigEntries.first(where: { $0.host == configHost }) else { return } + + host = entry.hostname ?? entry.host + if let entryPort = entry.port { + port = String(entryPort) + } + if let user = entry.user { + username = user + } + if let agentPath = entry.identityAgent { + let option = SSHAgentSocketOption(socketPath: agentPath) + agentSocketOption = option + if option == .custom { + customAgentSocketPath = agentPath.trimmingCharacters(in: .whitespacesAndNewlines) + } + authMethod = .sshAgent + } else if let keyPath = entry.identityFile { + privateKeyPath = keyPath + authMethod = .privateKey + } + if let proxyJump = entry.proxyJump { + jumpHosts = SSHConfigParser.parseProxyJump(proxyJump) + } + } + + private func browseForPrivateKey() { + 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 { + privateKeyPath = url.path(percentEncoded: false) + } + } + } + + 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) + } + } + } +} diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index 7e0d38f45..008cbfab6 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -131,6 +131,9 @@ struct SyncSettingsView: View { Toggle("Groups & Tags:", isOn: $syncSettings.syncGroupsAndTags) .onChange(of: syncSettings.syncGroupsAndTags) { _, _ in persistSettings() } + Toggle("SSH Profiles:", isOn: $syncSettings.syncSSHProfiles) + .onChange(of: syncSettings.syncSSHProfiles) { _, _ in persistSettings() } + Toggle("Settings:", isOn: $syncSettings.syncSettings) .onChange(of: syncSettings.syncSettings) { _, _ in persistSettings() } diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 84d49e150..08e3f0629 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: Route database connections through an encrypted SSH tunnel to reach SSH tunneling routes your database connection through an encrypted tunnel to reach servers that aren't directly accessible from your Mac. TablePro manages the tunnel lifecycle, including keep-alive and auto-reconnect. + +If you connect to multiple databases through the same SSH server, you can save your SSH configuration as a reusable profile. See [SSH Profiles](/features/ssh-profiles). + + ## How SSH Tunneling Works ```mermaid diff --git a/docs/docs.json b/docs/docs.json index 74a901a2b..77464b8d8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -65,7 +65,8 @@ "features/keyboard-shortcuts", "features/deep-links", "features/safe-mode", - "features/icloud-sync" + "features/icloud-sync", + "features/ssh-profiles" ] }, { @@ -161,7 +162,8 @@ "vi/features/keyboard-shortcuts", "vi/features/deep-links", "vi/features/safe-mode", - "vi/features/icloud-sync" + "vi/features/icloud-sync", + "vi/features/ssh-profiles" ] }, { @@ -262,7 +264,8 @@ "zh/features/keyboard-shortcuts", "zh/features/deep-links", "zh/features/safe-mode", - "zh/features/icloud-sync" + "zh/features/icloud-sync", + "zh/features/ssh-profiles" ] }, { diff --git a/docs/features/ssh-profiles.mdx b/docs/features/ssh-profiles.mdx new file mode 100644 index 000000000..6e2697fc6 --- /dev/null +++ b/docs/features/ssh-profiles.mdx @@ -0,0 +1,97 @@ +--- +title: SSH Profiles +description: Save SSH tunnel configurations as reusable profiles shared across connections +--- + +# SSH Profiles + +When you connect to MySQL, Redis, and PostgreSQL on the same remote server, you'd normally enter the same SSH host, port, and credentials three times. SSH profiles fix that: define the tunnel config once, then pick it from a dropdown on any connection. + +## Creating a Profile + + + + Open a new or existing connection and go to the **SSH Tunnel** tab. + + + Turn on the **Enable SSH Tunnel** toggle. + + + In the **SSH Profile** section, click **Create New Profile...**. A sheet opens with all the SSH fields. + + + Give the profile a name (e.g. "prod-bastion"), fill in the SSH server details, and click **Create**. + + + +### Profile fields + +| Field | Description | +|-------|-------------| +| **Name** | Label for this profile — shows in the dropdown | +| **SSH Host** | Hostname or IP of the SSH server | +| **SSH Port** | Default `22` | +| **Username** | SSH login user | +| **Auth Method** | Password, Private Key, SSH Agent, or Keyboard-Interactive | +| **Jump Hosts** | Optional multi-hop bastion chain | +| **TOTP** | Optional two-factor (auto-generate or prompt) | + +Auth-specific fields (password, key file, passphrase, agent socket) match the [inline SSH tunnel settings](/databases/ssh-tunneling#authentication-methods). + +## Selecting a Profile + +1. Go to a connection's **SSH Tunnel** tab and enable SSH +2. Open the **Profile** picker and select a profile +3. The connection shows a read-only summary of the profile's settings + +The connection stores a reference to the profile — not a copy. Updating the profile later affects every connection that uses it. + +To go back to per-connection SSH settings, switch the picker back to **Inline Configuration**. + +## Editing a Profile + +1. Select the profile in any connection's SSH tab +2. Click **Edit Profile...** +3. Change what you need and click **Save** + +All connections referencing the profile get the updated config on next connect. + +## Deleting a Profile + +1. Open the profile editor via **Edit Profile...** +2. Click **Delete Profile** +3. A confirmation dialog shows how many connections use this profile +4. Confirm deletion + +After deletion, affected connections show a "profile no longer exists" warning in the SSH tab. SSH tunneling is disabled for those connections until you select a different profile or switch to inline configuration. + +## Saving Inline Config as a Profile + +If you already configured SSH inline on a connection and want to reuse it: + +1. Go to the connection's **SSH Tunnel** tab (with inline config active) +2. Click **Save Current as Profile...** +3. Name the profile and click **Create** + +The connection switches to using the new profile. Your SSH password, key passphrase, and TOTP secret carry over. + +## iCloud Sync + +SSH profiles sync across Macs when iCloud Sync is enabled with the **SSH Profiles** toggle on in **Settings > Sync**. + + +SSH passwords and key passphrases stay in the local macOS Keychain by default. Turn on **Password sync** in **Settings > Sync** to sync credentials via iCloud Keychain. + + +See [iCloud Sync](/features/icloud-sync) for setup details. + +## Related + + + + SSH tunnel setup and troubleshooting + + + Sync across Macs + + diff --git a/docs/vi/databases/ssh-tunneling.mdx b/docs/vi/databases/ssh-tunneling.mdx index aab1d587d..52ee5a0ce 100644 --- a/docs/vi/databases/ssh-tunneling.mdx +++ b/docs/vi/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: Định tuyến kết nối database qua tunnel SSH mã hóa để SSH tunneling định tuyến kết nối database qua tunnel mã hóa để truy cập server không truy cập trực tiếp từ Mac. TablePro quản lý vòng đời tunnel, bao gồm keep-alive và tự kết nối lại. + +Nếu bạn kết nối nhiều database qua cùng SSH server, bạn có thể lưu cấu hình SSH thành profile tái sử dụng. Xem [SSH Profiles](/vi/features/ssh-profiles). + + ## Cách SSH Tunneling Hoạt động ```mermaid diff --git a/docs/vi/features/ssh-profiles.mdx b/docs/vi/features/ssh-profiles.mdx new file mode 100644 index 000000000..4296f78eb --- /dev/null +++ b/docs/vi/features/ssh-profiles.mdx @@ -0,0 +1,97 @@ +--- +title: SSH Profiles +description: Lưu cấu hình SSH tunnel thành profile tái sử dụng cho nhiều kết nối +--- + +# SSH Profiles + +Khi bạn kết nối MySQL, Redis và PostgreSQL trên cùng server, bạn phải nhập cùng SSH host, port và credentials ba lần. SSH profiles giải quyết vấn đề này: cấu hình tunnel một lần, rồi chọn từ dropdown trên bất kỳ kết nối nào. + +## Tạo Profile + + + + Mở kết nối mới hoặc có sẵn, chuyển sang tab **SSH Tunnel**. + + + Bật toggle **Enable SSH Tunnel**. + + + Trong phần **SSH Profile**, nhấn **Create New Profile...**. Một sheet mở ra với đầy đủ các trường SSH. + + + Đặt tên profile (ví dụ "prod-bastion"), điền thông tin SSH server, rồi nhấn **Create**. + + + +### Các trường profile + +| Trường | Mô tả | +|-------|-------------| +| **Name** | Tên hiển thị trong dropdown | +| **SSH Host** | Hostname hoặc IP của SSH server | +| **SSH Port** | Mặc định `22` | +| **Username** | Tên đăng nhập SSH | +| **Auth Method** | Password, Private Key, SSH Agent, hoặc Keyboard-Interactive | +| **Jump Hosts** | Chuỗi bastion multi-hop (tùy chọn) | +| **TOTP** | Xác thực hai yếu tố (tùy chọn) | + +Các trường xác thực (password, key file, passphrase, agent socket) giống với [cài đặt SSH tunnel inline](/vi/databases/ssh-tunneling#phương-thức-xác-thực). + +## Chọn Profile + +1. Vào tab **SSH Tunnel** của kết nối và bật SSH +2. Mở picker **Profile** và chọn một profile +3. Kết nối hiển thị tóm tắt chỉ đọc cài đặt của profile + +Kết nối lưu tham chiếu đến profile, không phải bản sao. Cập nhật profile sẽ ảnh hưởng đến mọi kết nối dùng nó. + +Để quay lại cài đặt SSH riêng cho từng kết nối, chuyển picker về **Inline Configuration**. + +## Sửa Profile + +1. Chọn profile trong tab SSH của bất kỳ kết nối nào +2. Nhấn **Edit Profile...** +3. Thay đổi và nhấn **Save** + +Tất cả kết nối tham chiếu profile sẽ nhận cấu hình mới khi kết nối lại. + +## Xóa Profile + +1. Mở trình sửa profile qua **Edit Profile...** +2. Nhấn **Delete Profile** +3. Hộp thoại xác nhận hiển thị số kết nối đang dùng profile +4. Xác nhận xóa + +Sau khi xóa, các kết nối bị ảnh hưởng hiển thị cảnh báo "profile không còn tồn tại" trong tab SSH. SSH tunneling bị vô hiệu hóa cho những kết nối đó cho đến khi bạn chọn profile khác hoặc chuyển sang cấu hình inline. + +## Lưu cấu hình inline thành Profile + +Nếu bạn đã cấu hình SSH inline trên một kết nối và muốn tái sử dụng: + +1. Vào tab **SSH Tunnel** của kết nối (đang dùng cấu hình inline) +2. Nhấn **Save Current as Profile...** +3. Đặt tên profile và nhấn **Create** + +Kết nối chuyển sang dùng profile mới. Mật khẩu SSH, key passphrase và TOTP secret được chuyển theo. + +## Đồng bộ iCloud + +SSH profiles đồng bộ giữa các Mac khi bật iCloud Sync với toggle **SSH Profiles** trong **Settings > Sync**. + + +Mật khẩu SSH và key passphrase mặc định lưu trong macOS Keychain local. Bật **Password sync** trong **Settings > Sync** để đồng bộ qua iCloud Keychain. + + +Xem [Đồng bộ iCloud](/vi/features/icloud-sync) để biết chi tiết. + +## Liên quan + + + + Thiết lập và khắc phục sự cố SSH tunnel + + + Đồng bộ giữa các Mac + + diff --git a/docs/zh/databases/ssh-tunneling.mdx b/docs/zh/databases/ssh-tunneling.mdx index 9d188832f..ce7ea9f1d 100644 --- a/docs/zh/databases/ssh-tunneling.mdx +++ b/docs/zh/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: 通过加密的 SSH 隧道路由数据库连接,访问私有网 SSH tunneling 将你的数据库连接通过加密隧道路由,以访问无法从 Mac 直接连接的服务器。TablePro 管理 tunnel 的生命周期,包括保活和自动重连。 + +如果你通过同一台 SSH 服务器连接多个数据库,可以将 SSH 配置保存为可复用的 profile。参阅 [SSH Profiles](/zh/features/ssh-profiles)。 + + ## SSH Tunneling 的工作原理 ```mermaid diff --git a/docs/zh/features/ssh-profiles.mdx b/docs/zh/features/ssh-profiles.mdx new file mode 100644 index 000000000..7e74a8d38 --- /dev/null +++ b/docs/zh/features/ssh-profiles.mdx @@ -0,0 +1,97 @@ +--- +title: SSH Profiles +description: 将 SSH 隧道配置保存为可复用的 profile,在多个连接间共享 +--- + +# SSH Profiles + +当你需要连接同一台远程服务器上的 MySQL、Redis 和 PostgreSQL 时,通常要输入三次相同的 SSH host、port 和凭据。SSH profiles 解决了这个问题:配置一次隧道,然后在任何连接的下拉菜单中选择即可。 + +## 创建 Profile + + + + 打开新建或已有的连接,切换到 **SSH Tunnel** 标签页。 + + + 开启 **Enable SSH Tunnel** 开关。 + + + 在 **SSH Profile** 区域,点击 **Create New Profile...**。弹出的 sheet 包含所有 SSH 字段。 + + + 为 profile 命名(如 "prod-bastion"),填写 SSH 服务器信息,点击 **Create**。 + + + +### Profile 字段 + +| 字段 | 说明 | +|-------|-------------| +| **Name** | 在下拉菜单中显示的名称 | +| **SSH Host** | SSH 服务器主机名或 IP | +| **SSH Port** | 默认 `22` | +| **Username** | SSH 登录用户名 | +| **Auth Method** | Password、Private Key、SSH Agent 或 Keyboard-Interactive | +| **Jump Hosts** | 可选的多跳 bastion 链 | +| **TOTP** | 可选的双因素认证 | + +认证相关字段(密码、密钥文件、口令、agent socket)与[内联 SSH tunnel 设置](/zh/databases/ssh-tunneling#认证方式)相同。 + +## 选择 Profile + +1. 进入连接的 **SSH Tunnel** 标签页并启用 SSH +2. 打开 **Profile** 选择器,选择一个 profile +3. 连接以只读摘要形式显示 profile 的设置 + +连接存储的是对 profile 的引用,不是副本。之后更新 profile 会影响所有使用它的连接。 + +要恢复为每个连接独立的 SSH 设置,将选择器切换回 **Inline Configuration**。 + +## 编辑 Profile + +1. 在任意连接的 SSH 标签页中选择该 profile +2. 点击 **Edit Profile...** +3. 修改后点击 **Save** + +所有引用该 profile 的连接在下次连接时使用更新后的配置。 + +## 删除 Profile + +1. 通过 **Edit Profile...** 打开 profile 编辑器 +2. 点击 **Delete Profile** +3. 确认对话框显示当前有多少连接正在使用此 profile +4. 确认删除 + +删除后,受影响的连接在 SSH 标签页中显示"profile 不再存在"的警告。这些连接的 SSH 隧道将被禁用,直到你选择其他 profile 或切换到内联配置。 + +## 将内联配置保存为 Profile + +如果你已在某个连接上配置了内联 SSH 设置并想复用: + +1. 进入连接的 **SSH Tunnel** 标签页(当前使用内联配置) +2. 点击 **Save Current as Profile...** +3. 命名 profile 并点击 **Create** + +连接切换为使用新 profile。SSH 密码、密钥口令和 TOTP secret 会一并迁移。 + +## iCloud 同步 + +启用 iCloud Sync 并在 **Settings > Sync** 中开启 **SSH Profiles** 开关后,SSH profiles 会在 Mac 之间同步。 + + +SSH 密码和密钥口令默认存储在本地 macOS Keychain 中。在 **Settings > Sync** 中开启 **Password sync** 可通过 iCloud Keychain 同步凭据。 + + +参阅 [iCloud 同步](/zh/features/icloud-sync) 了解详情。 + +## 相关 + + + + SSH tunnel 设置和故障排除 + + + 跨 Mac 同步 + +