226 lines
9.8 KiB
Swift
226 lines
9.8 KiB
Swift
import Foundation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
/// A value snapshot taken on MainActor. Resolving Keychain references can block and
|
|
/// therefore happens on a detached worker, before any SSH transport is opened.
|
|
public nonisolated struct SSHConnectionPreparation: Sendable {
|
|
public struct Node: Sendable {
|
|
public var host: Host
|
|
public var identity: Identity?
|
|
public var key: SSHKeyRef?
|
|
public var submission: SSHAuthenticationSubmission?
|
|
public var trustedKeys: Set<SSHHostKeyIdentity>
|
|
public var proxyIdentity: Identity?
|
|
public var proxySubmission: SSHProxyAuthenticationSubmission?
|
|
public init(
|
|
host: Host, identity: Identity?, key: SSHKeyRef?, submission: SSHAuthenticationSubmission?,
|
|
trustedKeys: Set<SSHHostKeyIdentity>, proxyIdentity: Identity?,
|
|
proxySubmission: SSHProxyAuthenticationSubmission? = nil
|
|
) {
|
|
self.host = host
|
|
self.identity = identity
|
|
self.key = key
|
|
self.submission = submission
|
|
self.trustedKeys = trustedKeys
|
|
self.proxyIdentity = proxyIdentity
|
|
self.proxySubmission = proxySubmission
|
|
}
|
|
|
|
}
|
|
public var nodes: [Node]
|
|
public var secrets: any SSHRuntimeSecretResolving
|
|
|
|
public func resolve(
|
|
discoveryOnly: Bool = false,
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) async throws -> SSHConnectionConfiguration {
|
|
let task = Task.detached {
|
|
try resolveSynchronously(
|
|
discoveryOnly: discoveryOnly,
|
|
trustPolicy: trustPolicy
|
|
)
|
|
}
|
|
return try await withTaskCancellationHandler {
|
|
let result = try await task.value
|
|
try Task.checkCancellation()
|
|
return result
|
|
} onCancel: {
|
|
task.cancel()
|
|
}
|
|
}
|
|
|
|
private func resolveSynchronously(
|
|
discoveryOnly: Bool, trustPolicy: SSHHostTrustPolicy
|
|
) throws -> SSHConnectionConfiguration {
|
|
guard let target = nodes.last else {
|
|
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
|
}
|
|
var jumps: [SSHRouteHopConfiguration] = []
|
|
for (offset, node) in nodes.dropLast().enumerated() {
|
|
do {
|
|
try Task.checkCancellation()
|
|
let material = try credentials(node)
|
|
if trustPolicy == .requireKnownHost, node.trustedKeys.isEmpty {
|
|
throw TermindSSHError.untrustedHost
|
|
}
|
|
jumps.append(
|
|
SSHRouteHopConfiguration(
|
|
id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port,
|
|
username: material.0, credentials: material.1,
|
|
expectedHostKeys: node.trustedKeys,
|
|
proxy: try proxy(node),
|
|
authenticationReference: authenticationReference(for: node)
|
|
))
|
|
} catch {
|
|
if error is CancellationError { throw error }
|
|
let ssh = error as? TermindSSHError
|
|
if ssh?.isProxyAuthentication == true { throw error }
|
|
throw TermindSSHError.routeHost(
|
|
hostID: node.host.id, authentication: ssh?.isLocalAuthenticationRequirement == true,
|
|
serverChallenge: false, hostKey: isUntrusted(ssh),
|
|
message: String(
|
|
localized: "Jump Host \(offset + 1) (\(node.host.name)): \(error.localizedDescription)")
|
|
)
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
let material =
|
|
discoveryOnly
|
|
? ("", [SSHCredentials]())
|
|
: try credentials(target)
|
|
if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty {
|
|
throw TermindSSHError.untrustedHost
|
|
}
|
|
return SSHConnectionConfiguration(
|
|
hostID: target.host.id, host: target.host.host, port: target.host.port,
|
|
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
|
|
proxy: try proxy(target), jumps: jumps,
|
|
charset: target.host.charset,
|
|
authenticationReference: authenticationReference(for: target)
|
|
)
|
|
}
|
|
|
|
private func authenticationReference(for node: Node) -> SSHAuthenticationReference {
|
|
if let identity = connectionIdentity(for: node) {
|
|
return SSHAuthenticationReference(
|
|
identityID: identity.id,
|
|
username: identity.username,
|
|
permitsNone: !identity.hasSecret,
|
|
hasEphemeralSecret: node.submission?.hasPassword == true
|
|
|| node.submission?.hasPassphrase == true
|
|
)
|
|
}
|
|
return SSHAuthenticationReference(
|
|
fallbackKeyID: node.submission?.keyID ?? node.key?.id,
|
|
username: node.submission?.username ?? "",
|
|
permitsNone: node.key == nil && node.submission?.hasPassword != true,
|
|
hasEphemeralSecret: node.submission?.hasPassword == true
|
|
|| node.submission?.hasPassphrase == true
|
|
)
|
|
}
|
|
|
|
private func credentials(_ node: Node) throws -> (String, [SSHCredentials]) {
|
|
let username = (node.submission?.username ?? node.identity?.username ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
|
var result: [SSHCredentials] = []
|
|
var retrievalFailure: (any Error)?
|
|
if let key = node.key {
|
|
if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true {
|
|
throw TermindSSHError.keyPassphraseRequired
|
|
}
|
|
let stored: SSHResolvedKeyCredential?
|
|
do {
|
|
stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage)
|
|
} catch {
|
|
stored = nil
|
|
retrievalFailure = error
|
|
}
|
|
if let stored {
|
|
result.append(
|
|
stored.sshCredential(
|
|
publicKey: key.publicKey, certificate: key.certificate,
|
|
passphraseOverride: node.submission?.passphraseForConnection(),
|
|
requiresPassphrase: key.requiresPassphraseEntry,
|
|
requiresUserPresence: key.requiresUserPresence
|
|
))
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
if let password = node.submission?.passwordForConnection() {
|
|
result.append(.password(password))
|
|
} else if node.submission == nil || node.submission?.identityID != nil,
|
|
let identity = node.identity, identity.usesPassword
|
|
{
|
|
do {
|
|
if let password = try secrets.sshSecret(
|
|
for: identity.credentialRef, storage: identity.passwordStorage
|
|
) {
|
|
result.append(.password(password))
|
|
}
|
|
} catch {
|
|
if retrievalFailure == nil { retrievalFailure = error }
|
|
}
|
|
}
|
|
if result.isEmpty, let retrievalFailure { throw retrievalFailure }
|
|
let usesIdentity = node.submission == nil || node.submission?.identityID != nil
|
|
if result.isEmpty, node.key != nil || (usesIdentity && node.identity?.hasSecret == true) {
|
|
throw TermindSSHError.missingCredential
|
|
}
|
|
return (username, result)
|
|
}
|
|
|
|
private func proxy(_ node: Node) throws -> SSHTransportProxy? {
|
|
guard let proxy = node.host.proxy else { return nil }
|
|
guard proxy.isValid else {
|
|
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
|
|
}
|
|
var password = node.proxySubmission?.passwordForConnection()
|
|
let identity =
|
|
node.proxySubmission?.identityID.flatMap { id in
|
|
node.proxyIdentity?.id == id ? node.proxyIdentity : nil
|
|
} ?? (node.proxySubmission == nil ? node.proxyIdentity : nil)
|
|
if password == nil, proxy.identityID != nil || node.proxySubmission?.identityID != nil {
|
|
guard let identity, identity.usesPassword else {
|
|
throw TermindSSHError.proxyAuthentication(
|
|
hostID: node.host.id, serverChallenge: false,
|
|
message: String(localized: "The proxy password identity is unavailable."))
|
|
}
|
|
do {
|
|
password = try secrets.sshSecret(for: identity.credentialRef, storage: identity.passwordStorage)
|
|
} catch {
|
|
throw TermindSSHError.proxyAuthentication(
|
|
hostID: node.host.id, serverChallenge: false, message: error.localizedDescription)
|
|
}
|
|
guard password != nil else {
|
|
throw TermindSSHError.proxyAuthentication(
|
|
hostID: node.host.id, serverChallenge: false,
|
|
message: String(localized: "The proxy password is unavailable."))
|
|
}
|
|
}
|
|
return SSHTransportProxy(
|
|
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
|
|
username: node.proxySubmission?.username ?? proxy.username, password: password,
|
|
identityID: node.proxySubmission?.identityID ?? proxy.identityID,
|
|
ownerHostID: node.host.id
|
|
)
|
|
}
|
|
|
|
private func isUntrusted(_ error: TermindSSHError?) -> Bool {
|
|
if case .untrustedHost = error { return true }
|
|
return false
|
|
}
|
|
private func connectionIdentity(for node: Node) -> Identity? {
|
|
if node.submission != nil, node.submission?.identityID == nil { return nil }
|
|
return node.identity
|
|
}
|
|
|
|
public init(nodes: [Node], secrets: any SSHRuntimeSecretResolving) {
|
|
self.nodes = nodes
|
|
self.secrets = secrets
|
|
}
|
|
|
|
}
|