185 lines
7.5 KiB
Swift
185 lines
7.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import Logging
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
public nonisolated struct SSHExecResult: Hashable, Sendable {
|
|
public var stdout: String
|
|
public var stderr: String
|
|
public var exitCode: Int32
|
|
public var signal: String?
|
|
public var stdoutTruncated: Bool
|
|
public var stderrTruncated: Bool
|
|
|
|
public init(
|
|
stdout: String, stderr: String, exitCode: Int32, signal: String? = nil,
|
|
stdoutTruncated: Bool, stderrTruncated: Bool
|
|
) {
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.exitCode = exitCode
|
|
self.signal = signal
|
|
self.stdoutTruncated = stdoutTruncated
|
|
self.stderrTruncated = stderrTruncated
|
|
}
|
|
}
|
|
|
|
public nonisolated enum SSHCommandInput: Sendable {
|
|
/// Byte-transparent input. Charset conversion is intentionally bypassed.
|
|
case plain(Data)
|
|
/// Text converted to the connected Host's Charset. Unrepresentable UTF-8
|
|
/// bytes remain in place so command input is never discarded.
|
|
case text(String)
|
|
/// UTF-8 secret text converted with the same byte-preserving fallback,
|
|
/// without materializing a Swift String.
|
|
case secret(AgentSecret)
|
|
|
|
func append(to queue: SSHByteQueue, charset: RemoteCharset = .utf8, appendsNewline: Bool = true) throws {
|
|
switch self {
|
|
case .plain(var bytes):
|
|
if appendsNewline, !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) }
|
|
queue.append(bytes)
|
|
case .text(let value):
|
|
var bytes = try RemoteCharsetStreamConverter.encode(value, charset: charset)
|
|
defer { bytes.resetBytes(in: bytes.indices) }
|
|
if appendsNewline, !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) }
|
|
queue.append(bytes)
|
|
case .secret(let secret):
|
|
defer { secret.clear() }
|
|
try secret.withUnsafeBytes { bytes in
|
|
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding)
|
|
var source = Data(bytes)
|
|
defer { source.resetBytes(in: source.indices) }
|
|
var converted = try converter.convert(source).data
|
|
defer { converted.resetBytes(in: converted.indices) }
|
|
converted.append(try converter.finish().data)
|
|
queue.append(converted)
|
|
if appendsNewline, !converted.isEmpty, converted.last != 0x0A {
|
|
queue.append(contentsOf: [0x0A])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The one failure that is safe to retry on a replacement connection. It is
|
|
/// emitted only before the remote process has started.
|
|
public nonisolated enum SSHCommandExecutionError: LocalizedError, Sendable {
|
|
case connectionUnavailable(any Error)
|
|
case channelUnavailable(any Error)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .connectionUnavailable(let underlying): underlying.localizedDescription
|
|
case .channelUnavailable(let underlying): underlying.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A long-lived authenticated SSH connection that multiplexes non-PTY exec and
|
|
/// SFTP subsystem Channels through one fair libssh2 owner. Every command uses
|
|
/// a private exec Channel, so shell state and working directories never leak.
|
|
public nonisolated protocol SSHConnectionProtocol: AnyObject, Sendable {
|
|
func connect(using configuration: SSHConnectionConfiguration) async throws
|
|
func connect(using request: SSHConnectionRequest) async throws
|
|
func execute(
|
|
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
|
|
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult
|
|
func openSFTPChannel() async throws -> any SFTPChannelProtocol
|
|
func executeSession(
|
|
_ command: String, session: SSHExecSession, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult
|
|
func disconnect() async
|
|
}
|
|
|
|
nonisolated extension SSHConnectionProtocol {
|
|
public func executeSession(
|
|
_ command: String, session: SSHExecSession, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
throw TermindSSHError.library("This connection does not support exec sessions.")
|
|
}
|
|
|
|
public func connect(using request: SSHConnectionRequest) async throws {
|
|
defer { request.configuration.clearSecrets() }
|
|
try await connect(using: request.configuration)
|
|
}
|
|
}
|
|
|
|
/// Called on the connection's serial libssh2 owner with charset-converted output.
|
|
/// Malformed source bytes are passed through verbatim. The handler must return promptly.
|
|
public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void
|
|
|
|
public actor SSHConnection: SSHConnectionProtocol {
|
|
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
|
|
private let agentForwardingFactory: SSHAgentForwardingFactory?
|
|
private nonisolated let worker: SSHConnectionWorker
|
|
|
|
public init(
|
|
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil,
|
|
agentForwardingFactory: SSHAgentForwardingFactory? = nil,
|
|
logger: Logger = ApplicationLogger.disabled("termind.ssh-connection"),
|
|
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
|
) {
|
|
self.keyboardInteractive = keyboardInteractive
|
|
self.agentForwardingFactory = agentForwardingFactory
|
|
worker = SSHConnectionWorker(logger: logger, maxSessionsPolicy: maxSessionsPolicy)
|
|
}
|
|
|
|
public func connect(using configuration: SSHConnectionConfiguration) async throws {
|
|
try await connect(using: SSHConnectionRequest(configuration: configuration))
|
|
}
|
|
|
|
public func connect(using request: SSHConnectionRequest) async throws {
|
|
try await connect(using: request, progress: nil, connectionFailure: nil)
|
|
}
|
|
|
|
public func connect(
|
|
using request: SSHConnectionRequest,
|
|
progress: SSHRouteProgressHandler?,
|
|
connectionFailure: (@Sendable (String) -> Void)?
|
|
) async throws {
|
|
defer { request.configuration.clearSecrets() }
|
|
var request = request.withKeyboardInteractiveFallback(keyboardInteractive)
|
|
request.agentForwarding = try await agentForwardingFactory?(request)
|
|
try await worker.connect(
|
|
using: request,
|
|
progress: progress, connectionFailure: connectionFailure)
|
|
}
|
|
|
|
public func execute(
|
|
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
|
|
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
let cancellation = TransferCancellationToken()
|
|
return try await withTaskCancellationHandler {
|
|
try await worker.execute(
|
|
command, workingDirectory: workingDirectory, stdin: stdin,
|
|
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
|
|
onOutput: onOutput)
|
|
} onCancel: {
|
|
cancellation.cancel()
|
|
}
|
|
}
|
|
|
|
public func openSFTPChannel() async throws -> any SFTPChannelProtocol {
|
|
try await worker.openSFTPChannel()
|
|
}
|
|
|
|
public func executeSession(
|
|
_ command: String, session: SSHExecSession, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
try await withTaskCancellationHandler {
|
|
try await worker.executeSession(command, session: session, onOutput: onOutput)
|
|
} onCancel: {
|
|
session.cancellation.cancel()
|
|
}
|
|
}
|
|
|
|
public func disconnect() async { await worker.disconnect() }
|
|
}
|