Files
Termind/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift
T

427 lines
19 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
@Observable
public final class SSHImportModel {
public var items: [SSHImportItem] = []
public var expanded = Set(SSHImportCategory.allCases)
public var storage: KeyStorage = .iCloud
public var keyColor: TagColor = .none
public var hostGroupID: HostGroup.ID?
public var hostColor: TagColor = .none
public var hostTags: [String] = []
public private(set) var isWorking = false
public private(set) var error: String?
public private(set) var completed = false
public private(set) var importedCount = 0
private let library: HostLibraryModel
private let reader: any SSHImportReading
private var root: URL?
private var grants: [URL] = []
public var sourcePath: String? { root?.path }
private var knownHostsOnly = false
private let defaultUsername: String
private var jumpIDs: [String: UUID] = [:]
public init(library: HostLibraryModel, reader: any SSHImportReading, defaultUsername: String = "") {
self.defaultUsername = defaultUsername
self.library = library
self.reader = reader
}
public func scan(_ url: URL, knownHostsOnly: Bool) async {
guard !isWorking else { return }
grants = []
items = []
completed = false
root = url
self.knownHostsOnly = knownHostsOnly
await reload()
}
public func authorize(_ url: URL) async {
grants.append(url)
await reload()
}
private func reload() async {
guard let root else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants)
addJumpDestinations()
for index in items.indices {
if let old = previous[items[index].id] {
items[index].selected = old.selected
items[index].acceptedWarnings = old.acceptedWarnings
items[index].preferredKeyID = old.preferredKeyID
if items[index].host != nil { items[index].name = old.name }
}
}
refreshDuplicates()
} catch {
self.error =
(error as? SSHImportSourceError)?.localizedDescription
?? String(localized: "The SSH files could not be read.")
}
}
private func addJumpDestinations() {
let originalIDs = items.filter { $0.host != nil }.map(\.id)
let jumps = items.compactMap(\.host).flatMap(\.jumps)
for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) {
let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init)
let address = userParts.last ?? jump
let host: String
let port: Int?
if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) {
(host, port) = endpoint
} else if address.filter({ $0 == ":" }).count == 1 {
let parts = address.split(separator: ":").map(String.init)
host = parts.first ?? address
port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0
} else {
host = address
port = nil
}
let saved = library.hosts.first { $0.name == host }
let identity = saved?.identityID.flatMap(library.identity)
let base = items.first { $0.host?.alias == host }?.host
let value = SSHImportedHost(
alias: jump, hostname: base?.hostname ?? saved?.host ?? host,
port: port ?? base?.port ?? saved?.port ?? 22,
username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? defaultUsername,
identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID)
let id = jumpIDs[jump] ?? UUID()
jumpIDs[jump] = id
items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value))
}
// A multi-hop route must not rewrite the standalone configuration of its intermediate aliases.
for id in originalIDs {
guard let index = items.firstIndex(where: { $0.id == id }),
let chain = items[index].host?.jumps, chain.count > 1
else { continue }
var previous = chain[0]
for alias in chain.dropFirst() {
guard let base = items.first(where: { $0.host?.alias == alias }), var host = base.host else { continue }
let routeAlias = previous + " → " + alias
if !items.contains(where: { $0.host?.alias == routeAlias }) {
host.alias = routeAlias
host.jumps = [previous]
let id = jumpIDs[routeAlias] ?? UUID()
jumpIDs[routeAlias] = id
items.append(
SSHImportItem(
id: id, category: .hosts, name: routeAlias,
source: items[index].source, warnings: base.warnings, host: host))
}
previous = routeAlias
}
items[index].host?.jumps = [previous]
}
}
public func refreshDuplicates() {
for index in items.indices {
guard !items[index].unavailable else { continue }
if let key = items[index].key {
if let existing = library.keys.first(where: {
$0.fingerprint == key.fingerprint && $0.certificate == key.certificate
&& library.keyAvailability(for: $0) == .available
}) {
items[index].existingID = existing.id
items[index].selected = false
} else if let duplicate = items[..<index].first(where: {
$0.key?.fingerprint == key.fingerprint && $0.key?.certificate == key.certificate
}) {
items[index].existingID = duplicate.existingID ?? duplicate.id
items[index].selected = false
}
}
if let entry = items[index].knownHost {
let same: (KnownHostEntry) -> Bool = {
$0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port)
&& $0.identity == entry.identity
}
if let existing = library.knownHosts.first(where: same) {
items[index].existingID = existing.id
items[index].selected = false
} else if let previous = items[..<index].first(where: { $0.knownHost.map(same) == true }) {
items[index].existingID = previous.existingID ?? previous.id
items[index].selected = false
}
}
}
for index in items.indices where items[index].host != nil {
if let existing = identicalHost(items[index]) {
items[index].existingID = existing.id
items[index].selected = false
}
}
}
public func selectable(_ item: SSHImportItem) -> Bool { !item.unavailable && item.existingID == nil }
public func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected }
}
public func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
items.filter { (category == nil || $0.category == category) && selectable($0) }
}
public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) {
var updated = items
for index in updated.indices
where selectable(updated[index]) && (category == nil || updated[index].category == category) {
updated[index].selected = selected
}
items = updated
}
public func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] {
guard let host = item.host else { return [] }
if let id = host.existingKeyID, let key = library.key(id) { return [key] }
var seen: Set<UUID> = []
return host.identityPaths.flatMap { path in
items.filter {
guard $0.source == path && $0.category == .keys else { return false }
if host.certificatePaths.isEmpty {
return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub")
}
return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths)
}.compactMap { candidate -> SSHKeyRef? in
if let id = candidate.existingID {
if let key = library.key(id) { return key }
if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key }
return nil
}
return candidate.selected ? candidate.key : nil
}
}.filter { seen.insert($0.id).inserted }
}
public func chosenKey(for item: SSHImportItem) -> SSHKeyRef? {
let options = keyOptions(for: item)
return options.first { $0.id == item.preferredKeyID } ?? options.first
}
private func identicalHost(_ item: SSHImportItem) -> Host? {
func matches(_ row: SSHImportItem, _ saved: Host, visited: Set<UUID>) -> Bool {
guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host,
saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port,
let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username,
identity.keyID == chosenKey(for: row)?.id
else { return false }
if candidate.jumps.isEmpty { return saved.jumpHostID == nil }
guard candidate.jumps.count == 1, let alias = candidate.jumps.first,
let jump = saved.jumpHostID.flatMap(library.host)
else { return false }
if let next = items.first(where: { $0.host?.alias == alias }) {
return matches(next, jump, visited: visited.union([saved.id]))
}
return jump.name == alias
}
return library.hosts.first { matches(item, $0, visited: []) }
}
public func warnings(for item: SSHImportItem) -> [String] {
var warnings = item.warnings
if let entry = item.knownHost,
library.knownHosts.contains(where: {
let sameAddress =
$0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port)
|| (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port))
|| (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port))
return sameAddress && $0.identity != entry.identity
})
{
warnings.append(
String(localized: "This address already has a different trusted fingerprint. Confirm adding this key."))
}
return warnings
}
public func hasNameConflict(_ item: SSHImportItem) -> Bool {
guard item.host != nil else { return false }
return (library.hosts.contains { $0.name == item.name } && identicalHost(item) == nil)
|| selectedItems(.hosts).contains { $0.id != item.id && $0.name == item.name }
}
public func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? {
if !item.issue.isEmpty { return item.issue }
if item.requiresPassphrase {
return String(localized: "Enter the key passphrase to validate this private key.")
}
if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings {
return String(localized: "Review and accept the import warnings.")
}
if let host = item.host {
guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!host.hostname.isEmpty, !host.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
(1...65_535).contains(host.port)
else { return String(localized: "Invalid SSH record.") }
if hasNameConflict(item) {
return String(localized: "A different host already uses this name. Rename this import or deselect it.")
}
for jump in host.jumps {
if !library.hosts.contains(where: { $0.name == jump })
&& !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) })
{
return String(localized: "Select the required jump host or deselect this host.")
}
}
}
return nil
}
public var canImport: Bool {
!isWorking && !completed && !selectedItems().isEmpty
&& selectedItems().allSatisfy { problem(for: $0) == nil }
&& (try? makeBatch()) != nil
}
public func unlock(_ id: UUID, passphrase: String) async {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
let previous = items[index]
var replacements = try await reader.unlock(id: id, name: previous.name, passphrase: passphrase)
guard !replacements.isEmpty, replacements.allSatisfy({ $0.category == .keys && $0.key != nil }) else {
throw CocoaError(.coderInvalidValue)
}
for replacementIndex in replacements.indices {
replacements[replacementIndex].selected = previous.selected
replacements[replacementIndex].acceptedWarnings = previous.acceptedWarnings
}
items.replaceSubrange(index...index, with: replacements)
refreshDuplicates()
} catch {
if let sourceError = error as? SSHImportSourceError {
self.error = sourceError.localizedDescription
} else {
items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.")
}
}
}
public func commit() async {
refreshDuplicates()
guard canImport else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
var batch = try makeBatch()
batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id)))
// Revalidate after awaiting the reader, because another window or sync can write meanwhile.
refreshDuplicates()
let refreshed = try makeBatch()
guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)),
selectedItems().allSatisfy({ problem(for: $0) == nil })
else {
throw CocoaError(.coderInvalidValue)
}
batch.hosts = refreshed.hosts
batch.identities = refreshed.identities
batch.knownHosts = refreshed.knownHosts
try library.commitSSHImport(batch)
importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count
completed = true
await reader.discard()
} catch { self.error = error.localizedDescription }
}
public func discard() async { await reader.discard() }
private func makeBatch() throws -> SSHImportBatch {
var batch = SSHImportBatch()
batch.keys = selectedItems(.keys).compactMap { item in
guard var key = item.key else { return nil }
key.storage = storage
key.colorTag = keyColor
return key
}
batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost)
var built: [UUID: Host] = [:]
var visiting: Set<UUID> = []
func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host {
if let existing = item.existingID.flatMap(library.host) { return existing }
if let existing = built[item.id] {
if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain }
return existing
}
guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else {
throw ImportRouteError.invalid
}
defer { visiting.remove(item.id) }
var jumpID = forcedJump
for alias in imported.jumps {
if let candidate = items.first(where: {
$0.host?.alias == alias && ($0.selected || $0.existingID != nil)
}) {
jumpID = try build(candidate, forcedJump: jumpID).id
} else if let existing = library.hosts.first(where: { $0.name == alias }) {
if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain }
jumpID = existing.id
} else {
throw ImportRouteError.invalid
}
}
let keyID = chosenKey(for: item)?.id
let identity: Identity
if let existing = (library.identities + batch.identities).first(where: {
$0.username == imported.username && $0.keyID == keyID
}) {
identity = existing
} else {
identity = Identity(name: imported.username, username: imported.username, keyID: keyID)
batch.identities.append(identity)
}
let host = Host(
id: item.id, name: item.name, host: imported.hostname, port: imported.port,
identityID: identity.id, jumpHostID: jumpID, groupID: hostGroupID,
tags: hostTags, colorTag: hostColor)
built[item.id] = host
return host
}
// Build routes with the most hops first so intermediate aliases get their chain before reuse.
for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) {
_ = try build(item)
}
batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) })
for host in batch.hosts {
var current: Host? = host
var seen: Set<UUID> = []
while let node = current {
guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid }
current = node.jumpHostID.flatMap { all[$0] }
}
}
return batch
}
public var routeProblem: String? {
do {
_ = try makeBatch()
return nil
} catch { return error.localizedDescription }
}
}
nonisolated private enum ImportRouteError: LocalizedError {
case invalid, conflictingChain
public var errorDescription: String? {
switch self {
case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.")
case .conflictingChain:
String(localized: "These hosts require conflicting jump routes. Import the routes separately.")
}
}
}