317 lines
13 KiB
Swift
317 lines
13 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CSSH2
|
|
import Darwin
|
|
import Foundation
|
|
import Logging
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
/// One authenticated SSH connection with any number of non-PTY exec Channels.
|
|
///
|
|
/// libssh2 itself is never entered concurrently. Every call is made by `queue`,
|
|
/// while command state machines take bounded turns from `ready`. This gives
|
|
/// commands Session-level concurrency without violating libssh2's single-owner
|
|
/// requirement or letting a quiet/blocked command pin its peers.
|
|
nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
|
private let queue: DispatchQueue
|
|
private let queueKey = DispatchSpecificKey<Void>()
|
|
private let logger: Logger
|
|
private let maxSessionsPolicy: SSHMaxSessionsPolicy
|
|
private var connection: SSHAuthenticatedConnection?
|
|
private var buffer: SSHConnectionBuffer?
|
|
private var channelBudget: SSHChannelBudget?
|
|
private var socketEvents: SSHSocketEvents?
|
|
private var timer: (any DispatchSourceTimer)?
|
|
private var hostID: UUID?
|
|
private var charset: RemoteCharset = .utf8
|
|
private var accessCheck: HostOperationAccessCheck?
|
|
private var scheduled = false
|
|
private var generation = 0
|
|
private var controlDirections: Int32 = 0
|
|
private var keepaliveDue = true
|
|
private var nextKeepalive = DispatchTime.now()
|
|
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
|
|
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
|
|
private var connectionFailure: (@Sendable (String) -> Void)?
|
|
private var didReportConnectionFailure = false
|
|
|
|
init(
|
|
logger: Logger, maxSessionsPolicy: SSHMaxSessionsPolicy,
|
|
queue: DispatchQueue = DispatchQueue(label: "com.akinokaede.termind.ssh.connection")
|
|
) {
|
|
self.queue = queue
|
|
self.logger = logger
|
|
self.maxSessionsPolicy = maxSessionsPolicy
|
|
queue.setSpecific(key: queueKey, value: ())
|
|
execChannelOwner = SSHMultiplexedExecChannelOwner(
|
|
queue: queue, logger: logger,
|
|
context: { [weak self] in
|
|
guard let self, let connection = self.connection, let buffer = self.buffer,
|
|
let channelBudget = self.channelBudget
|
|
else { return nil }
|
|
return .init(
|
|
connection: connection, buffer: buffer, channelBudget: channelBudget,
|
|
charset: self.charset, hostID: self.hostID)
|
|
},
|
|
schedulePump: { [weak self] in self?.schedulePump() },
|
|
connectionFailed: { [weak self] error in
|
|
guard let self, let connection = self.connection else { return }
|
|
self.failConnection(connection.libssh2Session, error: error)
|
|
})
|
|
sftpChannelOwner = SSHMultiplexedSFTPChannelOwner(
|
|
queue: queue,
|
|
context: { [weak self] in
|
|
guard let self, let connection = self.connection,
|
|
let buffer = self.buffer,
|
|
let channelBudget = self.channelBudget
|
|
else { return nil }
|
|
return .init(
|
|
connection: connection, buffer: buffer,
|
|
channelBudget: channelBudget, charset: self.charset, accessCheck: self.accessCheck)
|
|
},
|
|
updateEvents: { [weak self] session, buffer in
|
|
self?.updateSharedConnectionEvents(session: session, buffer: buffer)
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
// A queued callback can release the last reference to this worker.
|
|
// Cleanup must stay serialized without synchronously reentering its queue.
|
|
if DispatchQueue.getSpecific(key: queueKey) != nil {
|
|
cleanup(error: CancellationError())
|
|
} else {
|
|
queue.sync { cleanup(error: CancellationError()) }
|
|
}
|
|
}
|
|
|
|
func connect(
|
|
using request: SSHConnectionRequest,
|
|
progress: SSHRouteProgressHandler? = nil,
|
|
connectionFailure: (@Sendable (String) -> Void)? = nil
|
|
) async throws {
|
|
let operationID = UUID().uuidString.lowercased()
|
|
let hostID = request.configuration.hostID
|
|
logger.info(
|
|
"ssh.multiplexed.connect.started",
|
|
metadata: [
|
|
"host_id": RemoteLogging.hostID(hostID), "operation_id": "\(operationID)",
|
|
"protocol": "ssh", "stage": "connecting",
|
|
])
|
|
await disconnect()
|
|
do {
|
|
let opened = try await SSHConnectionOpener.openAuthenticatedConnection(
|
|
using: request, timeout: 20, progress: progress, logger: logger)
|
|
let channelBudget = maxSessionsPolicy.makeBudget()
|
|
try await queue.perform { [self] in
|
|
try Task.checkCancellation()
|
|
self.connection = opened
|
|
self.hostID = hostID
|
|
self.charset = request.configuration.charset
|
|
self.accessCheck = request.configuration.accessCheck
|
|
self.buffer = opened.installBuffer()
|
|
if let provider = request.agentForwarding {
|
|
self.buffer?.agentChannels = SSHAgentChannels(provider: provider, queue: self.queue) {
|
|
[weak self] in self?.wakeAll()
|
|
}
|
|
libssh2_session_callback_set2(
|
|
opened.libssh2Session, LIBSSH2_CALLBACK_AUTHAGENT,
|
|
unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self))
|
|
}
|
|
self.channelBudget = channelBudget
|
|
self.connectionFailure = connectionFailure
|
|
self.didReportConnectionFailure = false
|
|
libssh2_session_set_blocking(opened.libssh2Session, 0)
|
|
libssh2_keepalive_config(opened.libssh2Session, 0, 30)
|
|
self.startEvents(socket: opened.byteTransport.socket)
|
|
}
|
|
try Task.checkCancellation()
|
|
logger.info(
|
|
"ssh.multiplexed.connect.completed",
|
|
metadata: [
|
|
"host_id": RemoteLogging.hostID(hostID), "operation_id": "\(operationID)",
|
|
"outcome": "success", "protocol": "ssh", "stage": "connected",
|
|
])
|
|
} catch {
|
|
await disconnect()
|
|
logger.error(
|
|
"ssh.multiplexed.connect.failed",
|
|
metadata: [
|
|
"error_code": RemoteLogging.errorCode(error), "host_id": RemoteLogging.hostID(hostID),
|
|
"operation_id": "\(operationID)", "outcome": "failure", "protocol": "ssh",
|
|
"stage": "connecting",
|
|
])
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func execute(
|
|
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
|
|
outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
try await queue.perform { [self] in try accessCheck?() }
|
|
return try await execChannelOwner.execute(
|
|
command, workingDirectory: workingDirectory, stdin: stdin,
|
|
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput)
|
|
}
|
|
|
|
func openSFTPChannel() async throws -> any SFTPChannelProtocol {
|
|
try await sftpChannelOwner.openChannel()
|
|
}
|
|
|
|
func executeSession(
|
|
_ command: String, session: SSHExecSession, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
try await queue.perform { [self] in try accessCheck?() }
|
|
return try await execChannelOwner.execute(
|
|
command, workingDirectory: nil, stdin: nil, timeout: 0,
|
|
outputLimit: 256 * 1_024, cancellation: session.cancellation,
|
|
onOutput: onOutput, session: session)
|
|
}
|
|
|
|
private func updateSharedConnectionEvents(session: OpaquePointer, buffer: SSHConnectionBuffer) {
|
|
let directions = libssh2_session_block_directions(session)
|
|
socketEvents?.update(
|
|
read: !buffer.isBackpressured,
|
|
write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0)
|
|
}
|
|
|
|
func disconnect() async {
|
|
await withCheckedContinuation { continuation in
|
|
queue.async { [self] in
|
|
cleanup(error: CancellationError())
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startEvents(socket: Int32) {
|
|
generation += 1
|
|
let token = generation
|
|
socketEvents = SSHSocketEvents(socket: socket, queue: queue) { [weak self] in
|
|
guard let self, self.generation == token else { return }
|
|
self.wakeAll()
|
|
}
|
|
socketEvents?.update(read: false, write: false)
|
|
let source = DispatchSource.makeTimerSource(queue: queue)
|
|
source.schedule(deadline: .now() + .milliseconds(100), repeating: .milliseconds(100))
|
|
source.setEventHandler { [weak self] in
|
|
guard let self, self.generation == token else { return }
|
|
if DispatchTime.now() >= self.nextKeepalive { self.keepaliveDue = true }
|
|
self.wakeAll()
|
|
}
|
|
timer = source
|
|
source.resume()
|
|
keepaliveDue = true
|
|
}
|
|
|
|
private func wakeAll() {
|
|
execChannelOwner.wakeAllOnQueue()
|
|
schedulePump()
|
|
}
|
|
|
|
private func schedulePump() {
|
|
guard connection != nil, !scheduled else { return }
|
|
scheduled = true
|
|
let token = generation
|
|
queue.async { [weak self] in
|
|
guard let self, self.generation == token else { return }
|
|
self.scheduled = false
|
|
self.pump()
|
|
}
|
|
}
|
|
|
|
private func pump() {
|
|
guard let connection, let buffer else { return }
|
|
let session = connection.libssh2Session
|
|
let deadline = DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds
|
|
buffer.beginPass(deadline: deadline)
|
|
do { try buffer.flush(socket: connection.byteTransport.socket) } catch {
|
|
failConnection(session)
|
|
return
|
|
}
|
|
if buffer.isBackpressured {
|
|
socketEvents?.update(read: false, write: true)
|
|
return
|
|
}
|
|
controlDirections = 0
|
|
var progressed =
|
|
buffer.agentChannels?.advance(
|
|
deadline: min(
|
|
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2),
|
|
permitsIO: { !buffer.isBackpressured }) ?? false
|
|
if execChannelOwner.advanceOnQueue(deadline: deadline) { progressed = true }
|
|
if buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) == true {
|
|
progressed = true
|
|
}
|
|
if keepaliveDue {
|
|
var next: Int32 = 0
|
|
let result = libssh2_keepalive_send(session, &next)
|
|
if result == LIBSSH2_ERROR_EAGAIN {
|
|
blocked(session)
|
|
} else if result < 0 {
|
|
failConnection(session)
|
|
return
|
|
} else {
|
|
keepaliveDue = false
|
|
nextKeepalive = .now() + .seconds(max(1, Int(next)))
|
|
progressed = true
|
|
}
|
|
}
|
|
do { try buffer.flush(socket: connection.byteTransport.socket) } catch {
|
|
failConnection(session)
|
|
return
|
|
}
|
|
var byte: UInt8 = 0
|
|
let available = Darwin.recv(connection.byteTransport.socket, &byte, 1, MSG_PEEK | MSG_DONTWAIT)
|
|
if available == 0 || (available < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
|
|
failConnection(session)
|
|
return
|
|
}
|
|
let directions = controlDirections | execChannelOwner.controlDirections
|
|
socketEvents?.update(
|
|
read: !execChannelOwner.isEmptyOnQueue && !buffer.isBackpressured,
|
|
write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0)
|
|
if progressed || buffer.exhaustedReadBudget { wakeAll() }
|
|
}
|
|
|
|
private func blocked(_ session: OpaquePointer) {
|
|
controlDirections |= libssh2_session_block_directions(session)
|
|
}
|
|
|
|
private func failConnection(_ session: OpaquePointer, error: (any Error)? = nil) {
|
|
let failure = error ?? SSHConnectionOpener.connectionError(session, prefix: "The SSH connection closed")
|
|
if !didReportConnectionFailure {
|
|
didReportConnectionFailure = true
|
|
connectionFailure?(failure.localizedDescription)
|
|
}
|
|
cleanup(error: failure)
|
|
}
|
|
|
|
private func cleanup(error: any Error) {
|
|
buffer?.agentChannels?.shutdown()
|
|
generation += 1
|
|
scheduled = false
|
|
timer?.cancel()
|
|
timer = nil
|
|
execChannelOwner.closeAllOnQueue(error: error)
|
|
sftpChannelOwner.closeAllOnQueue()
|
|
channelBudget?.close()
|
|
channelBudget = nil
|
|
let connection = connection
|
|
self.connection = nil
|
|
buffer = nil
|
|
connectionFailure = nil
|
|
didReportConnectionFailure = false
|
|
hostID = nil
|
|
if let events = socketEvents {
|
|
events.cancel { connection?.close() }
|
|
socketEvents = nil
|
|
} else {
|
|
connection?.close()
|
|
}
|
|
}
|
|
|
|
}
|