Extract multiplexer dialect and resumable terminal engine registries while preserving session and checkpoint compatibility. Share local-network and theme policies, add regression coverage, and update discovery actions to Cancel and Rescan with localized Telnet copy.
223 lines
9.9 KiB
Swift
223 lines
9.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
public nonisolated struct HostMACAddressCandidate: Identifiable, Hashable, Sendable {
|
|
public var interface: String
|
|
public var address: MACAddress
|
|
public var id: String { interface + ":" + address.rawValue }
|
|
public init(interface: String, address: MACAddress) {
|
|
self.interface = interface
|
|
self.address = address
|
|
}
|
|
}
|
|
|
|
public nonisolated protocol HostMACAddressResolving: Sendable {
|
|
func resolve(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
|
|
-> [HostMACAddressCandidate]
|
|
}
|
|
|
|
/// A disposable editor operation. Authentication is memory-only and never saves the draft.
|
|
@MainActor
|
|
@Observable
|
|
public final class HostMACAddressModel {
|
|
public let connection = RemoteSessionConnectionController()
|
|
public private(set) var candidates: [HostMACAddressCandidate] = []
|
|
public private(set) var isFinished = false
|
|
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
|
|
private let host: Host
|
|
private let library: HostLibraryModel
|
|
private let resolver: any HostMACAddressResolving
|
|
private var submissions: [Host.ID: SSHAuthenticationSubmission]
|
|
private var proxies: [Host.ID: SSHProxyAuthenticationSubmission]
|
|
private var task: Task<Void, Never>?
|
|
private var generation = UUID()
|
|
|
|
public init(
|
|
host: Host, library: HostLibraryModel, resolver: any HostMACAddressResolving,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
|
|
proxies: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
|
|
) {
|
|
self.host = host
|
|
self.library = library
|
|
self.resolver = resolver
|
|
self.submissions = submissions
|
|
self.proxies = proxies
|
|
}
|
|
|
|
public convenience init(
|
|
host: Host, library: HostLibraryModel, resolver: any HostMACAddressResolving,
|
|
custom: HostCustomIdentityDraft, tailscaleUsername: String
|
|
) {
|
|
var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
|
|
if host.isTailscaleManaged {
|
|
submissions[host.id] = .init(username: tailscaleUsername)
|
|
} else if host.sshIdentityID == nil || library.identity(for: host)?.isHostCustom == true,
|
|
!custom.sshUsername.isEmpty
|
|
{
|
|
submissions[host.id] = .init(
|
|
username: custom.sshUsername,
|
|
password: custom.sshPassword.isEmpty ? nil : custom.sshPassword,
|
|
keyID: custom.sshKeyID)
|
|
}
|
|
var proxies: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
|
|
if custom.usesCustomProxy, let proxy = host.proxy {
|
|
proxies[host.id] = .init(
|
|
username: proxy.username, password: custom.proxyPassword, identityID: proxy.identityID)
|
|
}
|
|
self.init(host: host, library: library, resolver: resolver, submissions: submissions, proxies: proxies)
|
|
}
|
|
|
|
public func start() {
|
|
guard task == nil else { return }
|
|
generation = UUID()
|
|
let token = generation
|
|
let route = library.route(for: host)
|
|
connection.journey.begin(
|
|
host: host, route: route, purpose: .sshSession, username: library.identity(for: host)?.username)
|
|
task = Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
if let pending = proxies.firstHostRequiringProxyPassword(in: route) {
|
|
throw TermindSSHError.proxyAuthentication(
|
|
hostID: pending.id, serverChallenge: false,
|
|
message: TermindSSHError.missingCredential.localizedDescription)
|
|
}
|
|
if let pending = submissions.firstHostRequiringPassword(in: route) {
|
|
throw TermindSSHError.routeHost(
|
|
hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false,
|
|
message: TermindSSHError.missingCredential.localizedDescription)
|
|
}
|
|
if let pending = submissions.firstHostRequiringPassphrase(
|
|
in: route, key: library.key, requiresEntry: library.requiresPassphraseEntry)
|
|
{
|
|
throw TermindSSHError.routeHost(
|
|
hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false,
|
|
message: TermindSSHError.keyPassphraseRequired.localizedDescription)
|
|
}
|
|
var preparation = try library.prepareSSHConnection(
|
|
for: host, submissions: submissions.takeAuthenticationSecrets(),
|
|
proxySubmissions: proxies.takeProxyAuthenticationSecrets(),
|
|
temporaryHostKeys: connection.temporaryHostKeys)
|
|
preparation.beforeConnect = nil
|
|
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
|
|
defer { configuration.clearSecrets() }
|
|
try Task.checkCancellation()
|
|
let request = SSHConnectionRequest(
|
|
configuration: configuration,
|
|
authorizeHostKey: { [weak self] challenge in
|
|
guard let self else { throw CancellationError() }
|
|
return try await self.authorize(challenge, token: token)
|
|
},
|
|
keyboardInteractive: { [weak self] challenge in
|
|
guard let self else { throw CancellationError() }
|
|
return try await self.answer(challenge, token: token)
|
|
},
|
|
securityKeySigning: { [weak self] request in
|
|
guard let self else { throw CancellationError() }
|
|
return try await self.sign(request, token: token)
|
|
})
|
|
let values = try await resolver.resolve(
|
|
request,
|
|
progress: connection.progressHandler(
|
|
attemptID: token, isCurrent: { [weak self] in self?.generation == $0 }))
|
|
try Task.checkCancellation()
|
|
guard token == generation else { return }
|
|
candidates = values
|
|
isFinished = true
|
|
connection.disconnect()
|
|
clearSecrets()
|
|
} catch {
|
|
guard token == generation, !Task.isCancelled else { return }
|
|
if let ssh = error as? TermindSSHError,
|
|
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
|
|
{
|
|
let target = route.first { $0.id == ssh.routeHostID } ?? host
|
|
authenticationPrompt =
|
|
ssh.isProxyAuthentication
|
|
? SSHAuthenticationPromptResolver.proxy(
|
|
for: target, using: library, submission: proxies[target.id],
|
|
failureMessage: ssh.localizedDescription,
|
|
mode: ssh.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
|
|
: SSHAuthenticationPromptResolver.host(
|
|
target, using: library, submission: submissions[target.id],
|
|
failureMessage: ssh.localizedDescription,
|
|
mode: ssh.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
|
|
authenticationPrompt?.allowsSaving = false
|
|
connection.journey.waitForAuthentication(at: target.id, failure: ssh.localizedDescription)
|
|
} else {
|
|
clearSecrets()
|
|
connection.journey.fail(error.localizedDescription)
|
|
}
|
|
}
|
|
if token == generation { task = nil }
|
|
}
|
|
}
|
|
|
|
public func submit(
|
|
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?
|
|
) {
|
|
guard let prompt = authenticationPrompt, task == nil else { return }
|
|
if prompt.isProxy {
|
|
proxies[prompt.host.id] = .init(username: username, password: password, identityID: identityID)
|
|
} else {
|
|
let identity = identityID.flatMap(library.identity)
|
|
submissions[prompt.host.id] = .init(
|
|
username: identity?.username ?? username, password: password, passphrase: passphrase,
|
|
keyID: identity?.keyID ?? keyID, identityID: identityID)
|
|
}
|
|
authenticationPrompt = nil
|
|
start()
|
|
}
|
|
|
|
public func accept(_ observation: SSHHostKeyObservation) {
|
|
_ = connection.useTemporaryHostKey(observation)
|
|
}
|
|
|
|
private func check(_ token: UUID) throws {
|
|
try Task.checkCancellation()
|
|
guard token == generation else { throw CancellationError() }
|
|
}
|
|
|
|
private func authorize(_ challenge: SSHHostKeyChallenge, token: UUID) async throws -> SSHHostKeyDecision {
|
|
try check(token)
|
|
let decision = await connection.authorizeHostKey(challenge)
|
|
try check(token)
|
|
return decision
|
|
}
|
|
|
|
private func answer(_ challenge: SSHKeyboardInteractiveChallenge, token: UUID) async throws -> [String] {
|
|
try check(token)
|
|
let answers = try await connection.answerKeyboardInteractive(challenge)
|
|
try check(token)
|
|
return answers
|
|
}
|
|
|
|
private func sign(_ request: SecurityKeySigningRequest, token: UUID) async throws -> SecurityKeyAssertion {
|
|
try check(token)
|
|
let assertion = try await connection.signWithSecurityKey(request)
|
|
try check(token)
|
|
return assertion
|
|
}
|
|
|
|
public func cancel() {
|
|
generation = UUID()
|
|
task?.cancel()
|
|
task = nil
|
|
authenticationPrompt = nil
|
|
isFinished = false
|
|
candidates = []
|
|
clearSecrets()
|
|
connection.disconnect()
|
|
}
|
|
|
|
private func clearSecrets() {
|
|
for id in submissions.keys { submissions[id]?.clearSecrets() }
|
|
for id in proxies.keys { proxies[id]?.clearSecrets() }
|
|
submissions.removeAll()
|
|
proxies.removeAll()
|
|
}
|
|
}
|