2157 lines
89 KiB
Swift
2157 lines
89 KiB
Swift
import Foundation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
/// The single source of truth for saved hosts, groups, keys, snippets, and port
|
|
/// forwarding rules.
|
|
///
|
|
/// Core host metadata is backed by SwiftData in the app and projected into value
|
|
/// types for the views. Previews can still construct an in-memory store directly.
|
|
@Observable
|
|
public final class HostLibraryModel: RemoteSessionHostStore {
|
|
public private(set) var hosts: [Host] = []
|
|
public private(set) var groups: [HostGroup] = []
|
|
public private(set) var keys: [SSHKeyRef] = []
|
|
public private(set) var identities: [Identity] = []
|
|
public private(set) var snippets: [Snippet] = []
|
|
public private(set) var snippetPackages: [SnippetPackage] = []
|
|
public private(set) var portForwardings: [PortForwardingRule] = []
|
|
public private(set) var sftpTransfers: [SFTPTransfer] = []
|
|
public private(set) var knownHosts: [KnownHostEntry] = []
|
|
public private(set) var workspaceTemplates: [WorkspaceTemplate] = []
|
|
|
|
/// The latest local persistence failure. Secret text is never included.
|
|
public private(set) var storageError: String?
|
|
private let vault: VaultStore
|
|
private let keychain: any SecretStoring
|
|
@ObservationIgnored private var recordedIdentityCredentialAccesses: Set<Identity.ID> = []
|
|
/// Runtime resources live in AppModel, so deletion announces before model
|
|
/// references are removed and the controller can stop listeners cleanly.
|
|
public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
|
|
public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)?
|
|
public var didChangeHostConnection: ((Host.ID) -> Void)?
|
|
public var didPersistCore: (() -> Void)? {
|
|
get { vault.didPersist }
|
|
set { vault.didPersist = newValue }
|
|
}
|
|
public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? {
|
|
get { vault.didRecordChanges }
|
|
set { vault.didRecordChanges = newValue }
|
|
}
|
|
|
|
public func reportStorageError(_ error: any Error) {
|
|
storageError = error.localizedDescription
|
|
}
|
|
|
|
/// Live connection state, keyed by host. Lives here rather than on `Host`
|
|
/// because it is session state, not user data — it must never be persisted
|
|
/// or synced.
|
|
public private(set) var statuses: [Host.ID: ConnectionStatus] = [:]
|
|
private var connectionStatuses: [UUID: (hostID: Host.ID, status: ConnectionStatus)] = [:]
|
|
|
|
/// Whether the Hosts page's Ungrouped section is open.
|
|
///
|
|
/// Lives here rather than as view state because a real group keeps its
|
|
/// `isExpanded` on the model: if this were a `@State` on the page, folding
|
|
/// Ungrouped would spring back open on the next rebuild while folding
|
|
/// Production stayed folded, and there would be no way to explain the
|
|
/// difference to whoever noticed.
|
|
public private(set) var isUngroupedExpanded = true
|
|
public private(set) var isSnippetsUngroupedExpanded = true
|
|
|
|
public init(
|
|
hosts: [Host] = [],
|
|
groups: [HostGroup] = [],
|
|
keys: [SSHKeyRef] = [],
|
|
identities: [Identity] = [],
|
|
snippets: [Snippet] = [],
|
|
snippetPackages: [SnippetPackage] = [],
|
|
portForwardings: [PortForwardingRule] = [],
|
|
sftpTransfers: [SFTPTransfer] = [],
|
|
knownHosts: [KnownHostEntry] = [],
|
|
workspaceTemplates: [WorkspaceTemplate] = [],
|
|
persistence: (any VaultPersisting)? = nil,
|
|
secrets: any SecretStoring = InMemorySecretStore()
|
|
) {
|
|
self.hosts = hosts
|
|
self.groups = groups
|
|
self.keys = normalizedManualOrder(
|
|
keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
|
|
)
|
|
self.identities = normalizedManualOrder(
|
|
identities, sortIndex: \Identity.sortIndex, name: \Identity.name
|
|
)
|
|
self.snippets = normalizedSnippetOrder(snippets)
|
|
self.snippetPackages = snippetPackages
|
|
self.portForwardings = portForwardings
|
|
self.sftpTransfers = sftpTransfers
|
|
self.knownHosts = knownHosts
|
|
self.workspaceTemplates = workspaceTemplates
|
|
self.vault = VaultStore(persistence: persistence)
|
|
self.keychain = secrets
|
|
do {
|
|
let loaded = try vault.load(
|
|
fallback: VaultSnapshot(
|
|
hosts: hosts, groups: groups, identities: identities, keys: keys,
|
|
knownHosts: knownHosts, snippets: snippets,
|
|
snippetPackages: snippetPackages, portForwardings: portForwardings,
|
|
workspaceTemplates: workspaceTemplates
|
|
)
|
|
)
|
|
self.hosts = loaded.hosts
|
|
self.groups = loaded.groups
|
|
self.identities = loaded.identities.sorted {
|
|
manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name)
|
|
}
|
|
self.keys = loaded.keys.sorted {
|
|
manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label)
|
|
}
|
|
self.knownHosts = loaded.knownHosts
|
|
self.snippets = loaded.snippets.sorted { $0.sortIndex < $1.sortIndex }
|
|
self.snippetPackages = loaded.snippetPackages
|
|
self.portForwardings = loaded.portForwardings
|
|
self.workspaceTemplates = loaded.workspaceTemplates
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
// MARK: - Reads
|
|
|
|
public func host(_ id: Host.ID) -> Host? {
|
|
hosts.first { $0.id == id }
|
|
}
|
|
|
|
public func group(_ id: HostGroup.ID) -> HostGroup? {
|
|
groups.first { $0.id == id }
|
|
}
|
|
|
|
public func snippet(_ id: Snippet.ID) -> Snippet? {
|
|
snippets.first { $0.id == id }
|
|
}
|
|
|
|
public func snippetModifiedAt(_ id: Snippet.ID) -> Date? {
|
|
vault.modifiedAt(for: VaultRecordKey(kind: .snippet, id: id))
|
|
}
|
|
|
|
public func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? {
|
|
snippetPackages.first { $0.id == id }
|
|
}
|
|
|
|
public var rootSnippetPackages: [SnippetPackage] {
|
|
snippetPackages.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
public func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] {
|
|
snippetPackages.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
public func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] {
|
|
childSnippetPackages(of: parent).flatMap {
|
|
[$0] + descendantSnippetPackages(of: $0.id)
|
|
}
|
|
}
|
|
|
|
public func snippets(in package: SnippetPackage.ID?) -> [Snippet] {
|
|
snippets.filter { $0.packageID == package }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
public func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? {
|
|
guard let packageID, let package = snippetPackage(packageID) else { return nil }
|
|
var names = [package.name]
|
|
var parentID = package.parentID
|
|
var visited: Set<SnippetPackage.ID> = [package.id]
|
|
while let id = parentID, let parent = snippetPackage(id), visited.insert(id).inserted {
|
|
names.append(parent.name)
|
|
parentID = parent.parentID
|
|
}
|
|
return names.reversed().joined(separator: " / ")
|
|
}
|
|
|
|
public func status(of id: Host.ID) -> ConnectionStatus {
|
|
if statuses[id] == .connected { return .connected }
|
|
if sftpTransfers.contains(where: {
|
|
$0.involves(id) && $0.state == .running
|
|
}) {
|
|
return .connected
|
|
}
|
|
if let status = statuses[id] { return status }
|
|
if sftpTransfers.contains(where: {
|
|
$0.involves(id) && $0.state == .queued
|
|
}) {
|
|
return .connecting
|
|
}
|
|
return .disconnected
|
|
}
|
|
|
|
/// Top-level groups, in sort order.
|
|
public var rootGroups: [HostGroup] {
|
|
groups.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
public func childGroups(of parent: HostGroup.ID) -> [HostGroup] {
|
|
groups.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
/// Every group below `parent`, at any depth, parents before children.
|
|
///
|
|
/// What a delete has to take with it, and — read the other way — the set a
|
|
/// group may *not* be re-filed into, since a group cannot become its own
|
|
/// descendant.
|
|
public func descendantGroups(of parent: HostGroup.ID) -> [HostGroup] {
|
|
childGroups(of: parent).flatMap { [$0] + descendantGroups(of: $0.id) }
|
|
}
|
|
|
|
/// Hosts directly inside a group — `nil` means "not filed in any group".
|
|
public func hosts(in group: HostGroup.ID?) -> [Host] {
|
|
hosts.filter { $0.groupID == group }.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
/// Hosts anywhere in a group's subtree. The number a delete has to warn
|
|
/// about: deleting Production takes Edge with it, so the machines filed in
|
|
/// Edge are just as much at stake as the ones filed in Production.
|
|
public func hosts(inTreeOf group: HostGroup.ID) -> [Host] {
|
|
let tree = Set([group] + descendantGroups(of: group).map(\.id))
|
|
return hosts.filter { host in host.groupID.map(tree.contains) ?? false }
|
|
}
|
|
|
|
public func portForwardings(for host: Host.ID) -> [PortForwardingRule] {
|
|
portForwardings.filter { $0.hostID == host }
|
|
}
|
|
|
|
public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? {
|
|
portForwardings.first { $0.id == id }
|
|
}
|
|
|
|
public var sortedWorkspaceTemplates: [WorkspaceTemplate] {
|
|
workspaceTemplates.sorted {
|
|
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
public func workspaceTemplate(_ id: WorkspaceTemplate.ID) -> WorkspaceTemplate? {
|
|
workspaceTemplates.first { $0.id == id }
|
|
}
|
|
|
|
@discardableResult
|
|
public func saveWorkspaceTemplate(_ template: WorkspaceTemplate) -> Bool {
|
|
var template = template
|
|
template.name = template.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard template.isValid else {
|
|
storageError = String(localized: "The Workspace template is invalid.")
|
|
return false
|
|
}
|
|
guard
|
|
!workspaceTemplates.contains(where: {
|
|
$0.id != template.id
|
|
&& $0.name.compare(template.name, options: [.caseInsensitive, .diacriticInsensitive])
|
|
== .orderedSame
|
|
})
|
|
else {
|
|
storageError = String(localized: "A Workspace template with this name already exists.")
|
|
return false
|
|
}
|
|
|
|
let previous = workspaceTemplates
|
|
if let index = workspaceTemplates.firstIndex(where: { $0.id == template.id }) {
|
|
workspaceTemplates[index] = template
|
|
} else {
|
|
workspaceTemplates.append(template)
|
|
}
|
|
guard persistCore() else {
|
|
workspaceTemplates = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
public func renameWorkspaceTemplate(_ id: WorkspaceTemplate.ID, to name: String) -> Bool {
|
|
guard var template = workspaceTemplate(id) else { return false }
|
|
template.name = name
|
|
return saveWorkspaceTemplate(template)
|
|
}
|
|
|
|
@discardableResult
|
|
public func deleteWorkspaceTemplate(_ id: WorkspaceTemplate.ID) -> Bool {
|
|
let previous = workspaceTemplates
|
|
workspaceTemplates.removeAll { $0.id == id }
|
|
guard workspaceTemplates.count != previous.count else { return false }
|
|
guard persistCore() else {
|
|
workspaceTemplates = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public func sftpTransfers(for host: Host.ID) -> [SFTPTransfer] {
|
|
sftpTransfers.filter { $0.involves(host) }
|
|
}
|
|
|
|
public func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? {
|
|
keys.first { $0.id == id }
|
|
}
|
|
|
|
public func identity(_ id: Identity.ID) -> Identity? {
|
|
identities.first { $0.id == id }
|
|
}
|
|
|
|
/// The login a host connects with, resolved through the store so renaming an
|
|
/// identity renames it everywhere it is shown.
|
|
public func identity(for host: Host) -> Identity? {
|
|
host.identityID.flatMap { identity($0) }
|
|
}
|
|
|
|
/// `user@host:port` — what the UI shows almost everywhere a host is named.
|
|
///
|
|
/// Falls back to the bare address when no identity is set, rather than
|
|
/// inventing a login: a host that does not yet know who it connects as
|
|
/// should look like one.
|
|
public func loginAddress(for host: Host) -> String {
|
|
guard let username = identity(for: host)?.username, !username.isEmpty else {
|
|
return host.displayAddress
|
|
}
|
|
return "\(username)@\(host.displayAddress)"
|
|
}
|
|
|
|
/// Which identities would break if a key were deleted. The Keys list shows
|
|
/// this count, because "delete" needs to say what it takes with it.
|
|
public func identities(using key: SSHKeyRef.ID) -> [Identity] {
|
|
identities.filter { $0.keyID == key }
|
|
}
|
|
|
|
/// The pinned host keys for a saved host, matched the way OpenSSH matches:
|
|
/// hostname plus port. Newest keys are shown first in the host's details.
|
|
public func knownHosts(for host: Host) -> [KnownHostEntry] {
|
|
knownHosts
|
|
.filter { $0.matches(hostname: host.host, port: host.port) }
|
|
.sorted {
|
|
if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt }
|
|
if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint }
|
|
return $0.id.uuidString < $1.id.uuidString
|
|
}
|
|
}
|
|
|
|
/// Resolves persistent trust plus approvals scoped to the current connection
|
|
/// journey. Temporary approvals never enter SwiftData or sync.
|
|
public func expectedHostKeys(
|
|
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) throws -> Set<SSHHostKeyIdentity> {
|
|
var keys = Set(knownHosts(for: host).map(\.identity))
|
|
if let temporary = temporaryHostKeys[host.id] {
|
|
keys.insert(temporary)
|
|
}
|
|
if trustPolicy == .requireKnownHost, keys.isEmpty {
|
|
throw TermindSSHError.untrustedHost
|
|
}
|
|
return keys
|
|
}
|
|
|
|
/// Why a saved host cannot currently receive a key without stopping for
|
|
/// another question. Export is deliberately limited to hosts whose login
|
|
/// and host-key trust have already been established elsewhere in the app.
|
|
public func keyExportUnavailableReason(for host: Host) -> String? {
|
|
guard let identity = identity(for: host) else {
|
|
return String(localized: "No identity")
|
|
}
|
|
guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return String(localized: "No username")
|
|
}
|
|
let hasAvailablePassword = passwordAvailability(for: identity) == .available
|
|
let hasUsableKey =
|
|
identity.keyID.flatMap { key($0) }.map {
|
|
keyAvailability(for: $0) == .available
|
|
} == true
|
|
guard !identity.hasSecret || hasAvailablePassword || hasUsableKey else {
|
|
return String(localized: "No saved credential")
|
|
}
|
|
let knownHosts = knownHosts(for: host)
|
|
guard !knownHosts.isEmpty else {
|
|
return String(localized: "Connect once to trust this host")
|
|
}
|
|
guard !knownHosts.contains(where: \.isMismatched) else {
|
|
return String(localized: "Host key changed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Resolves model references and Keychain secrets before work leaves the
|
|
/// main actor. The resulting value is Sendable and contains no persistence
|
|
/// objects, so several one-shot exports can safely run in parallel.
|
|
public func prepareSSHConnection(
|
|
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
|
|
) throws -> SSHConnectionPreparation {
|
|
guard let route = validatedRoute(for: host) else {
|
|
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
|
}
|
|
return try prepareSSHConnection(for: SSHConnectionRoute(hosts: route), submissions: submissions)
|
|
}
|
|
|
|
public func prepareSSHConnection(
|
|
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
|
|
) throws -> SSHConnectionPreparation {
|
|
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
|
let submissions = route.submissions(merging: submissions)
|
|
return SSHConnectionPreparation(
|
|
nodes: route.hosts.map { node in
|
|
let submission = submissions[node.id]
|
|
let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node)
|
|
let keyID = submission != nil ? submission?.keyID : identity?.keyID
|
|
return SSHConnectionPreparation.Node(
|
|
host: node, identity: identity,
|
|
key: keyID.flatMap { key($0) },
|
|
submission: submission, trustedKeys: Set(knownHosts(for: node).map(\.identity)),
|
|
proxyIdentity: node.proxy?.identityID.flatMap { self.identity($0) }
|
|
)
|
|
},
|
|
secrets: keychain,
|
|
credentialAccessHandler: { [weak self] identityIDs, date in
|
|
Task { @MainActor [weak self] in
|
|
self?.recordIdentityCredentialAccess(identityIDs, at: date)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
public func sshConnectionConfiguration(
|
|
for host: Host,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) throws -> SSHConnectionConfiguration {
|
|
var accessedIdentityIDs: Set<Identity.ID> = []
|
|
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
|
return try sshConnectionConfiguration(
|
|
for: host, submissions: submissions,
|
|
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
private func sshConnectionConfiguration(
|
|
for host: Host,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
|
trustPolicy: SSHHostTrustPolicy,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> SSHConnectionConfiguration {
|
|
let username: String
|
|
let credentials: [SSHCredentials]
|
|
if let submission = submissions[host.id] {
|
|
username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
|
credentials = try sshCredentials(
|
|
for: submission, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
} else {
|
|
guard let identity = identity(for: host) else {
|
|
throw TermindSSHError.missingCredential
|
|
}
|
|
username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
|
let resolution = sshCredentials(
|
|
for: identity, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
guard resolution.requiredPassphraseKeyID == nil else {
|
|
throw TermindSSHError.keyPassphraseRequired
|
|
}
|
|
credentials = resolution.credentials
|
|
}
|
|
let expectsCredential =
|
|
submissions[host.id].map { $0.keyID != nil || $0.password != nil }
|
|
?? (identity(for: host)?.hasSecret == true)
|
|
if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential }
|
|
let expectedHostKeys = try expectedHostKeys(
|
|
for: host, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy
|
|
)
|
|
|
|
return SSHConnectionConfiguration(
|
|
hostID: host.id, host: host.host, port: host.port, username: username,
|
|
credentials: credentials,
|
|
expectedHostKeys: expectedHostKeys,
|
|
proxy: try transportProxy(
|
|
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
|
),
|
|
jumps: try sshJumpConfigurations(
|
|
for: host, submissions: submissions,
|
|
temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
)
|
|
}
|
|
|
|
public func sshJumpConfigurations(
|
|
for host: Host,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) throws -> [SSHRouteHopConfiguration] {
|
|
var accessedIdentityIDs: Set<Identity.ID> = []
|
|
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
|
return try sshJumpConfigurations(
|
|
for: host, submissions: submissions,
|
|
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
private func sshJumpConfigurations(
|
|
for host: Host,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
|
trustPolicy: SSHHostTrustPolicy,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> [SSHRouteHopConfiguration] {
|
|
guard let route = validatedRoute(for: host) else {
|
|
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
|
}
|
|
return try sshJumpConfigurations(
|
|
route: route, submissions: submissions, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
public func sshJumpConfigurations(
|
|
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) throws -> [SSHRouteHopConfiguration] {
|
|
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
|
var accessedIdentityIDs: Set<Identity.ID> = []
|
|
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
|
return try sshJumpConfigurations(
|
|
route: route.hosts, submissions: route.submissions(merging: submissions),
|
|
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
private func sshJumpConfigurations(
|
|
route: [Host], submissions: [Host.ID: SSHAuthenticationSubmission],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> [SSHRouteHopConfiguration] {
|
|
return try route.dropLast().enumerated().map { offset, jump in
|
|
do {
|
|
if let submission = submissions[jump.id] {
|
|
return try sshRouteHopConfiguration(
|
|
for: jump, submission: submission,
|
|
temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
return try sshRouteHopConfiguration(
|
|
for: jump, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy,
|
|
accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
} catch {
|
|
let authentication =
|
|
switch error as? TermindSSHError {
|
|
case .missingCredential, .missingUsername, .keyPassphraseRequired: true
|
|
default: false
|
|
}
|
|
let hostKey =
|
|
switch error as? TermindSSHError {
|
|
case .untrustedHost: true
|
|
default: false
|
|
}
|
|
throw TermindSSHError.routeHost(
|
|
hostID: jump.id, authentication: authentication,
|
|
serverChallenge: false, hostKey: hostKey,
|
|
message: String(localized: "Jump Host \(offset + 1) (\(jump.name)): \(error.localizedDescription)")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sshRouteHopConfiguration(
|
|
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
|
trustPolicy: SSHHostTrustPolicy,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> SSHRouteHopConfiguration {
|
|
guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential }
|
|
let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
|
let expectedHostKeys = try expectedHostKeys(
|
|
for: host, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy
|
|
)
|
|
let resolution = sshCredentials(
|
|
for: identity, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
guard resolution.requiredPassphraseKeyID == nil else {
|
|
throw TermindSSHError.keyPassphraseRequired
|
|
}
|
|
guard !identity.hasSecret || !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential }
|
|
return SSHRouteHopConfiguration(
|
|
id: host.id, name: host.name, host: host.host, port: host.port,
|
|
username: username, credentials: resolution.credentials,
|
|
expectedHostKeys: expectedHostKeys,
|
|
proxy: try transportProxy(
|
|
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
)
|
|
}
|
|
|
|
private func sshRouteHopConfiguration(
|
|
for host: Host, submission: SSHAuthenticationSubmission,
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
|
trustPolicy: SSHHostTrustPolicy,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> SSHRouteHopConfiguration {
|
|
let username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
|
let expectedHostKeys = try expectedHostKeys(
|
|
for: host, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: trustPolicy
|
|
)
|
|
let credentials = try sshCredentials(
|
|
for: submission, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
guard submission.keyID == nil || !credentials.isEmpty else { throw TermindSSHError.missingCredential }
|
|
return SSHRouteHopConfiguration(
|
|
id: host.id, name: host.name, host: host.host, port: host.port,
|
|
username: username, credentials: credentials,
|
|
expectedHostKeys: expectedHostKeys,
|
|
proxy: try transportProxy(
|
|
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
)
|
|
}
|
|
|
|
private func sshCredentials(
|
|
for submission: SSHAuthenticationSubmission,
|
|
accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> [SSHCredentials] {
|
|
var credentials: [SSHCredentials] = []
|
|
if let keyID = submission.keyID, let key = key(keyID) {
|
|
if let stored = try keyCredential(for: key) {
|
|
if let identityID = submission.identityID {
|
|
accessedIdentityIDs.insert(identityID)
|
|
}
|
|
if key.requiresPassphraseEntry,
|
|
submission.passphrase?.isEmpty != false
|
|
{
|
|
throw TermindSSHError.keyPassphraseRequired
|
|
}
|
|
credentials.append(
|
|
stored.sshCredential(
|
|
publicKey: key.publicKey, certificate: key.certificate,
|
|
passphraseOverride: submission.passphrase
|
|
)
|
|
)
|
|
}
|
|
}
|
|
if let password = submission.password { credentials.append(.password(password)) }
|
|
return credentials
|
|
}
|
|
|
|
public func transportProxy(for host: Host) throws -> SSHTransportProxy? {
|
|
var accessedIdentityIDs: Set<Identity.ID> = []
|
|
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
|
return try transportProxy(
|
|
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
private func transportProxy(
|
|
for host: Host, accessedIdentityIDs: inout Set<Identity.ID>
|
|
) throws -> SSHTransportProxy? {
|
|
guard let proxy = host.proxy else { return nil }
|
|
guard proxy.isValid else {
|
|
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
|
|
}
|
|
var password: String?
|
|
if let identityID = proxy.identityID {
|
|
guard let identity = identity(identityID), identity.usesPassword else {
|
|
throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable."))
|
|
}
|
|
password = try passwordCredential(for: identity)
|
|
guard password != nil else {
|
|
throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable."))
|
|
}
|
|
accessedIdentityIDs.insert(identity.id)
|
|
}
|
|
return SSHTransportProxy(
|
|
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
|
|
username: proxy.username, password: password
|
|
)
|
|
}
|
|
|
|
/// Loads Key and Password independently so one damaged or missing Keychain
|
|
/// item does not suppress the other usable credential.
|
|
public func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
|
|
var accessedIdentityIDs: Set<Identity.ID> = []
|
|
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
|
return sshCredentials(
|
|
for: identity, accessedIdentityIDs: &accessedIdentityIDs
|
|
)
|
|
}
|
|
|
|
private func sshCredentials(
|
|
for identity: Identity, accessedIdentityIDs: inout Set<Identity.ID>
|
|
) -> SSHCredentialResolution {
|
|
var credentials: [SSHCredentials] = []
|
|
var failures: [String] = []
|
|
var requiredPassphraseKeyID: SSHKeyRef.ID?
|
|
|
|
if let keyID = identity.keyID {
|
|
if let key = key(keyID) {
|
|
do {
|
|
if let credential = try keyCredential(for: key) {
|
|
accessedIdentityIDs.insert(identity.id)
|
|
if key.requiresPassphraseEntry {
|
|
requiredPassphraseKeyID = key.id
|
|
} else {
|
|
credentials.append(
|
|
credential.sshCredential(
|
|
publicKey: key.publicKey, certificate: key.certificate
|
|
)
|
|
)
|
|
}
|
|
} else {
|
|
failures.append(
|
|
String(localized: "The selected key has no saved private key.")
|
|
)
|
|
}
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
failures.append(error.localizedDescription)
|
|
}
|
|
} else {
|
|
failures.append(
|
|
String(localized: "The selected key is no longer available.")
|
|
)
|
|
}
|
|
}
|
|
|
|
if identity.usesPassword {
|
|
do {
|
|
if let password = try passwordCredential(for: identity) {
|
|
credentials.append(.password(password))
|
|
accessedIdentityIDs.insert(identity.id)
|
|
} else {
|
|
failures.append(
|
|
String(localized: "The saved password is no longer available.")
|
|
)
|
|
}
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
failures.append(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
return SSHCredentialResolution(
|
|
credentials: credentials, failures: failures,
|
|
hasConfiguredCredential: identity.hasSecret,
|
|
requiredPassphraseKeyID: requiredPassphraseKeyID
|
|
)
|
|
}
|
|
|
|
/// Persists connection-specific credential access without turning ordinary
|
|
/// Keychain inspection into Identity usage. A failed save restores the
|
|
/// observable projection while retaining the storage error for presentation.
|
|
public func recordIdentityCredentialAccess(
|
|
_ identityIDs: Set<Identity.ID>, at date: Date = Date()
|
|
) {
|
|
let pending = identityIDs.subtracting(recordedIdentityCredentialAccesses)
|
|
guard !pending.isEmpty else { return }
|
|
let previous = identities
|
|
var updates: [Identity.ID: Date] = [:]
|
|
var found: Set<Identity.ID> = []
|
|
for index in identities.indices where pending.contains(identities[index].id) {
|
|
let id = identities[index].id
|
|
found.insert(id)
|
|
if identities[index].lastUsedAt.map({ $0 >= date }) != true {
|
|
identities[index].lastUsedAt = date
|
|
updates[id] = date
|
|
}
|
|
}
|
|
guard !found.isEmpty else { return }
|
|
do {
|
|
try vault.saveIdentityLastUsedAt(updates)
|
|
recordedIdentityCredentialAccesses.formUnion(found)
|
|
storageError = nil
|
|
} catch {
|
|
identities = previous
|
|
storageError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private static func identityConnectionFieldsChanged(_ old: Identity, _ new: Identity) -> Bool {
|
|
old.username != new.username
|
|
|| old.credentialRef != new.credentialRef
|
|
|| old.passwordStorage != new.passwordStorage
|
|
|| old.keyID != new.keyID
|
|
}
|
|
|
|
private static func keyConnectionFieldsChanged(_ old: SSHKeyRef, _ new: SSHKeyRef) -> Bool {
|
|
old.algorithm != new.algorithm
|
|
|| old.publicKey != new.publicKey
|
|
|| old.certificate != new.certificate
|
|
|| old.fingerprint != new.fingerprint
|
|
|| old.credentialRef != new.credentialRef
|
|
|| old.storage != new.storage
|
|
|| old.hasPassphrase != new.hasPassphrase
|
|
|| old.requiresUserPresence != new.requiresUserPresence
|
|
}
|
|
|
|
/// Every host tag, deduplicated case-insensitively and sorted.
|
|
public var allTags: [String] {
|
|
uniqueSortedTags(hosts.flatMap(\.tags))
|
|
}
|
|
|
|
/// Tags in use on hosts. What the Hosts page offers to filter by: a
|
|
/// snippet-only tag there would be a filter that matches nothing.
|
|
public var hostTags: [String] {
|
|
uniqueSortedTags(hosts.flatMap(\.tags))
|
|
}
|
|
|
|
private func uniqueSortedTags(_ tags: [String]) -> [String] {
|
|
var seen = Set<String>()
|
|
let uniqueTags = tags.filter { seen.insert($0.lowercased()).inserted }
|
|
return uniqueTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
// MARK: - Search
|
|
|
|
/// Also matches the resolved identity, so filtering by `deploy` still finds
|
|
/// every box that logs in as `deploy` now that the name lives one hop away.
|
|
public func searchHosts(_ query: String) -> [Host] {
|
|
hosts
|
|
.filter { host in
|
|
host.matches(query)
|
|
|| loginAddress(for: host).localizedCaseInsensitiveContains(query)
|
|
|| (!query.isEmpty && identity(for: host)?.matches(query) == true)
|
|
}
|
|
.sorted { $0.name < $1.name }
|
|
}
|
|
|
|
public func searchSnippets(_ query: String) -> [Snippet] {
|
|
snippets.filter {
|
|
$0.matches(query)
|
|
|| (!query.isEmpty
|
|
&& snippetPackagePath($0.packageID)?.localizedCaseInsensitiveContains(query) == true)
|
|
}.sorted { $0.sortIndex < $1.sortIndex }
|
|
}
|
|
|
|
public func searchKeys(_ query: String) -> [SSHKeyRef] {
|
|
keys
|
|
.filter { key in
|
|
query.isEmpty
|
|
|| key.label.localizedCaseInsensitiveContains(query)
|
|
|| key.fingerprint.localizedCaseInsensitiveContains(query)
|
|
}
|
|
.sorted { manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) }
|
|
}
|
|
|
|
public func searchIdentities(_ query: String) -> [Identity] {
|
|
identities.filter { $0.matches(query) }.sorted {
|
|
manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name)
|
|
}
|
|
}
|
|
|
|
// MARK: - Writes
|
|
|
|
public func upsert(_ host: Host) { _ = saveHost(host) }
|
|
|
|
@discardableResult
|
|
public func saveHost(_ host: Host) -> Bool {
|
|
var host = host
|
|
host.name = SavedName.resolve(host.name, default: host.defaultName)
|
|
guard validatedRoute(for: host) != nil else {
|
|
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
|
|
return false
|
|
}
|
|
let previous = hosts
|
|
let old = self.host(host.id)
|
|
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
|
|
var updated = host
|
|
updated.detectedDistribution = hosts[index].detectedDistribution
|
|
hosts[index] = updated
|
|
} else {
|
|
var new = host
|
|
new.sortIndex = nextSortIndex(in: host.groupID)
|
|
hosts.append(new)
|
|
}
|
|
guard persistCore() else {
|
|
hosts = previous
|
|
return false
|
|
}
|
|
if let old, !old.hasSameConnectionRoute(as: host) { didChangeHostConnection?(host.id) }
|
|
return true
|
|
}
|
|
|
|
/// Transitional in-memory projection for repository-owned durable deletion.
|
|
/// The repository is the only writer; this keeps legacy views in sync until
|
|
/// they consume `HostLibraryModel` directly.
|
|
public func projectHostDeletion(_ hostID: Host.ID) {
|
|
removeHostProjection(hostID)
|
|
}
|
|
|
|
private func removeHostProjection(_ hostID: Host.ID) {
|
|
hosts.removeAll { $0.id == hostID }
|
|
for index in hosts.indices where hosts[index].jumpHostID == hostID {
|
|
hosts[index].jumpHostID = nil
|
|
}
|
|
for index in snippets.indices {
|
|
snippets[index].targetHostIDs.removeAll { $0 == hostID }
|
|
}
|
|
portForwardings.removeAll { $0.hostID == hostID }
|
|
connectionStatuses = connectionStatuses.filter { $0.value.hostID != hostID }
|
|
statuses[hostID] = nil
|
|
}
|
|
|
|
public func setStatus(_ status: ConnectionStatus, for id: Host.ID) {
|
|
statuses[id] = status
|
|
}
|
|
|
|
/// Records one live connection's state and projects terminals and File
|
|
/// Transfer browsers for the same host into the status dot shown by Hosts
|
|
/// and host pickers. A connected source wins over a connecting or failed
|
|
/// sibling, so closing one pane cannot make an otherwise-online host appear
|
|
/// offline.
|
|
public func setConnectionStatus(
|
|
_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID
|
|
) {
|
|
if status == .disconnected {
|
|
connectionStatuses[connectionID] = nil
|
|
} else {
|
|
connectionStatuses[connectionID] = (hostID, status)
|
|
}
|
|
refreshStatus(for: hostID)
|
|
}
|
|
|
|
/// Persists only a meaningful, validated distribution transition. The
|
|
/// current Host projection is mutated in place so a shell result cannot
|
|
/// overwrite editor changes made while the connection was starting.
|
|
public func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) {
|
|
guard let index = hosts.firstIndex(where: { $0.id == hostID }),
|
|
let systemInfo,
|
|
case .resolved(let next) = systemInfo.distributionDetection
|
|
else { return }
|
|
|
|
guard hosts[index].detectedDistribution != next else { return }
|
|
hosts[index].detectedDistribution = next
|
|
persistCore()
|
|
}
|
|
|
|
private func refreshStatus(for hostID: Host.ID) {
|
|
let active = connectionStatuses.values
|
|
.filter { $0.hostID == hostID }
|
|
.map(\.status)
|
|
if active.contains(.connected) {
|
|
statuses[hostID] = .connected
|
|
} else if active.contains(.reconnecting) {
|
|
statuses[hostID] = .reconnecting
|
|
} else if active.contains(.connecting) {
|
|
statuses[hostID] = .connecting
|
|
} else if let failure = active.first(where: {
|
|
if case .failed = $0 { return true }
|
|
return false
|
|
}) {
|
|
statuses[hostID] = failure
|
|
} else {
|
|
statuses[hostID] = nil
|
|
}
|
|
}
|
|
|
|
/// Drops a pinned host key. The next connection to that host is treated as a
|
|
/// first connection, which is the whole point — it is the escape hatch for a
|
|
/// server that was legitimately rekeyed.
|
|
public func forget(knownHost id: KnownHostEntry.ID) {
|
|
deleteKnownHosts([id])
|
|
}
|
|
|
|
public func forgetKnownHosts(for host: Host) {
|
|
deleteKnownHosts(Set(knownHosts(for: host).map(\.id)))
|
|
}
|
|
|
|
public func trust(_ entry: KnownHostEntry) {
|
|
let endpointMatches: (KnownHostEntry) -> Bool = {
|
|
$0.matches(hostname: entry.hostname, port: entry.port)
|
|
}
|
|
for index in knownHosts.indices where endpointMatches(knownHosts[index]) {
|
|
knownHosts[index].isMismatched = false
|
|
}
|
|
if let index = knownHosts.firstIndex(where: {
|
|
endpointMatches($0) && $0.fingerprint == entry.fingerprint
|
|
}) {
|
|
let retainedID = knownHosts[index].id
|
|
knownHosts[index].algorithm = entry.algorithm
|
|
knownHosts[index].addedAt = entry.addedAt
|
|
knownHosts.removeAll {
|
|
$0.id != retainedID && endpointMatches($0)
|
|
&& $0.fingerprint == entry.fingerprint
|
|
}
|
|
} else {
|
|
knownHosts.append(entry)
|
|
}
|
|
persistCore()
|
|
}
|
|
|
|
public func markKnownHostMismatched(hostname: String, port: Int) {
|
|
var changed = false
|
|
for index in knownHosts.indices
|
|
where knownHosts[index].matches(hostname: hostname, port: port) {
|
|
knownHosts[index].isMismatched = true
|
|
changed = true
|
|
}
|
|
if changed { persistCore() }
|
|
}
|
|
|
|
public func setExpanded(_ expanded: Bool, for groupID: HostGroup.ID) {
|
|
guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return }
|
|
groups[index].isExpanded = expanded
|
|
persistCore()
|
|
}
|
|
|
|
public func setUngroupedExpanded(_ expanded: Bool) {
|
|
isUngroupedExpanded = expanded
|
|
}
|
|
|
|
public func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) {
|
|
guard let index = snippetPackages.firstIndex(where: { $0.id == packageID }) else { return }
|
|
snippetPackages[index].isExpanded = expanded
|
|
persistCore()
|
|
}
|
|
|
|
public func setSnippetsUngroupedExpanded(_ expanded: Bool) {
|
|
isSnippetsUngroupedExpanded = expanded
|
|
}
|
|
|
|
/// Adds or updates an identity. New ones land at the end of the list, which
|
|
/// is sorted by name wherever it is shown, so there is no order to keep.
|
|
@discardableResult
|
|
public func upsert(_ identity: Identity) -> Bool {
|
|
var identity = identity
|
|
identity.name = SavedName.resolve(identity.name, default: identity.defaultName)
|
|
let identitySnapshot = identities
|
|
let hostSnapshot = hosts
|
|
var resetsCredentialAccess = false
|
|
if let index = identities.firstIndex(where: { $0.id == identity.id }) {
|
|
let previous = identities[index]
|
|
var updated = identity
|
|
updated.sortIndex = previous.sortIndex
|
|
if let lastUsedAt = previous.lastUsedAt,
|
|
updated.lastUsedAt.map({ $0 < lastUsedAt }) != false
|
|
{
|
|
updated.lastUsedAt = lastUsedAt
|
|
}
|
|
resetsCredentialAccess = Self.identityConnectionFieldsChanged(previous, updated)
|
|
identities[index] = updated
|
|
} else {
|
|
var new = identity
|
|
new.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1
|
|
identities.append(new)
|
|
}
|
|
if !identity.isProxyEligible {
|
|
for index in hosts.indices where hosts[index].proxy?.identityID == identity.id {
|
|
hosts[index].proxy?.identityID = nil
|
|
hosts[index].proxy?.username = ""
|
|
}
|
|
}
|
|
let saved = persistCore()
|
|
if !saved {
|
|
identities = identitySnapshot
|
|
hosts = hostSnapshot
|
|
}
|
|
if saved, resetsCredentialAccess { recordedIdentityCredentialAccesses.remove(identity.id) }
|
|
return saved
|
|
}
|
|
|
|
/// Updates an Identity and its Keychain password as one operation.
|
|
///
|
|
/// The editor supplies intent rather than an optional string so a blank
|
|
/// field can keep an existing password while an explicit remove action can
|
|
/// delete it. The old reference is resolved here, before the model changes,
|
|
/// which prevents a cleared Identity from making its former Keychain item
|
|
/// unreachable.
|
|
public func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws {
|
|
let previousIdentity = self.identity(identity.id)
|
|
let previousReference = previousIdentity?.credentialRef ?? identity.credentialRef
|
|
let previousStorage = previousIdentity?.passwordStorage ?? identity.passwordStorage
|
|
var updated = identity
|
|
|
|
let oldPassword =
|
|
previousReference.isEmpty
|
|
? nil : try keychain.string(for: previousReference, storage: previousStorage)
|
|
let identitySnapshot = identities
|
|
let hostSnapshot = hosts
|
|
var wroteDestination = false
|
|
do {
|
|
switch passwordUpdate {
|
|
case .keep:
|
|
updated.credentialRef = previousReference
|
|
if previousStorage != updated.passwordStorage, !previousReference.isEmpty {
|
|
guard let oldPassword else {
|
|
throw TermindSSHError.library(
|
|
String(
|
|
localized:
|
|
"The saved password is unavailable on this Mac. Enter it again before changing storage."
|
|
)
|
|
)
|
|
}
|
|
try keychain.setString(
|
|
oldPassword, for: previousReference, storage: updated.passwordStorage
|
|
)
|
|
wroteDestination = true
|
|
}
|
|
case .replace(let password):
|
|
guard !password.isEmpty else {
|
|
throw TermindSSHError.library(
|
|
String(localized: "A replacement password cannot be empty.")
|
|
)
|
|
}
|
|
let reference =
|
|
previousReference.isEmpty
|
|
? "termind.password.\(UUID().uuidString)"
|
|
: previousReference
|
|
try keychain.setString(password, for: reference, storage: updated.passwordStorage)
|
|
wroteDestination = true
|
|
updated.credentialRef = reference
|
|
case .remove:
|
|
updated.credentialRef = ""
|
|
}
|
|
storageError = nil
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
throw error
|
|
}
|
|
|
|
guard upsert(updated) else {
|
|
identities = identitySnapshot
|
|
hosts = hostSnapshot
|
|
do {
|
|
if wroteDestination {
|
|
if updated.passwordStorage == previousStorage, let oldPassword {
|
|
try keychain.setString(
|
|
oldPassword, for: previousReference, storage: previousStorage
|
|
)
|
|
} else if !updated.credentialRef.isEmpty {
|
|
try keychain.removePassword(
|
|
updated.credentialRef, storage: updated.passwordStorage
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
storageError = String(
|
|
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
|
|
)
|
|
}
|
|
throw TermindSSHError.library(
|
|
storageError ?? String(localized: "The identity could not be saved.")
|
|
)
|
|
}
|
|
if case .replace = passwordUpdate { recordedIdentityCredentialAccesses.remove(updated.id) }
|
|
|
|
let removesPrevious =
|
|
!previousReference.isEmpty
|
|
&& (updated.credentialRef.isEmpty || previousStorage != updated.passwordStorage)
|
|
if removesPrevious {
|
|
do {
|
|
try keychain.removePassword(previousReference, storage: previousStorage)
|
|
} catch {
|
|
// The newly committed destination stays authoritative. Retaining
|
|
// the source is safer than rolling a successful save backward.
|
|
storageError = String(
|
|
localized:
|
|
"The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Throwing counterpart used by connection setup, where a Keychain failure
|
|
/// must remain distinguishable from an Identity that has no password.
|
|
public func passwordCredential(for identity: Identity) throws -> String? {
|
|
guard identity.usesPassword else { return nil }
|
|
return try keychain.string(
|
|
for: identity.credentialRef, storage: identity.passwordStorage
|
|
)
|
|
}
|
|
|
|
public func passwordAvailability(for identity: Identity) -> SecretAvailability {
|
|
guard identity.usesPassword else { return .notConfigured }
|
|
do {
|
|
return try passwordCredential(for: identity) == nil ? .missing : .available
|
|
} catch {
|
|
return .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
public func isProxyEligibleOnThisDevice(_ identity: Identity) -> Bool {
|
|
identity.isProxyEligible && passwordAvailability(for: identity) == .available
|
|
}
|
|
|
|
/// Removes an identity, along with the password it was the only way to
|
|
/// reach, and unfiles it from every host that logged in with it — those
|
|
/// hosts keep their address and go back to "no login chosen yet".
|
|
@discardableResult
|
|
public func delete(identityID: Identity.ID) -> Bool {
|
|
guard let identity = identity(identityID) else { return false }
|
|
let password: String?
|
|
do {
|
|
password =
|
|
identity.credentialRef.isEmpty
|
|
? nil
|
|
: try keychain.string(
|
|
for: identity.credentialRef, storage: identity.passwordStorage
|
|
)
|
|
try keychain.removePassword(
|
|
identity.credentialRef, storage: identity.passwordStorage
|
|
)
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
return false
|
|
}
|
|
let identitySnapshot = identities
|
|
let hostSnapshot = hosts
|
|
identities.removeAll { $0.id == identityID }
|
|
for index in hosts.indices where hosts[index].identityID == identityID {
|
|
hosts[index].identityID = nil
|
|
}
|
|
for index in hosts.indices where hosts[index].proxy?.identityID == identityID {
|
|
hosts[index].proxy?.identityID = nil
|
|
hosts[index].proxy?.username = ""
|
|
}
|
|
guard persistCore() else {
|
|
identities = identitySnapshot
|
|
hosts = hostSnapshot
|
|
if let password {
|
|
do {
|
|
try keychain.setString(
|
|
password, for: identity.credentialRef,
|
|
storage: identity.passwordStorage
|
|
)
|
|
} catch {
|
|
storageError = String(
|
|
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
recordedIdentityCredentialAccesses.remove(identityID)
|
|
return true
|
|
}
|
|
|
|
public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws {
|
|
var key = key
|
|
key.label = SavedName.resolve(key.label, default: key.defaultName)
|
|
let previousKey = self.key(key.id)
|
|
let resetsCredentialAccess =
|
|
credential != nil || previousKey.map { Self.keyConnectionFieldsChanged($0, key) } == true
|
|
if key.storage == .secureEnclave, !key.certificate.isEmpty {
|
|
throw TermindSSHError.library(
|
|
String(localized: "Secure Enclave keys cannot store an SSH certificate.")
|
|
)
|
|
}
|
|
if !key.certificate.isEmpty {
|
|
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey)
|
|
}
|
|
let keySnapshot = keys
|
|
let changesLocation =
|
|
previousKey.map {
|
|
$0.storage != key.storage || $0.credentialRef != key.credentialRef
|
|
} ?? false
|
|
if changesLocation,
|
|
previousKey?.storage == .secureEnclave || key.storage == .secureEnclave
|
|
{
|
|
throw TermindSSHError.library(
|
|
String(localized: "Secure Enclave keys cannot be moved to another storage location.")
|
|
)
|
|
}
|
|
let previousCredential =
|
|
try (credential != nil || changesLocation)
|
|
? previousKey.flatMap { try keyCredential(for: $0) }
|
|
: nil
|
|
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
|
|
if let destinationCredential {
|
|
try keychain.setKeyCredential(
|
|
destinationCredential, for: key.credentialRef, storage: key.storage
|
|
)
|
|
}
|
|
if let index = keys.firstIndex(where: { $0.id == key.id }) {
|
|
var updated = key
|
|
updated.sortIndex = keys[index].sortIndex
|
|
keys[index] = updated
|
|
} else {
|
|
var new = key
|
|
new.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
|
|
keys.append(new)
|
|
}
|
|
guard persistCore() else {
|
|
keys = keySnapshot
|
|
do {
|
|
if changesLocation, destinationCredential != nil {
|
|
try keychain.removeKeyCredential(
|
|
key.credentialRef, storage: key.storage
|
|
)
|
|
} else if destinationCredential != nil, let previousKey, let previousCredential {
|
|
try keychain.setKeyCredential(
|
|
previousCredential, for: previousKey.credentialRef,
|
|
storage: previousKey.storage
|
|
)
|
|
} else if destinationCredential != nil {
|
|
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
|
|
}
|
|
} catch {
|
|
storageError = String(
|
|
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
|
|
)
|
|
}
|
|
throw TermindSSHError.library(
|
|
storageError ?? String(localized: "The key could not be saved.")
|
|
)
|
|
}
|
|
if resetsCredentialAccess {
|
|
recordedIdentityCredentialAccesses.subtract(identities(using: key.id).map(\.id))
|
|
}
|
|
|
|
if changesLocation, let previousKey {
|
|
do {
|
|
try keychain.removeKeyCredential(
|
|
previousKey.credentialRef, storage: previousKey.storage
|
|
)
|
|
} catch {
|
|
storageError = String(
|
|
localized:
|
|
"The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reorders Keys without allowing them to leave their fixed Keychain
|
|
/// section. The supplied order is the order of a multi-card drag.
|
|
public func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) {
|
|
guard
|
|
reorderManually(
|
|
keyIDs, before: next, items: &keys, sortIndex: \SSHKeyRef.sortIndex
|
|
)
|
|
else { return }
|
|
persistCore()
|
|
}
|
|
|
|
/// Reorders Identities independently from Keys. A mixed Keychain selection
|
|
/// is split by the page before it reaches this fixed-section operation.
|
|
public func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) {
|
|
guard
|
|
reorderManually(
|
|
identityIDs, before: next, items: &identities,
|
|
sortIndex: \Identity.sortIndex
|
|
)
|
|
else { return }
|
|
persistCore()
|
|
}
|
|
|
|
public func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? {
|
|
try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
|
|
}
|
|
|
|
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
|
|
do {
|
|
return try keyCredential(for: key) == nil ? .missing : .available
|
|
} catch {
|
|
return .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
public func delete(keyID: SSHKeyRef.ID) throws {
|
|
guard let key = key(keyID) else { return }
|
|
let affectedIdentityIDs = Set(identities(using: keyID).map(\.id))
|
|
let credential = try keyCredential(for: key)
|
|
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
|
|
let keySnapshot = keys
|
|
let identitySnapshot = identities
|
|
keys.removeAll { $0.id == keyID }
|
|
for index in identities.indices where identities[index].keyID == keyID {
|
|
identities[index].keyID = nil
|
|
}
|
|
guard persistCore() else {
|
|
keys = keySnapshot
|
|
identities = identitySnapshot
|
|
if let credential {
|
|
try keychain.setKeyCredential(
|
|
credential, for: key.credentialRef, storage: key.storage
|
|
)
|
|
}
|
|
throw TermindSSHError.library(
|
|
storageError ?? String(localized: "The key could not be deleted.")
|
|
)
|
|
}
|
|
recordedIdentityCredentialAccesses.subtract(affectedIdentityIDs)
|
|
}
|
|
|
|
/// Adds or updates a group. A new group is placed after its last sibling
|
|
/// rather than at the end of the flat array — `sortIndex` is per-parent, so
|
|
/// a child of Production has to be numbered against Production's children.
|
|
public func upsert(_ group: HostGroup) {
|
|
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
|
groups[index] = group
|
|
} else {
|
|
var new = group
|
|
new.sortIndex = nextSortIndex(under: group.parentID)
|
|
groups.append(new)
|
|
}
|
|
persistCore()
|
|
}
|
|
|
|
/// Deletes a group and every group beneath it.
|
|
///
|
|
/// Removes a group subtree after callers have durably requested deletion
|
|
/// for any hosts they do not intend to keep.
|
|
public func deleteGroupProjection(_ groupID: HostGroup.ID) {
|
|
let doomed = Set([groupID] + descendantGroups(of: groupID).map(\.id))
|
|
for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false {
|
|
hosts[index].groupID = nil
|
|
}
|
|
groups.removeAll { doomed.contains($0.id) }
|
|
persistCore()
|
|
}
|
|
|
|
public func upsert(_ snippet: Snippet) {
|
|
if let index = snippets.firstIndex(where: { $0.id == snippet.id }) {
|
|
var updated = snippet
|
|
if snippets[index].packageID == updated.packageID {
|
|
updated.sortIndex = snippets[index].sortIndex
|
|
} else {
|
|
updated.sortIndex = nextSnippetSortIndex(in: updated.packageID)
|
|
}
|
|
snippets[index] = updated
|
|
} else {
|
|
var new = snippet
|
|
new.sortIndex = nextSnippetSortIndex(in: snippet.packageID)
|
|
snippets.append(new)
|
|
}
|
|
persistCore()
|
|
}
|
|
|
|
public func delete(snippetIDs: [Snippet.ID]) {
|
|
let doomed = Set(snippetIDs)
|
|
snippets.removeAll { doomed.contains($0.id) }
|
|
persistCore()
|
|
}
|
|
|
|
public func upsert(_ package: SnippetPackage) {
|
|
if let index = snippetPackages.firstIndex(where: { $0.id == package.id }) {
|
|
let forbidden = Set(
|
|
[package.id] + descendantSnippetPackages(of: package.id).map(\.id)
|
|
)
|
|
guard package.parentID.map(forbidden.contains) != true else { return }
|
|
var updated = package
|
|
if snippetPackages[index].parentID != updated.parentID {
|
|
updated.sortIndex = nextSnippetPackageSortIndex(under: updated.parentID)
|
|
}
|
|
snippetPackages[index] = updated
|
|
} else {
|
|
guard package.parentID != package.id else { return }
|
|
var new = package
|
|
new.sortIndex = nextSnippetPackageSortIndex(under: package.parentID)
|
|
snippetPackages.append(new)
|
|
}
|
|
expandSnippetPackageAncestors(package.id)
|
|
persistCore()
|
|
}
|
|
|
|
public func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) {
|
|
let doomed = Set(
|
|
[snippetPackageID] + descendantSnippetPackages(of: snippetPackageID).map(\.id)
|
|
)
|
|
if keepSnippets {
|
|
for index in snippets.indices where snippets[index].packageID.map(doomed.contains) == true {
|
|
snippets[index].packageID = nil
|
|
snippets[index].sortIndex = nextSnippetSortIndex(in: nil)
|
|
}
|
|
} else {
|
|
snippets.removeAll { $0.packageID.map(doomed.contains) == true }
|
|
}
|
|
snippetPackages.removeAll { doomed.contains($0.id) }
|
|
persistCore()
|
|
}
|
|
|
|
public func move(
|
|
snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?,
|
|
before next: Snippet.ID? = nil
|
|
) {
|
|
if let packageID, snippetPackage(packageID) == nil { return }
|
|
guard
|
|
let placement = manualOrderPlacement(
|
|
snippetIDs, before: next,
|
|
destinationItems: snippets(in: packageID),
|
|
validIDs: Set(snippets.map(\.id)), sortIndex: \Snippet.sortIndex
|
|
)
|
|
else { return }
|
|
if placement.moving.allSatisfy({ snippet($0)?.packageID == packageID })
|
|
&& snippets(in: packageID).map(\.id) == placement.resulting
|
|
{
|
|
return
|
|
}
|
|
|
|
let moving = Set(placement.moving)
|
|
for index in snippets.indices {
|
|
if moving.contains(snippets[index].id) { snippets[index].packageID = packageID }
|
|
if let position = placement.positions[snippets[index].id] {
|
|
snippets[index].sortIndex = position
|
|
}
|
|
}
|
|
expandSnippetPackageAncestors(packageID)
|
|
persistCore()
|
|
}
|
|
|
|
@discardableResult
|
|
public func upsert(_ forwarding: PortForwardingRule) -> Bool {
|
|
guard var normalized = PortForwardingEditorPolicy.normalized(forwarding), let host = host(forwarding.hostID)
|
|
else {
|
|
storageError = String(localized: "The forwarding rule is incomplete.")
|
|
return false
|
|
}
|
|
normalized.label = SavedName.resolve(
|
|
normalized.label,
|
|
default: normalized.defaultName(hostName: SavedName.resolve(host.name, default: host.defaultName)))
|
|
let previous = portForwardings
|
|
if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) {
|
|
if portForwardings[index] == normalized { return true }
|
|
willDeletePortForwardings?([normalized.id])
|
|
portForwardings[index] = normalized
|
|
} else {
|
|
portForwardings.append(normalized)
|
|
}
|
|
guard persistCore() else {
|
|
portForwardings = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
public func deletePortForwardings(_ ids: Set<PortForwardingRule.ID>) -> Bool {
|
|
let affected = portForwardings.filter { ids.contains($0.id) }.map(\.id)
|
|
guard !affected.isEmpty else { return true }
|
|
let previous = portForwardings
|
|
willDeletePortForwardings?(affected)
|
|
portForwardings.removeAll { ids.contains($0.id) }
|
|
guard persistCore() else {
|
|
portForwardings = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Root jump first and destination last. A nil result means the proposed
|
|
/// host closes a cycle, references a missing jump, or exceeds eight hosts.
|
|
public func validatedRoute(for proposed: Host) -> [Host]? {
|
|
var route: [Host] = []
|
|
var cursor: Host? = proposed
|
|
var visited: Set<Host.ID> = []
|
|
while let current = cursor {
|
|
guard visited.insert(current.id).inserted, route.count < 8 else { return nil }
|
|
route.append(current)
|
|
guard let jumpID = current.jumpHostID else { break }
|
|
if jumpID == proposed.id { return nil }
|
|
cursor = jumpID == proposed.id ? proposed : host(jumpID)
|
|
if cursor == nil { return nil }
|
|
}
|
|
return route.reversed()
|
|
}
|
|
|
|
public func route(for host: Host) -> [Host] {
|
|
validatedRoute(for: host) ?? [host]
|
|
}
|
|
|
|
public func routeSummary(for host: Host) -> String {
|
|
route(for: host).map { node in
|
|
if let proxy = node.proxy {
|
|
return "\(proxy.kind.displayName) \(proxy.hostname):\(proxy.port) → \(node.displayAddress)"
|
|
}
|
|
return node.displayAddress
|
|
}.joined(separator: " → ")
|
|
}
|
|
|
|
/// OpenSSH equivalent when the route needs no in-process Proxy. Passwords,
|
|
/// private keys, and proxy credentials never enter the clipboard.
|
|
public func sshCommand(for host: Host) -> String? {
|
|
sshCommand(for: host, additionalArguments: [])
|
|
}
|
|
|
|
public func sshCommand(for rule: PortForwardingRule, host proposedHost: Host? = nil) -> String? {
|
|
guard let destination = proposedHost ?? host(rule.hostID), destination.id == rule.hostID
|
|
else { return nil }
|
|
var arguments = ["-N"]
|
|
switch rule.kind {
|
|
case .local:
|
|
guard let host = rule.targetHost, let port = rule.targetPort else { return nil }
|
|
arguments += ["-L", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"]
|
|
case .remote:
|
|
guard let host = rule.targetHost, let port = rule.targetPort else { return nil }
|
|
arguments += ["-R", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"]
|
|
case .dynamic:
|
|
arguments += ["-D", rule.bindAddress]
|
|
}
|
|
return sshCommand(for: destination, additionalArguments: arguments)
|
|
}
|
|
|
|
private func sshCommand(for destination: Host, additionalArguments: [String]) -> String? {
|
|
guard let route = validatedRoute(for: destination),
|
|
route.allSatisfy({ $0.proxy == nil })
|
|
else { return nil }
|
|
|
|
let jumps = route.dropLast().map { node in
|
|
Self.openSSHAddress(
|
|
host: node.host, port: node.port,
|
|
username: identity(for: node)?.username, bracketsIPv6: true
|
|
)
|
|
}
|
|
var arguments = additionalArguments
|
|
if !jumps.isEmpty { arguments += ["-J", jumps.joined(separator: ",")] }
|
|
if destination.port != 22 { arguments += ["-p", String(destination.port)] }
|
|
arguments.append(
|
|
Self.openSSHAddress(
|
|
host: destination.host, port: 22,
|
|
username: identity(for: destination)?.username, bracketsIPv6: false
|
|
)
|
|
)
|
|
let suffix = arguments.map(Self.shellQuoted).joined(separator: " ")
|
|
return suffix.isEmpty ? "ssh" : "ssh \(suffix)"
|
|
}
|
|
|
|
private static func openSSHAddress(
|
|
host: String, port: Int, username: String?, bracketsIPv6: Bool
|
|
) -> String {
|
|
let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let resolvedHost = bracketsIPv6 && host.contains(":") ? "[\(host)]" : host
|
|
let address = port == 22 ? resolvedHost : "\(resolvedHost):\(port)"
|
|
guard let trimmedUsername, !trimmedUsername.isEmpty else { return address }
|
|
return "\(trimmedUsername)@\(address)"
|
|
}
|
|
|
|
/// POSIX-shell quoting for a single argument. Common SSH punctuation stays
|
|
/// readable; everything else uses single quotes without losing apostrophes.
|
|
private static func shellQuoted(_ value: String) -> String {
|
|
let safe = CharacterSet(
|
|
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-")
|
|
if !value.isEmpty, value.unicodeScalars.allSatisfy(safe.contains) { return value }
|
|
return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
|
|
}
|
|
|
|
private static func commandAddress(_ host: String, port: Int) -> String {
|
|
"\(host.contains(":") ? "[\(host)]" : host):\(port)"
|
|
}
|
|
|
|
public func setSFTPTransferState(_ state: SFTPTransfer.State, for id: SFTPTransfer.ID) {
|
|
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
|
|
sftpTransfers[index].state = state
|
|
}
|
|
|
|
public func enqueueSFTPTransfer(_ sftpTransfer: SFTPTransfer) {
|
|
sftpTransfers.append(sftpTransfer)
|
|
}
|
|
|
|
public func sftpTransfer(_ id: SFTPTransfer.ID) -> SFTPTransfer? {
|
|
sftpTransfers.first { $0.id == id }
|
|
}
|
|
|
|
public func updateSFTPTransfer(
|
|
_ id: SFTPTransfer.ID, completedBytes: Int64, totalBytes: Int64,
|
|
bytesPerSecond: Int64
|
|
) {
|
|
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
|
|
sftpTransfers[index].transferredBytes = completedBytes
|
|
sftpTransfers[index].byteCount = totalBytes
|
|
sftpTransfers[index].bytesPerSecond = bytesPerSecond
|
|
}
|
|
|
|
public func finishSFTPTransfer(_ id: SFTPTransfer.ID, state: SFTPTransfer.State) {
|
|
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
|
|
sftpTransfers[index].state = state
|
|
sftpTransfers[index].bytesPerSecond = 0
|
|
sftpTransfers[index].completedAt = Date()
|
|
if state == .completed {
|
|
sftpTransfers[index].transferredBytes = sftpTransfers[index].byteCount
|
|
}
|
|
}
|
|
|
|
/// Clears terminal transfer records while leaving queued and running work
|
|
/// untouched. The transfer center is a current-session history, not a log.
|
|
public func clearFinishedSFTPTransfers() {
|
|
sftpTransfers.removeAll {
|
|
switch $0.state {
|
|
case .completed, .cancelled, .failed: true
|
|
case .queued, .running, .paused: false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Files one or more hosts into a group, preserving the order supplied.
|
|
///
|
|
/// `next` is the host that should follow the moved batch; `nil` appends it.
|
|
/// Updating every host before persisting makes a multi-selection one logical
|
|
/// drag instead of a series of partially saved moves.
|
|
public func move(hostIDs: [Host.ID], into group: HostGroup.ID?, before next: Host.ID? = nil) {
|
|
if let group, self.group(group) == nil { return }
|
|
guard
|
|
let placement = manualOrderPlacement(
|
|
hostIDs, before: next,
|
|
destinationItems: hosts(in: group),
|
|
validIDs: Set(hosts.map(\.id)),
|
|
sortIndex: \Host.sortIndex
|
|
)
|
|
else { return }
|
|
if placement.moving.allSatisfy({ host($0)?.groupID == group })
|
|
&& hosts(in: group).map(\.id) == placement.resulting
|
|
{
|
|
return
|
|
}
|
|
|
|
let movingSet = Set(placement.moving)
|
|
for index in hosts.indices {
|
|
if movingSet.contains(hosts[index].id) { hosts[index].groupID = group }
|
|
if let value = placement.positions[hosts[index].id] {
|
|
hosts[index].sortIndex = value
|
|
}
|
|
}
|
|
persistCore()
|
|
}
|
|
|
|
@discardableResult
|
|
public func saveKnownHost(_ entry: KnownHostEntry) -> Bool {
|
|
var entry = entry
|
|
entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard entry.isValid,
|
|
!knownHosts.contains(where: {
|
|
$0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port
|
|
&& $0.identity == entry.identity
|
|
})
|
|
else {
|
|
storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.")
|
|
return false
|
|
}
|
|
let previous = knownHosts
|
|
if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) {
|
|
// Editing the address or key explicitly creates a new trust decision.
|
|
if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname
|
|
|| knownHosts[index].port != entry.port
|
|
{
|
|
entry.addedAt = Date()
|
|
entry.isMismatched = false
|
|
}
|
|
knownHosts[index] = entry
|
|
} else {
|
|
entry.addedAt = Date()
|
|
knownHosts.append(entry)
|
|
}
|
|
guard persistCore() else {
|
|
knownHosts = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
public func deleteKnownHosts(_ ids: Set<UUID>) -> Bool {
|
|
let previous = knownHosts
|
|
knownHosts.removeAll { ids.contains($0.id) }
|
|
guard persistCore() else {
|
|
knownHosts = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Saves a complete temporary route in one Vault transaction. Existing Hosts
|
|
/// and Identities are reused or copied, never edited by a one-off command.
|
|
public func saveConnectionRoute(
|
|
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission? = nil,
|
|
authenticationHostID: Host.ID? = nil, suppliedPassword: String? = nil
|
|
) throws -> SSHConnectionRoute {
|
|
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
|
var batch = SSHImportBatch()
|
|
var saved: [Host] = []
|
|
var usernames: [Host.ID: String] = [:]
|
|
var passwords: [(Identity, String)] = []
|
|
var usedNames = Set(hosts.map(\.name))
|
|
for var node in route.hosts {
|
|
let originalID = node.id
|
|
let source = route.sourceHostIDs[originalID].flatMap(host) ?? host(originalID)
|
|
let username = route.usernames[originalID] ?? identity(for: node)?.username
|
|
if node.id == authenticationHostID, let authentication {
|
|
if let selected = authentication.identityID.flatMap(identity), suppliedPassword == nil {
|
|
node.identityID = selected.id
|
|
} else {
|
|
let login = Identity(
|
|
name: authentication.username, username: authentication.username,
|
|
credentialRef: suppliedPassword == nil ? "" : "termind.password.\(UUID().uuidString)",
|
|
keyID: authentication.keyID
|
|
)
|
|
batch.identities.append(login)
|
|
if let suppliedPassword { passwords.append((login, suppliedPassword)) }
|
|
node.identityID = login.id
|
|
}
|
|
} else if node.identityID == nil, let username, !username.isEmpty {
|
|
let matches = (identities + batch.identities).filter { $0.username == username }
|
|
if matches.count == 1 {
|
|
node.identityID = matches[0].id
|
|
} else {
|
|
let login = Identity(name: username, username: username)
|
|
batch.identities.append(login)
|
|
node.identityID = login.id
|
|
}
|
|
}
|
|
node.jumpHostID = saved.last?.id
|
|
if let existing = host(originalID), existing != node { node.id = UUID() }
|
|
if host(node.id) == nil {
|
|
let base = source.map { String(localized: "\($0.name) copy") } ?? node.name
|
|
var name = base
|
|
var suffix = 2
|
|
while usedNames.contains(name) {
|
|
name = "\(base) (\(suffix))"
|
|
suffix += 1
|
|
}
|
|
node.name = name
|
|
usedNames.insert(name)
|
|
batch.hosts.append(node)
|
|
}
|
|
usernames[node.id] = originalID == authenticationHostID ? authentication?.username : username
|
|
saved.append(node)
|
|
}
|
|
var written: [Identity] = []
|
|
do {
|
|
for (identity, password) in passwords {
|
|
try keychain.setString(password, for: identity.credentialRef, storage: identity.passwordStorage)
|
|
written.append(identity)
|
|
}
|
|
if !batch.hosts.isEmpty || !batch.identities.isEmpty { try commitSSHImport(batch) }
|
|
} catch {
|
|
let saveError = error
|
|
for identity in written {
|
|
do {
|
|
try keychain.removePassword(identity.credentialRef, storage: identity.passwordStorage)
|
|
} catch {
|
|
throw TermindSSHError.library(
|
|
String(
|
|
localized:
|
|
"The database save and Keychain rollback both failed: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
}
|
|
throw saveError
|
|
}
|
|
return SSHConnectionRoute(
|
|
hosts: saved.map { host($0.id) ?? $0 }, usernames: usernames,
|
|
sourceHostIDs: Dictionary(uniqueKeysWithValues: saved.map { ($0.id, $0.id) })
|
|
)
|
|
}
|
|
|
|
/// New records only. Existing credentials and host configurations are never overwritten.
|
|
public func commitSSHImport(_ batch: SSHImportBatch) throws {
|
|
let oldHosts = hosts
|
|
let oldIdentities = identities
|
|
let oldKeys = keys
|
|
let oldKnownHosts = knownHosts
|
|
guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)),
|
|
Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)),
|
|
Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)),
|
|
batch.knownHosts.allSatisfy(\.isValid)
|
|
else { throw CocoaError(.coderInvalidValue) }
|
|
var written: [SSHKeyRef] = []
|
|
do {
|
|
for key in batch.keys {
|
|
guard let secret = batch.credentials[key.id] else {
|
|
throw CocoaError(.coderValueNotFound)
|
|
}
|
|
try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage)
|
|
written.append(key)
|
|
}
|
|
for var key in batch.keys {
|
|
key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
|
|
keys.append(key)
|
|
}
|
|
for var identity in batch.identities {
|
|
identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1
|
|
identities.append(identity)
|
|
}
|
|
for var host in batch.hosts {
|
|
host.sortIndex = nextSortIndex(in: nil)
|
|
hosts.append(host)
|
|
}
|
|
guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else {
|
|
throw CocoaError(.coderInvalidValue)
|
|
}
|
|
knownHosts.append(contentsOf: batch.knownHosts)
|
|
guard persistCore() else {
|
|
throw TermindSSHError.library(storageError ?? String(localized: "Import failed."))
|
|
}
|
|
} catch {
|
|
hosts = oldHosts
|
|
identities = oldIdentities
|
|
keys = oldKeys
|
|
knownHosts = oldKnownHosts
|
|
var cleanupFailed = false
|
|
for key in written {
|
|
do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch {
|
|
cleanupFailed = true
|
|
}
|
|
}
|
|
if cleanupFailed {
|
|
storageError = String(
|
|
localized: "Import failed. Some newly stored keys could not be removed from Keychain.")
|
|
throw TermindSSHError.library(storageError!)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func nextSortIndex(in group: HostGroup.ID?) -> Double {
|
|
(hosts(in: group).last?.sortIndex ?? 0) + 1
|
|
}
|
|
|
|
/// The same, for a group among its siblings. Separate from the hosts version
|
|
/// because the two number different collections: a new child of Production is
|
|
/// placed after Production's last *group*, and the hosts filed directly in
|
|
/// Production have their own independent run of indices.
|
|
private func nextSortIndex(under parent: HostGroup.ID?) -> Double {
|
|
let siblings = parent.map { childGroups(of: $0) } ?? rootGroups
|
|
return (siblings.last?.sortIndex ?? 0) + 1
|
|
}
|
|
|
|
private func nextSnippetSortIndex(in package: SnippetPackage.ID?) -> Double {
|
|
(snippets(in: package).last?.sortIndex ?? -1) + 1
|
|
}
|
|
|
|
private func nextSnippetPackageSortIndex(under parent: SnippetPackage.ID?) -> Double {
|
|
let siblings = parent.map { childSnippetPackages(of: $0) } ?? rootSnippetPackages
|
|
return (siblings.last?.sortIndex ?? -1) + 1
|
|
}
|
|
|
|
private func expandSnippetPackageAncestors(_ packageID: SnippetPackage.ID?) {
|
|
var current = packageID
|
|
var visited: Set<SnippetPackage.ID> = []
|
|
while let id = current, let index = snippetPackages.firstIndex(where: { $0.id == id }),
|
|
visited.insert(id).inserted
|
|
{
|
|
snippetPackages[index].isExpanded = true
|
|
current = snippetPackages[index].parentID
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func persistCore() -> Bool {
|
|
do {
|
|
try vault.save(
|
|
VaultSnapshot(
|
|
hosts: hosts, groups: groups, identities: identities,
|
|
keys: keys, knownHosts: knownHosts, snippets: snippets,
|
|
snippetPackages: snippetPackages, portForwardings: portForwardings,
|
|
workspaceTemplates: workspaceTemplates
|
|
))
|
|
storageError = nil
|
|
return true
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Applies a CloudKit-imported projection without writing it back as a new
|
|
/// local edit. Runtime connection and transfer state intentionally remain.
|
|
public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) {
|
|
let previousIdentities = Dictionary(uniqueKeysWithValues: identities.map { ($0.id, $0) })
|
|
let previousKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) })
|
|
hosts = snapshot.hosts
|
|
groups = snapshot.groups
|
|
identities = normalizedManualOrder(
|
|
snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name
|
|
)
|
|
keys = normalizedManualOrder(
|
|
snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
|
|
)
|
|
let currentIdentityIDs = Set(identities.map(\.id))
|
|
recordedIdentityCredentialAccesses.formIntersection(currentIdentityIDs)
|
|
for identity in identities {
|
|
if let previous = previousIdentities[identity.id],
|
|
Self.identityConnectionFieldsChanged(previous, identity)
|
|
{
|
|
recordedIdentityCredentialAccesses.remove(identity.id)
|
|
}
|
|
}
|
|
let currentKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) })
|
|
let changedKeyIDs = Set(previousKeys.keys).union(currentKeys.keys).filter { id in
|
|
guard let previous = previousKeys[id], let current = currentKeys[id] else { return true }
|
|
return Self.keyConnectionFieldsChanged(previous, current)
|
|
}
|
|
recordedIdentityCredentialAccesses.subtract(
|
|
identities.filter { $0.keyID.map(changedKeyIDs.contains) == true }.map(\.id)
|
|
)
|
|
knownHosts = snapshot.knownHosts
|
|
snippets = normalizedSnippetOrder(snapshot.snippets)
|
|
snippetPackages = snapshot.snippetPackages
|
|
portForwardings = snapshot.portForwardings
|
|
workspaceTemplates = snapshot.workspaceTemplates
|
|
statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } }
|
|
connectionStatuses = connectionStatuses.filter { _, value in
|
|
hosts.contains { $0.id == value.hostID }
|
|
}
|
|
storageError = nil
|
|
vault.didApplyRemoteSnapshot()
|
|
}
|
|
}
|
|
|
|
// MARK: - Manual ordering
|
|
|
|
/// A deterministic tie-break keeps hand-built preview data usable before its
|
|
/// first move. Persisted data normally has unique
|
|
/// fractional indices, so the name is not otherwise part of the ordering.
|
|
private func manuallyOrdered(
|
|
_ lhsIndex: Double, _ lhsName: String,
|
|
_ rhsIndex: Double, _ rhsName: String
|
|
) -> Bool {
|
|
if lhsIndex != rhsIndex { return lhsIndex < rhsIndex }
|
|
return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending
|
|
}
|
|
|
|
/// Normalize duplicate preview and unit-test defaults once so their first
|
|
/// fractional move has real alphabetical neighbours.
|
|
private func normalizedManualOrder<Item>(
|
|
_ values: [Item],
|
|
sortIndex: WritableKeyPath<Item, Double>,
|
|
name: KeyPath<Item, String>
|
|
) -> [Item] {
|
|
let indices = values.map { $0[keyPath: sortIndex] }
|
|
guard indices.contains(where: { !$0.isFinite }) || Set(indices).count != indices.count else {
|
|
return values
|
|
}
|
|
|
|
var ordered = values.sorted {
|
|
manuallyOrdered(
|
|
$0[keyPath: sortIndex], $0[keyPath: name],
|
|
$1[keyPath: sortIndex], $1[keyPath: name]
|
|
)
|
|
}
|
|
for index in ordered.indices { ordered[index][keyPath: sortIndex] = Double(index) }
|
|
return ordered
|
|
}
|
|
|
|
/// Snippet indices are local to a Package. Equal indices in two different
|
|
/// Packages are expected, so normalization must never compare them globally.
|
|
private func normalizedSnippetOrder(_ values: [Snippet]) -> [Snippet] {
|
|
var result = values
|
|
for packageID in Set(values.map(\.packageID)) {
|
|
let members = result.indices.filter { result[$0].packageID == packageID }
|
|
let sortIndices = members.map { result[$0].sortIndex }
|
|
guard
|
|
sortIndices.contains(where: { !$0.isFinite })
|
|
|| Set(sortIndices).count != sortIndices.count
|
|
else { continue }
|
|
|
|
let ordered = members.sorted {
|
|
manuallyOrdered(
|
|
result[$0].sortIndex, result[$0].title,
|
|
result[$1].sortIndex, result[$1].title
|
|
)
|
|
}
|
|
for (position, index) in ordered.enumerated() {
|
|
result[index].sortIndex = Double(position)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private struct ManualOrderPlacement<ID: Hashable> {
|
|
let moving: [ID]
|
|
let resulting: [ID]
|
|
let positions: [ID: Double]
|
|
}
|
|
|
|
/// Generic remove-then-insert calculation shared by Hosts, Keys and Identities.
|
|
/// The destination may omit moving IDs (Hosts crossing groups), so validity is
|
|
/// supplied separately from the concrete destination collection.
|
|
private func manualOrderPlacement<Item: Identifiable>(
|
|
_ requestedIDs: [Item.ID],
|
|
before next: Item.ID?,
|
|
destinationItems: [Item],
|
|
validIDs: Set<Item.ID>,
|
|
sortIndex: KeyPath<Item, Double>
|
|
) -> ManualOrderPlacement<Item.ID>? where Item.ID: Hashable {
|
|
var seen: Set<Item.ID> = []
|
|
let moving = requestedIDs.filter {
|
|
validIDs.contains($0) && seen.insert($0).inserted
|
|
}
|
|
guard !moving.isEmpty else { return nil }
|
|
|
|
let movingSet = Set(moving)
|
|
let remaining = destinationItems.filter { !movingSet.contains($0.id) }
|
|
let insertionIndex: Int
|
|
if let next {
|
|
guard let index = remaining.firstIndex(where: { $0.id == next }) else { return nil }
|
|
insertionIndex = index
|
|
} else {
|
|
insertionIndex = remaining.endIndex
|
|
}
|
|
|
|
let resulting =
|
|
Array(remaining[..<insertionIndex]).map(\.id) + moving
|
|
+ Array(remaining[insertionIndex...]).map(\.id)
|
|
let lower = insertionIndex > 0 ? remaining[insertionIndex - 1][keyPath: sortIndex] : nil
|
|
let upper = insertionIndex < remaining.endIndex ? remaining[insertionIndex][keyPath: sortIndex] : nil
|
|
var positions: [Item.ID: Double] = [:]
|
|
|
|
switch (lower, upper) {
|
|
case (let lower?, let upper?):
|
|
let step = (upper - lower) / Double(moving.count + 1)
|
|
if step.isFinite && step > 0 {
|
|
for (offset, id) in moving.enumerated() {
|
|
positions[id] = lower + step * Double(offset + 1)
|
|
}
|
|
}
|
|
case (let lower?, nil):
|
|
for (offset, id) in moving.enumerated() {
|
|
positions[id] = lower + Double(offset + 1)
|
|
}
|
|
case (nil, let upper?):
|
|
for (offset, id) in moving.enumerated() {
|
|
positions[id] = upper - Double(moving.count - offset)
|
|
}
|
|
case (nil, nil):
|
|
for (offset, id) in moving.enumerated() { positions[id] = Double(offset) }
|
|
}
|
|
|
|
if positions.count != moving.count {
|
|
positions = Dictionary(
|
|
uniqueKeysWithValues: resulting.enumerated().map { ($0.element, Double($0.offset)) }
|
|
)
|
|
}
|
|
return ManualOrderPlacement(moving: moving, resulting: resulting, positions: positions)
|
|
}
|
|
|
|
/// Shared fixed-section wrapper for every manually ordered Keychain collection.
|
|
private func reorderManually<Item: Identifiable>(
|
|
_ requestedIDs: [Item.ID],
|
|
before next: Item.ID?,
|
|
items: inout [Item],
|
|
sortIndex: WritableKeyPath<Item, Double>
|
|
) -> Bool where Item.ID: Hashable {
|
|
let current = items.sorted { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] }
|
|
guard
|
|
let placement = manualOrderPlacement(
|
|
requestedIDs, before: next, destinationItems: current,
|
|
validIDs: Set(current.map(\.id)), sortIndex: sortIndex
|
|
), placement.resulting != current.map(\.id)
|
|
else { return false }
|
|
|
|
for index in items.indices {
|
|
if let value = placement.positions[items[index].id] {
|
|
items[index][keyPath: sortIndex] = value
|
|
}
|
|
}
|
|
items.sort { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] }
|
|
return true
|
|
}
|