Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift
T

370 lines
15 KiB
Swift

import Foundation
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 starting
case running(activeConnections: Int)
case reconnecting(attempt: Int, nextRetry: Date, message: String)
case failed(String)
public var isActive: Bool {
switch self {
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
.starting, .running, .reconnecting:
true
case .stopped, .failed: 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 .starting: String(localized: "Starting")
case .running(let count):
count == 0
? String(localized: "Running")
: count == 1
? String(localized: "Running · 1 connection")
: String(localized: "Running · \(count) connections")
case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)")
case .failed(let message): message
}
}
}
/// Keeps device-local enabled intent separate from process-local SSH resources.
/// Enabled rule IDs are not part of Vault or Settings sync.
@Observable
public final class PortForwardingController {
private let hosts: HostLibraryModel
private let defaults: UserDefaults?
private let enabledDefaultsKey: String
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
private var enabled: Set<PortForwardingRule.ID>
/// Enabled IDs created in this process before their rules have been saved.
/// Unlike stale IDs restored from defaults, these survive reconciliation until
/// their editor explicitly discards them or a save makes them runnable.
private var pending: Set<PortForwardingRule.ID> = []
private var desired: Set<PortForwardingRule.ID> = []
private var sessions: [Host.ID: SSHForwardingSession] = [:]
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
private var reconnectTasks: [Host.ID: Task<Void, Never>] = [:]
private var reconnectAttempts: [Host.ID: Int] = [:]
private var connectedAt: [Host.ID: Date] = [:]
private var ruleHostIDs: [PortForwardingRule.ID: Host.ID]
private var stoppingTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
private var stoppingGenerations: [PortForwardingRule.ID: UUID] = [:]
public init(
hosts: HostLibraryModel, defaults: UserDefaults? = nil,
enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs
) {
self.hosts = hosts
self.defaults = defaults
self.enabledDefaultsKey = enabledDefaultsKey
enabled = Set(
defaults?.stringArray(forKey: enabledDefaultsKey)?.compactMap(UUID.init(uuidString:)) ?? [])
ruleHostIDs = Dictionary(uniqueKeysWithValues: hosts.portForwardings.map { ($0.id, $0.hostID) })
}
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState {
states[id] ?? .stopped
}
public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { enabled.contains(id) }
public func setEnabled(_ value: Bool, for id: PortForwardingRule.ID) {
if value {
enabled.insert(id)
if let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil {
pending.remove(id)
} else {
pending.insert(id)
}
persistEnabled()
if !pending.contains(id) { activate(id) }
} else {
enabled.remove(id)
pending.remove(id)
persistEnabled()
suspend(id)
}
}
public func setEnabled(_ value: Bool, for ids: [PortForwardingRule.ID]) {
for id in ids { setEnabled(value, for: id) }
}
/// Restores all, or a changed subset, after launch, foregrounding, or a saved configuration replacement.
public func resumeEnabled(_ ids: [PortForwardingRule.ID]? = nil) {
var valid: Set<PortForwardingRule.ID> = []
for id in enabled {
guard let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil else { continue }
ruleHostIDs[rule.id] = rule.hostID
valid.insert(id)
}
pending.subtract(valid)
let reconciled = valid.union(pending.intersection(enabled))
if reconciled != enabled {
enabled = reconciled
persistEnabled()
}
let targets = ids.map { valid.intersection($0) } ?? valid
targets.forEach(activate)
}
/// Stops live resources while preserving the device-local enabled intent.
public func suspend(_ id: PortForwardingRule.ID) {
let hostID = hosts.portForwarding(id)?.hostID ?? ruleHostIDs[id]
let session = hostID.flatMap { sessions[$0] }
desired.remove(id)
states[id] = .stopped
let previous = stoppingTasks[id]
let generation = UUID()
stoppingGenerations[id] = generation
let task = Task {
if let previous { await previous.value }
if let session { await session.stop(id) }
}
stoppingTasks[id] = task
Task { [weak self] in
await task.value
guard let self, self.stoppingGenerations[id] == generation else { return }
self.stoppingTasks[id] = nil
self.stoppingGenerations[id] = nil
if let hostID { await self.disconnectHostIfIdle(hostID) }
}
}
public func suspend(_ ids: [PortForwardingRule.ID]) { ids.forEach(suspend) }
/// Forgets enabled intent only for rules that were enabled before their first save.
public func discardPending(_ ids: [PortForwardingRule.ID]) {
let discarded = pending.intersection(ids)
guard !discarded.isEmpty else { return }
pending.subtract(discarded)
enabled.subtract(discarded)
for id in discarded { states[id] = nil }
persistEnabled()
}
public func remove(_ ids: [PortForwardingRule.ID]) {
for id in ids {
enabled.remove(id)
pending.remove(id)
suspend(id)
ruleHostIDs[id] = nil
}
persistEnabled()
}
public func suspendAll() {
let all = Array(enabled.union(desired))
desired.removeAll()
for id in all { states[id] = .stopped }
let active = sessions
sessions.removeAll()
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
for task in reconnectTasks.values { task.cancel() }
reconnectTasks.removeAll()
reconnectAttempts.removeAll()
connectedAt.removeAll()
for task in stoppingTasks.values { task.cancel() }
stoppingTasks.removeAll()
stoppingGenerations.removeAll()
Task { for session in active.values { await session.disconnect() } }
}
private func activate(_ id: PortForwardingRule.ID) {
guard enabled.contains(id), !desired.contains(id) else { return }
if let rule = hosts.portForwarding(id) { ruleHostIDs[id] = rule.hostID }
desired.insert(id)
states[id] = .preparing
let stopping = stoppingTasks[id]
Task { [weak self] in
if let stopping { await stopping.value }
guard let self, self.desired.contains(id), self.enabled.contains(id),
let rule = self.hosts.portForwarding(id)
else { return }
await self.start(rule)
}
}
private func persistEnabled() {
defaults?.set(enabled.map(\.uuidString).sorted(), forKey: enabledDefaultsKey)
}
private func start(_ rule: PortForwardingRule) async {
do {
guard rule.isValid else {
throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete."))
}
guard let host = hosts.host(rule.hostID) else {
throw TermindSSHError.route(String(localized: "The forwarding host is unavailable."))
}
let session: SSHForwardingSession
if let existing = sessions[host.id] {
session = existing
} else {
let created = makeSession(for: host.id)
sessions[host.id] = created
session = created
states[rule.id] = .connecting
let task = Task {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await created.connect(configuration)
}
connectionTasks[host.id] = task
do { try await task.value } catch {
sessions[host.id] = nil
connectionTasks[host.id] = nil
throw error
}
connectionTasks[host.id] = nil
connectedAt[host.id] = Date()
}
if let task = connectionTasks[host.id] { try await task.value }
guard desired.contains(rule.id) else { return }
states[rule.id] = .starting
try await session.start(rule)
guard desired.contains(rule.id) else {
await session.stop(rule.id)
return
}
states[rule.id] = .running(activeConnections: 0)
} catch {
guard desired.contains(rule.id) else {
states[rule.id] = .stopped
await disconnectHostIfIdle(rule.hostID)
return
}
if let sshError = error as? TermindSSHError,
sshError.isRetryableForwardingFailure
{
if let session = sessions.removeValue(forKey: rule.hostID) {
await session.disconnect()
}
if reconnectTasks[rule.hostID] == nil {
scheduleReconnect(
rule.hostID, attempt: 1, message: error.localizedDescription
)
}
} else {
desired.remove(rule.id)
states[rule.id] = .failed(error.localizedDescription)
await disconnectHostIfIdle(rule.hostID)
}
}
}
private func disconnectHostIfIdle(_ hostID: Host.ID) async {
let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID }
guard !hasActiveRule else { return }
let session = sessions.removeValue(forKey: hostID)
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
reconnectTasks[hostID]?.cancel()
reconnectTasks[hostID] = nil
reconnectAttempts[hostID] = nil
connectedAt[hostID] = nil
if let session { await session.disconnect() }
}
private func makeSession(for hostID: Host.ID) -> SSHForwardingSession {
SSHForwardingSession(
connectionHandler: { [weak self] id, count in
Task { @MainActor in
guard let self, self.desired.contains(id) else { return }
self.states[id] = .running(activeConnections: count)
}
},
failureHandler: { [weak self] message in
Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) }
}
)
}
private func handleConnectionFailure(_ hostID: Host.ID, message: String) {
guard reconnectTasks[hostID] == nil else { return }
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty else { return }
sessions[hostID] = nil
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true
let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1
scheduleReconnect(hostID, attempt: attempt, message: message)
}
private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) {
let delays: [TimeInterval] = [1, 2, 5, 10, 30]
let delay = delays[min(attempt - 1, delays.count - 1)]
let nextRetry = Date().addingTimeInterval(delay)
reconnectAttempts[hostID] = attempt
for id in desiredRuleIDs(for: hostID) {
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
}
reconnectTasks[hostID] = Task { [weak self] in
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
guard let self else { return }
await self.reconnect(hostID, attempt: attempt)
}
}
private func reconnect(_ hostID: Host.ID, attempt: Int) async {
reconnectTasks[hostID] = nil
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty, let host = hosts.host(hostID) else { return }
let session = makeSession(for: hostID)
do {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await session.connect(configuration)
guard !desiredRuleIDs(for: hostID).isEmpty else {
await session.disconnect()
return
}
sessions[hostID] = session
connectedAt[hostID] = Date()
for id in desiredRuleIDs(for: hostID) {
guard let rule = hosts.portForwarding(id) else { continue }
states[id] = .starting
try await session.start(rule)
states[id] = .running(activeConnections: 0)
}
} catch {
sessions[hostID] = nil
await session.disconnect()
if let sshError = error as? TermindSSHError,
!sshError.isRetryableForwardingFailure
{
for id in desiredRuleIDs(for: hostID) {
desired.remove(id)
states[id] = .failed(error.localizedDescription)
}
reconnectAttempts[hostID] = nil
} else {
scheduleReconnect(
hostID, attempt: attempt + 1, message: error.localizedDescription
)
}
}
}
private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] {
desired.filter { hosts.portForwarding($0)?.hostID == hostID }
}
}