// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Darwin import Foundation import Logging import TermindApplication import TermindDomain /// A host-scoped group of physical SSH connections. Only local and dynamic rules fan out. actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { typealias ConnectionFactory = @Sendable ( UUID, @escaping SSHForwardingConnection.ConnectionHandler, @escaping SSHForwardingConnection.FailureHandler ) -> SSHForwardingConnection private final class Slot: @unchecked Sendable { let id: UUID let connection: SSHForwardingConnection var rules: Set = [] init(id: UUID, connection: SSHForwardingConnection) { self.id = id self.connection = connection } } private struct RuleRegistration: Sendable { let rule: PortForwardingRule let generation: UUID } private var maximumConnections: Int private let connectionHandler: SSHForwardingConnection.ConnectionHandler private let failureHandler: SSHForwardingConnection.FailureHandler private var accessCheck: HostOperationAccessCheck? private let makeConnection: ConnectionFactory private let listeners: SSHForwardingLocalListeners private var slots: [UUID: Slot] = [:] private var primaryID: UUID? private var registrations: [PortForwardingRule.ID: RuleRegistration] = [:] private var counts: [UUID: [PortForwardingRule.ID: Int]] = [:] private var additionalRequest: SSHAdditionalConnectionRequestResolver? private var opening: [UUID: Slot] = [:] private var installing: [UUID: Slot] = [:] private var openingTasks: [UUID: Task] = [:] private var draining: Set = [] private var assignments: [UUID: Int] = [:] private var checkingIdle: Set = [] private var recheckIdle: Set = [] private var retryTask: Task? private var stabilityTasks: [UUID: Task] = [:] private var retryAttempt = 0 private var roundRobin = 0 private var disconnecting = false init( maximumConnections: Int, connectionHandler: @escaping SSHForwardingConnection.ConnectionHandler, failureHandler: @escaping SSHForwardingConnection.FailureHandler, logger: Logger = ApplicationLogger.disabled("termind.port-forwarding-pool"), makeConnection: ConnectionFactory? = nil ) { self.maximumConnections = Host.normalizedPortForwardingMaxConnections(maximumConnections) self.connectionHandler = connectionHandler self.failureHandler = failureHandler self.makeConnection = makeConnection ?? { _, connectionHandler, failureHandler in SSHForwardingConnection( connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger) } listeners = SSHForwardingLocalListeners { _, _, descriptor in Darwin.close(descriptor) } listeners.setHandler { [weak self] ruleID, generation, descriptor in guard let self else { Darwin.close(descriptor) return } Task { await self.assign(descriptor, ruleID: ruleID, generation: generation) } } } func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws { await disconnect() accessCheck = request.configuration.accessCheck disconnecting = false let id = UUID() let slot = makeSlot(id: id) slots[id] = slot primaryID = id do { try await slot.connection.connect(request, progress: progress) } catch { slots[id] = nil primaryID = nil throw error } } func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async { additionalRequest = resolver fillPool() } func connectionPoolStatus() async -> SSHForwardingPoolStatus { SSHForwardingPoolStatus(connected: slots.count, target: desiredConnections) } func updateMaximumConnections(_ maximum: Int) async { maximumConnections = Host.normalizedPortForwardingMaxConnections(maximum) rebalanceSlots() // Pending auxiliaries have no accepted flows and can be cancelled immediately. let pending: [Slot] if slots.count + opening.count + installing.count > desiredConnections { pending = Array(opening.values) + Array(installing.values) opening.removeAll() installing.removeAll() for task in openingTasks.values { task.cancel() } openingTasks.removeAll() } else { pending = [] } retryTask?.cancel() retryTask = nil publishPoolCounts() for slot in pending { await slot.connection.disconnect() } for id in Array(draining) { await retireIfIdle(id) } fillPool() } func flowDistribution(for ruleID: PortForwardingRule.ID) async -> [Int] { slots.keys.sorted { $0.uuidString < $1.uuidString }.map { counts[$0]?[ruleID] ?? 0 } } func start(_ rule: PortForwardingRule, generation: UUID) async throws { try accessCheck?() guard let primary = primarySlot else { throw TermindSSHError.disconnected } registrations[rule.id] = RuleRegistration(rule: rule, generation: generation) do { if rule.kind == .remote { try await primary.connection.start(rule, generation: generation) primary.rules.insert(rule.id) } else { // Draining slots may be retained again if the limit increases before their flows finish. for slot in slots.values { try await slot.connection.registerShared(rule, generation: generation) slot.rules.insert(rule.id) } try await listeners.start(rule, generation: generation) fillPool() } } catch { registrations[rule.id] = nil for slot in slots.values where slot.rules.remove(rule.id) != nil { await slot.connection.stop(rule.id) } await listeners.stop(rule.id) throw error } } func stop(_ id: PortForwardingRule.ID) async { let registration = registrations.removeValue(forKey: id) if registration?.rule.kind != .remote { await listeners.stop(id) } for slot in slots.values where slot.rules.remove(id) != nil { await slot.connection.stop(id) } for slotID in counts.keys { counts[slotID]?[id] = nil } report(id, generation: registration?.generation) if !needsPool { await retireAuxiliaries() } } func disconnect() async { disconnecting = true retryTask?.cancel() retryTask = nil for task in stabilityTasks.values { task.cancel() } stabilityTasks.removeAll() for task in openingTasks.values { task.cancel() } openingTasks.removeAll() draining.removeAll() assignments.removeAll() checkingIdle.removeAll() recheckIdle.removeAll() let pending = Array(opening.values) + Array(installing.values) opening.removeAll() installing.removeAll() await listeners.disconnect() let active = Array(slots.values) slots.removeAll() primaryID = nil registrations.removeAll() counts.removeAll() additionalRequest = nil await withTaskGroup(of: Void.self) { group in for slot in active + pending { group.addTask { await slot.connection.disconnect() } } } } private var primarySlot: Slot? { primaryID.flatMap { slots[$0] } } private var needsPool: Bool { registrations.values.contains { $0.rule.kind != .remote } } private var desiredConnections: Int { needsPool ? maximumConnections : 1 } private func rebalanceSlots() { let auxiliaries = slots.keys.filter { $0 != primaryID }.sorted { $0.uuidString < $1.uuidString } let retained = Set(auxiliaries.prefix(max(0, desiredConnections - 1))) draining = Set(auxiliaries).subtracting(retained) } private func retireIfIdle(_ id: UUID) async { guard draining.contains(id), id != primaryID, assignments[id, default: 0] == 0, let slot = slots[id] else { return } guard checkingIdle.insert(id).inserted else { recheckIdle.insert(id) return } let busy = await slot.connection.hasActiveFlows() checkingIdle.remove(id) let needsRecheck = recheckIdle.remove(id) != nil if busy { if needsRecheck { await retireIfIdle(id) } return } // The worker query is authoritative: count callbacks may still be in flight. guard draining.contains(id), id != primaryID, slots[id] === slot, assignments[id, default: 0] == 0 else { return } slots[id] = nil counts[id] = nil draining.remove(id) stabilityTasks.removeValue(forKey: id)?.cancel() publishPoolCounts() await slot.connection.disconnect() fillPool() } private func makeSlot(id: UUID) -> Slot { let connection = makeConnection( id, { [weak self] ruleID, generation, count in Task { await self?.receivedCount(count, ruleID: ruleID, generation: generation, slotID: id) } }, { [weak self] message in Task { await self?.failed(slotID: id, message: message) } }) return Slot(id: id, connection: connection) } private func fillPool() { guard !disconnecting, needsPool, additionalRequest != nil else { return } let missing = desiredConnections - slots.count - opening.count - installing.count guard missing > 0 else { return } for _ in 0.. 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 nonisolated final class SSHForwardingLocalListeners: @unchecked Sendable { typealias Handler = @Sendable (PortForwardingRule.ID, UUID, Int32) -> Void private final class Entry: @unchecked Sendable { let generation: UUID let socket: Int32 var events: SSHSocketEvents? init(generation: UUID, socket: Int32) { self.generation = generation self.socket = socket } } private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.forwarding-listeners") private var entries: [PortForwardingRule.ID: Entry] = [:] private var handler: Handler init(handler: @escaping Handler) { self.handler = handler } func setHandler(_ handler: @escaping Handler) { queue.sync { self.handler = handler } } func start(_ rule: PortForwardingRule, generation: UUID) async throws { try await withCheckedThrowingContinuation { continuation in queue.async { [self] in do { if let current = entries[rule.id] { guard current.generation == generation else { throw CancellationError() } continuation.resume() return } let socket = try makeListener(host: rule.bindHost, port: rule.bindPort) let entry = Entry(generation: generation, socket: socket) entry.events = SSHSocketEvents(socket: socket, queue: queue) { [weak self, weak entry] in guard let self, let entry else { return } self.accept(ruleID: rule.id, entry: entry) } entries[rule.id] = entry entry.events?.update(read: true, write: false) continuation.resume() } catch { continuation.resume(throwing: error) } } } } func stop(_ id: PortForwardingRule.ID) async { await withCheckedContinuation { continuation in queue.async { [self] in guard let entry = entries.removeValue(forKey: id) else { continuation.resume() return } if let events = entry.events { events.cancel { Darwin.close(entry.socket) continuation.resume() } } else { Darwin.close(entry.socket) continuation.resume() } } } } func disconnect() async { let ids = await withCheckedContinuation { continuation in queue.async { continuation.resume(returning: Array(self.entries.keys)) } } for id in ids { await stop(id) } } private func accept(ruleID: UUID, entry: Entry) { guard entries[ruleID] === entry else { return } for _ in 0..<32 { let descriptor = Darwin.accept(entry.socket, nil, nil) guard descriptor >= 0 else { break } handler(ruleID, entry.generation, descriptor) } } private func makeListener(host: String, port: Int) throws -> Int32 { var hints = addrinfo() hints.ai_family = AF_UNSPEC hints.ai_socktype = SOCK_STREAM hints.ai_flags = AI_PASSIVE var result: UnsafeMutablePointer? let resolved = getaddrinfo(host, String(port), &hints, &result) guard resolved == 0, let first = result else { throw TermindSSHError.socket(String(cString: gai_strerror(resolved))) } defer { freeaddrinfo(first) } var cursor: UnsafeMutablePointer? = first while let info = cursor?.pointee { let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) if candidate >= 0 { var yes: Int32 = 1 setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) if setsockopt(candidate, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) == 0, Darwin.bind(candidate, info.ai_addr, info.ai_addrlen) == 0, Darwin.listen(candidate, 128) == 0 { let flags = fcntl(candidate, F_GETFL, 0) if flags >= 0 { _ = fcntl(candidate, F_SETFL, flags | O_NONBLOCK) } return candidate } Darwin.close(candidate) } cursor = info.ai_next } throw TermindSSHError.socket(String(cString: strerror(errno))) } }