Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

732 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()
try await hosts.resolveHostAccess(host)
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 }
}
}