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.
732 lines
33 KiB
Swift
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 }
|
|
}
|
|
}
|