731 lines
33 KiB
Swift
731 lines
33 KiB
Swift
import Foundation
|
|
import Logging
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
|
|
case stopped
|
|
case preparing
|
|
case awaitingTrust
|
|
case awaitingAuthentication
|
|
case connecting
|
|
case reconnecting
|
|
case starting
|
|
case running(activeConnections: Int, sshConnections: Int = 1, targetSSHConnections: Int = 1)
|
|
case failed(String)
|
|
|
|
public var isActive: Bool {
|
|
switch self {
|
|
case .stopped, .failed: false
|
|
default: true
|
|
}
|
|
}
|
|
|
|
public var isFailed: Bool {
|
|
if case .failed = self { return true }
|
|
return false
|
|
}
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .stopped: String(localized: "Stopped")
|
|
case .preparing: String(localized: "Preparing")
|
|
case .awaitingTrust: String(localized: "Awaiting trust")
|
|
case .awaitingAuthentication: String(localized: "Awaiting authentication")
|
|
case .connecting: String(localized: "Connecting")
|
|
case .reconnecting: String(localized: "Reconnecting")
|
|
case .starting: String(localized: "Starting")
|
|
case .running(let count, let sshConnections, let targetSSHConnections):
|
|
if targetSSHConnections > 1 {
|
|
String(localized: "Running · SSH \(sshConnections)/\(targetSSHConnections) · \(count) TCP flows")
|
|
} else if count == 0 {
|
|
String(localized: "Running")
|
|
} else if count == 1 {
|
|
String(localized: "Running · 1 connection")
|
|
} else {
|
|
String(localized: "Running · \(count) connections")
|
|
}
|
|
case .failed(let message): message
|
|
}
|
|
}
|
|
|
|
public var compactLabel: String {
|
|
switch self {
|
|
case .running: String(localized: "Running")
|
|
case .failed: String(localized: "Failed")
|
|
default: label
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A host's manually opened transport and its shared authentication surface.
|
|
@Observable
|
|
public final class PortForwardingConnection {
|
|
public let hostID: Host.ID
|
|
public let connection: RemoteSessionConnectionController
|
|
public var journey: ConnectionJourney { connection.journey }
|
|
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
|
|
public fileprivate(set) var poolStatus = SSHForwardingPoolStatus.single
|
|
public fileprivate(set) var hostKeyRequestID: UUID?
|
|
fileprivate var route: [Host]
|
|
fileprivate var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
|
|
fileprivate var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
|
|
fileprivate var attemptID: UUID?
|
|
fileprivate var forwardingConnection: (any SSHForwardingConnectionProtocol)?
|
|
fileprivate var task: Task<Void, Never>?
|
|
fileprivate var connected = false
|
|
fileprivate var ready = false
|
|
fileprivate var silentAttempt = false
|
|
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
|
|
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
|
|
fileprivate var maximumConnections: Int
|
|
|
|
fileprivate init(
|
|
host: Host, route: [Host], maximumConnections: Int, reconnect: SessionReconnectController
|
|
) {
|
|
hostID = host.id
|
|
self.route = route
|
|
self.maximumConnections = maximumConnections
|
|
connection = RemoteSessionConnectionController(reconnect: reconnect)
|
|
}
|
|
|
|
fileprivate func setPrompt(_ prompt: SSHAuthenticationPrompt?) { authenticationPrompt = prompt }
|
|
}
|
|
|
|
/// Explicit Start owns runtime intent. Established connections recover silently until Stop or a terminal failure.
|
|
@Observable
|
|
public final class PortForwardingController {
|
|
public nonisolated enum PoolPolicy: Sendable {
|
|
case singleConnection
|
|
case hostConfigured
|
|
}
|
|
|
|
public typealias ConnectionFactory =
|
|
@MainActor (
|
|
@escaping SSHForwardingConnection.ConnectionHandler, @escaping SSHForwardingConnection.FailureHandler
|
|
) -> any SSHForwardingConnectionProtocol
|
|
|
|
private let hosts: HostLibraryModel
|
|
private let makeConnection:
|
|
@MainActor (
|
|
Int, @escaping SSHForwardingConnection.ConnectionHandler,
|
|
@escaping SSHForwardingConnection.FailureHandler
|
|
) -> any SSHForwardingConnectionProtocol
|
|
private let poolPolicy: PoolPolicy
|
|
private var connectionLimit: Int
|
|
private var limitUpdateTask: Task<Void, Never>?
|
|
private let logger: Logger
|
|
private let makeReconnectController: @MainActor () -> SessionReconnectController
|
|
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
|
|
private var connections: [Host.ID: PortForwardingConnection] = [:]
|
|
private var presentations: [PortForwardingRule.ID: PortForwardingConnection] = [:]
|
|
private var generations: [PortForwardingRule.ID: UUID] = [:]
|
|
private var ruleTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
|
|
private var cleanupTasks: [Host.ID: Task<Void, Never>] = [:]
|
|
private var savingAuthentication: PortForwardingConnection?
|
|
|
|
public init(
|
|
hosts: HostLibraryModel,
|
|
poolPolicy: PoolPolicy = .singleConnection,
|
|
connectionLimit: Int = Host.portForwardingMaxConnectionsRange.upperBound,
|
|
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding"),
|
|
makeConnection: ConnectionFactory? = nil,
|
|
makeReconnectController: @escaping @MainActor () -> SessionReconnectController = {
|
|
SessionReconnectController()
|
|
}
|
|
) {
|
|
self.hosts = hosts
|
|
self.logger = logger
|
|
self.poolPolicy = poolPolicy
|
|
self.connectionLimit = Host.normalizedPortForwardingMaxConnections(connectionLimit)
|
|
self.makeReconnectController = makeReconnectController
|
|
if let makeConnection {
|
|
self.makeConnection = { _, connectionHandler, failureHandler in
|
|
makeConnection(connectionHandler, failureHandler)
|
|
}
|
|
} else {
|
|
self.makeConnection = { maximumConnections, connectionHandler, failureHandler in
|
|
if poolPolicy == .hostConfigured {
|
|
SSHForwardingConnectionPool(
|
|
maximumConnections: maximumConnections,
|
|
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
|
|
} else {
|
|
SSHForwardingConnection(
|
|
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
|
|
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
|
|
|
|
private func maximumConnections(for host: Host) -> Int {
|
|
switch poolPolicy {
|
|
case .singleConnection: 1
|
|
case .hostConfigured:
|
|
min(connectionLimit, Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections))
|
|
}
|
|
}
|
|
|
|
/// The app supplies final connection limits; the transport has no commerce dependency.
|
|
public func updateConnectionLimit(_ maximum: Int) {
|
|
connectionLimit = Host.normalizedPortForwardingMaxConnections(maximum)
|
|
for context in connections.values {
|
|
if let host = hosts.host(context.hostID) { context.maximumConnections = maximumConnections(for: host) }
|
|
}
|
|
let previous = limitUpdateTask
|
|
limitUpdateTask = Task { [weak self] in
|
|
await previous?.value
|
|
guard let self else { return }
|
|
for context in Array(connections.values) {
|
|
await context.forwardingConnection?.updateMaximumConnections(context.maximumConnections)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func start(_ id: PortForwardingRule.ID) {
|
|
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
|
|
let host = hosts.host(rule.hostID), host.sshEnabled
|
|
else { return }
|
|
logger.info(
|
|
"forwarding.requested",
|
|
metadata: [
|
|
"forwarding_kind": RemoteLogging.forwardingKind(rule.kind),
|
|
"host_id": RemoteLogging.hostID(rule.hostID),
|
|
"operation_id": "\(UUID().uuidString.lowercased())", "stage": "requested",
|
|
])
|
|
let context: PortForwardingConnection
|
|
if let existing = connections[host.id], !activeIDs(existing).isEmpty {
|
|
context = existing
|
|
} else {
|
|
context = PortForwardingConnection(
|
|
host: host, route: hosts.route(for: host),
|
|
maximumConnections: maximumConnections(for: host),
|
|
reconnect: makeReconnectController())
|
|
connections[host.id] = context
|
|
}
|
|
generations[id] = UUID()
|
|
presentations[id] = context
|
|
states[id] = .preparing
|
|
if context.connected {
|
|
startRule(rule, in: context)
|
|
} else if context.connection.isRecovering {
|
|
states[id] = .reconnecting
|
|
} else if context.task == nil && context.authenticationPrompt == nil {
|
|
open(context)
|
|
} else {
|
|
states[id] =
|
|
context.authenticationPrompt != nil || context.connection.keyboardAuthentication.current != nil
|
|
? .awaitingAuthentication : context.connection.pendingHostKey != nil ? .awaitingTrust : .connecting
|
|
}
|
|
}
|
|
|
|
public func retry(_ id: PortForwardingRule.ID) { start(id) }
|
|
|
|
public func stop(_ id: PortForwardingRule.ID) {
|
|
generations[id] = nil
|
|
states[id] = .stopped
|
|
guard let context = presentations.removeValue(forKey: id) else { return }
|
|
let forwardingConnection = context.forwardingConnection
|
|
let previous = ruleTasks[id]
|
|
ruleTasks[id] = Task {
|
|
if let forwardingConnection { await forwardingConnection.stop(id) }
|
|
if let previous { await previous.value }
|
|
}
|
|
if activeIDs(context).isEmpty { retire(context) }
|
|
}
|
|
|
|
public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) }
|
|
public func stopAll() { stop(Array(presentations.keys)) }
|
|
public func remove(_ ids: [PortForwardingRule.ID]) {
|
|
stop(ids)
|
|
for id in ids { states[id] = nil }
|
|
}
|
|
|
|
/// Connection edits include jump hosts. Authentication's own explicit save may continue its opening.
|
|
public func hostConfigurationChanged(_ hostID: Host.ID) {
|
|
for context in Array(connections.values) where context.route.contains(where: { $0.id == hostID }) {
|
|
guard context !== savingAuthentication else { continue }
|
|
stop(activeIDs(context))
|
|
}
|
|
}
|
|
|
|
public func reconcileConfiguration() {
|
|
for context in Array(connections.values) {
|
|
guard
|
|
context.route.allSatisfy({ old in
|
|
hosts.host(old.id).map { old.hasSameConnectionRoute(as: $0) } == true
|
|
})
|
|
else {
|
|
stop(activeIDs(context))
|
|
continue
|
|
}
|
|
}
|
|
remove(presentations.keys.filter { hosts.portForwarding($0) == nil })
|
|
}
|
|
|
|
public func submitAuthentication(
|
|
for id: PortForwardingRule.ID, promptID: UUID,
|
|
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
|
|
) {
|
|
guard generations[id] != nil, let context = presentations[id],
|
|
let prompt = context.authenticationPrompt, prompt.id == promptID,
|
|
var host = hosts.host(prompt.host.id)
|
|
else { return }
|
|
let identity = identityID.flatMap(hosts.identity)
|
|
let name = ((prompt.isProxy ? nil : identity?.username) ?? username)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { return }
|
|
if prompt.isProxy {
|
|
let submission = SSHProxyAuthenticationSubmission(
|
|
username: name, password: password, identityID: identity?.id)
|
|
do {
|
|
if save {
|
|
savingAuthentication = context
|
|
defer { savingAuthentication = nil }
|
|
host = try hosts.saveProxyAuthentication(
|
|
submission, for: host, selectedIdentity: identity,
|
|
suppliedPassword: password)
|
|
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
|
|
}
|
|
context.proxySubmissions[host.id] = submission
|
|
context.setPrompt(nil)
|
|
context.journey.resume()
|
|
open(context)
|
|
} catch {
|
|
var failed = prompt
|
|
failed.failureMessage = error.localizedDescription
|
|
context.setPrompt(failed)
|
|
}
|
|
return
|
|
}
|
|
if prompt.requiresPassphrase {
|
|
do {
|
|
context.submissions[host.id] = try hosts.passphraseSubmission(
|
|
for: prompt, existing: context.submissions[host.id], passphrase: passphrase, save: save)
|
|
} catch {
|
|
var failed = prompt
|
|
failed.failureMessage = error.localizedDescription
|
|
context.setPrompt(failed)
|
|
return
|
|
}
|
|
} else {
|
|
let chosenKey = identity?.keyID ?? keyID
|
|
if save {
|
|
savingAuthentication = context
|
|
defer { savingAuthentication = nil }
|
|
do {
|
|
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
|
|
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
|
|
host.identityID = saved.id
|
|
guard hosts.saveHost(host) else {
|
|
throw TermindSSHError.library(hosts.storageError ?? String(localized: "Unable to Save"))
|
|
}
|
|
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
|
|
} catch {
|
|
var failed = prompt
|
|
failed.failureMessage = error.localizedDescription
|
|
context.setPrompt(failed)
|
|
return
|
|
}
|
|
}
|
|
context.submissions[host.id] = SSHAuthenticationSubmission(
|
|
username: name, password: password, passphrase: passphrase, keyID: chosenKey, identityID: identity?.id)
|
|
}
|
|
context.setPrompt(nil)
|
|
context.journey.resume()
|
|
open(context)
|
|
}
|
|
|
|
public func acceptHostKey(for id: UUID, requestID: UUID, observation: SSHHostKeyObservation, remember: Bool) {
|
|
guard generations[id] != nil, let context = presentations[id],
|
|
context.hostKeyRequestID == requestID,
|
|
context.connection.pendingHostKey?.observation == observation
|
|
else { return }
|
|
if remember {
|
|
context.connection.trustHostKey(observation, using: hosts)
|
|
} else {
|
|
_ = context.connection.useTemporaryHostKey(observation)
|
|
}
|
|
update(context, state: .connecting)
|
|
}
|
|
|
|
private func activeIDs(_ context: PortForwardingConnection) -> [UUID] {
|
|
generations.keys.filter { presentations[$0] === context }
|
|
}
|
|
|
|
private func isCurrent(_ context: PortForwardingConnection, attempt: UUID) -> Bool {
|
|
connections[context.hostID] === context && context.attemptID == attempt && !activeIDs(context).isEmpty
|
|
}
|
|
|
|
private func update(_ context: PortForwardingConnection, state: PortForwardingRuntimeState) {
|
|
for id in activeIDs(context) { states[id] = state }
|
|
}
|
|
|
|
private func open(_ context: PortForwardingConnection) {
|
|
guard let host = hosts.host(context.hostID), connections[host.id] === context,
|
|
!activeIDs(context).isEmpty, context.task == nil, !context.connected
|
|
else { return }
|
|
let silently = context.connection.isRecovering
|
|
let attempt = UUID()
|
|
context.attemptID = attempt
|
|
context.silentAttempt = silently
|
|
if !silently {
|
|
context.journey.begin(
|
|
host: host, route: hosts.route(for: host), purpose: .portForwarding,
|
|
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
|
|
update(context, state: .preparing)
|
|
if let proxyHost = context.proxySubmissions.firstHostRequiringProxyPassword(in: context.route) {
|
|
requestAuthentication(
|
|
context,
|
|
error: .proxyAuthentication(
|
|
hostID: proxyHost.id, serverChallenge: false,
|
|
message: String(localized: "The proxy password is unavailable.")))
|
|
context.attemptID = nil
|
|
return
|
|
}
|
|
if let passwordHost = context.submissions.firstHostRequiringPassword(in: context.route) {
|
|
requestAuthentication(
|
|
context,
|
|
error: .routeHost(
|
|
hostID: passwordHost.id, authentication: true,
|
|
serverChallenge: false, hostKey: false,
|
|
message: TermindSSHError.missingCredential.localizedDescription
|
|
)
|
|
)
|
|
context.attemptID = nil
|
|
return
|
|
}
|
|
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
|
|
in: context.route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
|
|
) {
|
|
requestAuthentication(
|
|
context,
|
|
error: .routeHost(
|
|
hostID: passphraseHost.id, authentication: true,
|
|
serverChallenge: false, hostKey: false,
|
|
message: TermindSSHError.keyPassphraseRequired.localizedDescription
|
|
)
|
|
)
|
|
context.attemptID = nil
|
|
return
|
|
}
|
|
}
|
|
let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets()
|
|
let attemptProxySubmissions = silently ? [:] : context.proxySubmissions.takeProxyAuthenticationSecrets()
|
|
context.maximumConnections = maximumConnections(for: host)
|
|
let forwardingConnection = makeConnection(
|
|
context.maximumConnections,
|
|
{ [weak self, weak context] id, generation, count in
|
|
Task { @MainActor in
|
|
guard let self, let context, self.isCurrent(context, attempt: attempt),
|
|
self.generations[id] == generation, self.presentations[id] === context
|
|
else { return }
|
|
context.connectionCounts[id] = count
|
|
let poolStatus = await context.forwardingConnection?.connectionPoolStatus() ?? .single
|
|
context.poolStatus = poolStatus
|
|
let running = PortForwardingRuntimeState.running(
|
|
activeConnections: count, sshConnections: poolStatus.connected,
|
|
targetSSHConnections: poolStatus.target)
|
|
if case .running = self.states[id], self.states[id] != running {
|
|
self.states[id] = running
|
|
}
|
|
}
|
|
},
|
|
{ [weak self, weak context] message in
|
|
Task { @MainActor in
|
|
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
|
|
self.recover(context, message: message)
|
|
}
|
|
})
|
|
context.forwardingConnection = forwardingConnection
|
|
let cleanup = cleanupTasks[context.hostID]
|
|
context.task = Task { [weak self, weak context] in
|
|
guard let self, let context else { return }
|
|
do {
|
|
await cleanup?.value
|
|
try Task.checkCancellation()
|
|
let configuration: SSHConnectionConfiguration
|
|
if silently {
|
|
guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential }
|
|
configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration
|
|
} else {
|
|
let preparation = try hosts.prepareSSHConnection(
|
|
for: host, submissions: attemptSubmissions,
|
|
proxySubmissions: attemptProxySubmissions,
|
|
temporaryHostKeys: context.connection.temporaryHostKeys)
|
|
configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
|
|
}
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
if silently {
|
|
try await forwardingConnection.connect(
|
|
SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
|
|
.enforcingInteractionPolicy(), progress: nil)
|
|
} else {
|
|
update(context, state: .connecting)
|
|
let request = SSHConnectionRequest(
|
|
configuration: configuration,
|
|
authorizeHostKey: { [weak self, weak context] challenge in
|
|
guard let self, let context else { throw CancellationError() }
|
|
return await self.authorize(challenge, in: context, attempt: attempt)
|
|
},
|
|
keyboardInteractive: { [weak self, weak context] challenge in
|
|
guard let self, let context else { throw CancellationError() }
|
|
return try await self.answer(challenge, in: context, attempt: attempt)
|
|
},
|
|
securityKeySigning: { [weak self, weak context] request in
|
|
guard let self, let context else { throw CancellationError() }
|
|
return try await self.sign(request, in: context, attempt: attempt)
|
|
})
|
|
try await forwardingConnection.connect(
|
|
request,
|
|
progress: { [weak self, weak context] event in
|
|
Task { @MainActor in
|
|
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
|
|
context.journey.receive(event)
|
|
}
|
|
})
|
|
}
|
|
try Task.checkCancellation()
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
context.task = nil
|
|
context.connected = true
|
|
if !silently {
|
|
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
|
|
}
|
|
if let snapshot = context.additionalSnapshot {
|
|
await forwardingConnection.updateMaximumConnections(context.maximumConnections)
|
|
await forwardingConnection.configureAdditionalConnections { [weak self] in
|
|
guard let self else { throw CancellationError() }
|
|
return try self.hosts.additionalConnectionRequest(from: snapshot)
|
|
}
|
|
}
|
|
context.submissions.removeAll()
|
|
context.proxySubmissions.removeAll()
|
|
context.connection.temporaryHostKeys.removeAll()
|
|
for id in activeIDs(context) {
|
|
if let rule = hosts.portForwarding(id) { startRule(rule, in: context) }
|
|
}
|
|
} catch {
|
|
await forwardingConnection.disconnect()
|
|
guard isCurrent(context, attempt: attempt) else { return }
|
|
context.task = nil
|
|
context.attemptID = nil
|
|
context.forwardingConnection = nil
|
|
if silently {
|
|
if Self.requiresManualRetry(error) {
|
|
fail(context, message: error.localizedDescription, presentJourney: false)
|
|
} else {
|
|
recover(context, message: error.localizedDescription)
|
|
}
|
|
} else if let ssh = error as? TermindSSHError,
|
|
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
|
|
{
|
|
requestAuthentication(context, error: ssh)
|
|
} else {
|
|
fail(context, message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func authorize(
|
|
_ challenge: SSHHostKeyChallenge, in context: PortForwardingConnection, attempt: UUID
|
|
) async -> SSHHostKeyDecision {
|
|
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
|
|
let requestID = UUID()
|
|
context.hostKeyRequestID = requestID
|
|
defer {
|
|
if context.hostKeyRequestID == requestID { context.hostKeyRequestID = nil }
|
|
}
|
|
update(context, state: .awaitingTrust)
|
|
let decision = await context.connection.authorizeHostKey(challenge)
|
|
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
|
|
update(context, state: .connecting)
|
|
return decision
|
|
}
|
|
|
|
private func sign(_ request: SecurityKeySigningRequest, in context: PortForwardingConnection, attempt: UUID)
|
|
async throws -> SecurityKeyAssertion
|
|
{
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
update(context, state: .awaitingAuthentication)
|
|
let assertion = try await context.connection.signWithSecurityKey(request)
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
update(context, state: .connecting)
|
|
return assertion
|
|
}
|
|
|
|
private func answer(
|
|
_ challenge: SSHKeyboardInteractiveChallenge, in context: PortForwardingConnection, attempt: UUID
|
|
) async throws -> [String] {
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
try Task.checkCancellation()
|
|
update(context, state: .awaitingAuthentication)
|
|
let answers = try await context.connection.answerKeyboardInteractive(challenge)
|
|
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
|
update(context, state: .connecting)
|
|
return answers
|
|
}
|
|
|
|
private func requestAuthentication(_ context: PortForwardingConnection, error: TermindSSHError) {
|
|
guard let host = hosts.host(error.routeHostID ?? context.hostID) else {
|
|
fail(context, message: error.localizedDescription)
|
|
return
|
|
}
|
|
if error.isProxyAuthentication, host.proxy != nil {
|
|
let staged = context.proxySubmissions.removeValue(forKey: host.id)
|
|
context.setPrompt(
|
|
SSHAuthenticationPromptResolver.proxy(
|
|
for: host, using: hosts, submission: staged,
|
|
failureMessage: error.localizedDescription,
|
|
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity))
|
|
context.journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription)
|
|
update(context, state: .awaitingAuthentication)
|
|
return
|
|
}
|
|
let staged = context.submissions[host.id]
|
|
let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host)
|
|
let keyID = staged?.keyID ?? identity?.keyID
|
|
let passphrase =
|
|
staged?.requiresPasswordReentry != true
|
|
&& keyID.flatMap(hosts.key)?.hasPassphrase == true
|
|
&& staged?.passphrase == nil
|
|
context.setPrompt(
|
|
SSHAuthenticationPromptResolver.host(
|
|
host, using: hosts, submission: staged,
|
|
failureMessage: error.localizedDescription,
|
|
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity,
|
|
requiresPassphrase: passphrase))
|
|
if error.isServerAuthenticationChallenge {
|
|
context.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
|
|
} else if passphrase {
|
|
context.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
|
|
} else {
|
|
context.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
|
|
}
|
|
update(context, state: .awaitingAuthentication)
|
|
}
|
|
|
|
private func startRule(_ rule: PortForwardingRule, in context: PortForwardingConnection) {
|
|
guard let token = generations[rule.id], let forwardingConnection = context.forwardingConnection,
|
|
let attempt = context.attemptID
|
|
else {
|
|
return
|
|
}
|
|
let silently = context.silentAttempt
|
|
context.connectionCounts[rule.id] = 0
|
|
states[rule.id] = silently ? .reconnecting : .starting
|
|
let previous = ruleTasks[rule.id]
|
|
ruleTasks[rule.id] = Task { [weak self] in
|
|
if let previous { await previous.value }
|
|
guard let self, generations[rule.id] == token, isCurrent(context, attempt: attempt) else { return }
|
|
do {
|
|
try await forwardingConnection.start(rule, generation: token)
|
|
guard generations[rule.id] == token, isCurrent(context, attempt: attempt) else {
|
|
await forwardingConnection.stop(rule.id)
|
|
return
|
|
}
|
|
let poolStatus = await forwardingConnection.connectionPoolStatus()
|
|
context.poolStatus = poolStatus
|
|
states[rule.id] = .running(
|
|
activeConnections: context.connectionCounts[rule.id] ?? 0,
|
|
sshConnections: poolStatus.connected, targetSSHConnections: poolStatus.target)
|
|
if !context.ready {
|
|
context.ready = true
|
|
context.connection.markConnected()
|
|
}
|
|
if !silently { context.journey.succeed() }
|
|
} catch {
|
|
guard generations[rule.id] == token, isCurrent(context, attempt: attempt) else { return }
|
|
// Transport cleanup can finish a pending remote listen before its failure callback arrives.
|
|
if error is CancellationError
|
|
|| (error as? TermindSSHError).map({
|
|
if case .disconnected = $0 { return true }
|
|
return false
|
|
}) == true
|
|
{
|
|
recover(context, message: error.localizedDescription)
|
|
return
|
|
}
|
|
generations[rule.id] = nil
|
|
states[rule.id] = .failed(error.localizedDescription)
|
|
if activeIDs(context).isEmpty {
|
|
retire(context)
|
|
if !silently { context.journey.fail(error.localizedDescription) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func requiresManualRetry(_ error: any Error) -> Bool {
|
|
if error is SSHInteractionRequired || error is CancellationError { return true }
|
|
guard let ssh = error as? TermindSSHError else { return false }
|
|
if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication || ssh.shouldRequestHostKey {
|
|
return true
|
|
}
|
|
switch ssh {
|
|
case .untrustedHost, .mismatchedHostKey, .hostKeyChanged: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
private func recover(_ context: PortForwardingConnection, message: String) {
|
|
guard context.connection.hasConnectedOnce, context.additionalSnapshot != nil else {
|
|
fail(context, message: message)
|
|
return
|
|
}
|
|
closeTransport(context)
|
|
context.journey.hide()
|
|
update(context, state: .reconnecting)
|
|
context.connection.scheduleRecovery(after: message) { [weak self, weak context] in
|
|
guard let self, let context, self.connections[context.hostID] === context,
|
|
context.connection.isRecovering
|
|
else { return }
|
|
self.open(context)
|
|
}
|
|
}
|
|
|
|
private func fail(_ context: PortForwardingConnection, message: String, presentJourney: Bool = true) {
|
|
for id in activeIDs(context) {
|
|
generations[id] = nil
|
|
states[id] = .failed(message)
|
|
}
|
|
retire(context)
|
|
if presentJourney { context.journey.fail(message) }
|
|
}
|
|
|
|
private func closeTransport(_ context: PortForwardingConnection) {
|
|
context.attemptID = nil
|
|
let opening = context.task
|
|
context.task?.cancel()
|
|
context.task = nil
|
|
context.connected = false
|
|
context.ready = false
|
|
context.connectionCounts.removeAll()
|
|
context.poolStatus = SSHForwardingPoolStatus(connected: 0, target: context.maximumConnections)
|
|
let forwardingConnection = context.forwardingConnection
|
|
context.forwardingConnection = nil
|
|
let previous = cleanupTasks[context.hostID]
|
|
cleanupTasks[context.hostID] = Task {
|
|
await previous?.value
|
|
await opening?.value
|
|
await forwardingConnection?.disconnect()
|
|
}
|
|
}
|
|
|
|
private func retire(_ context: PortForwardingConnection) {
|
|
closeTransport(context)
|
|
context.setPrompt(nil)
|
|
context.hostKeyRequestID = nil
|
|
context.submissions.removeAll()
|
|
context.proxySubmissions.removeAll()
|
|
context.additionalSnapshot = nil
|
|
context.connection.disconnect()
|
|
if connections[context.hostID] === context { connections[context.hostID] = nil }
|
|
}
|
|
}
|