417 lines
17 KiB
Swift
417 lines
17 KiB
Swift
import CSSH2
|
|
import Darwin
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
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
|
|
}
|
|
|
|
}
|
|
|
|
/// The one failure that is safe to retry on a fresh connection.
|
|
///
|
|
/// It is thrown only while the remote command is provably *not running*: the
|
|
/// session was already gone, or the channel could not be opened or started.
|
|
/// Every failure after `process_startup` succeeds is rethrown unchanged, because
|
|
/// by then the command may have partially run and a transparent retry could
|
|
/// delete something twice.
|
|
public nonisolated enum SSHExecSessionError: LocalizedError, Sendable {
|
|
case channelUnavailable(any Error)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .channelUnavailable(let underlying): underlying.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A long-lived authenticated SSH session that runs each command on its own
|
|
/// non-PTY channel.
|
|
///
|
|
/// Splitting connect from execute is the whole point: the handshake, host-key
|
|
/// check, and signature are paid once per host instead of once per command, and
|
|
/// a jump-host route stops rebuilding every hop for every `ls`. Semantics are
|
|
/// unchanged — a command still gets a private channel and a private
|
|
/// `/bin/sh -lc`, so there is no shared shell state and no cwd carry-over.
|
|
public nonisolated protocol SSHExecSessionProtocol: AnyObject, Sendable {
|
|
func connect(using configuration: SSHConnectionConfiguration) async throws
|
|
func connect(using request: SSHConnectionRequest) async throws
|
|
func execute(
|
|
_ command: String, workingDirectory: String?, stdin: Data?,
|
|
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult
|
|
func disconnect() async
|
|
}
|
|
|
|
nonisolated extension SSHExecSessionProtocol {
|
|
public func connect(using request: SSHConnectionRequest) async throws {
|
|
try await connect(using: request.configuration)
|
|
}
|
|
}
|
|
|
|
/// Called with each chunk the command produces, and which stream it came from —
|
|
/// `false` for stdout, `true` for stderr.
|
|
///
|
|
/// It runs on the session's own serial queue, between two `libssh2` reads, so it
|
|
/// must return promptly and must never await: anything it blocks on stalls every
|
|
/// command on that host. Appending to a lock-guarded buffer is the intended
|
|
/// shape.
|
|
public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void
|
|
|
|
public actor SSHExecSession: SSHExecSessionProtocol {
|
|
public init() {}
|
|
private nonisolated let worker = SSHExecWorker()
|
|
|
|
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 worker.connect(using: request)
|
|
}
|
|
|
|
/// Actor-isolated, so commands on one session queue behind each other. That
|
|
/// is not incidental: a libssh2 session cannot be driven from two places at
|
|
/// once, and the worker's queue would serialize them anyway.
|
|
public func execute(
|
|
_ command: String, workingDirectory: String?, stdin: Data?,
|
|
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 disconnect() async { await worker.disconnect() }
|
|
}
|
|
|
|
private nonisolated final class SSHExecWorker: @unchecked Sendable {
|
|
/// Every libssh2 call for this session happens here, the idle keepalive
|
|
/// included. libssh2 sessions are not thread-safe, and serializing on one
|
|
/// queue is what makes reuse across commands safe at all.
|
|
private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.exec")
|
|
private var routeTransport: SSHRoutedSocket?
|
|
private var session: OpaquePointer?
|
|
private var keepaliveTimer: (any DispatchSourceTimer)?
|
|
/// Set when the transport is known to be gone. The next `execute` reports
|
|
/// `channelUnavailable` instead of calling into a session that cannot answer.
|
|
private var isDead = false
|
|
|
|
deinit { cleanup() }
|
|
|
|
public func connect(using request: SSHConnectionRequest) async throws {
|
|
await queue.perform { self.cleanup() }
|
|
let opened = try await SSHSessionWorker.openAuthenticatedSession(
|
|
using: request, timeout: 20
|
|
)
|
|
do {
|
|
await queue.perform {
|
|
self.routeTransport = opened.transport
|
|
self.session = opened.session
|
|
self.isDead = false
|
|
self.startKeepalive(for: opened.session)
|
|
}
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
await queue.perform { self.cleanup() }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func disconnect() async { await queue.perform { self.cleanup() } }
|
|
|
|
public func execute(
|
|
_ command: String, workingDirectory: String?, stdin: Data?,
|
|
timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken,
|
|
onOutput: SSHExecOutputHandler?
|
|
) async throws -> SSHExecResult {
|
|
try await queue.perform {
|
|
do {
|
|
return try self.executeOnQueue(
|
|
command, workingDirectory: workingDirectory, stdin: stdin,
|
|
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
|
|
onOutput: onOutput
|
|
)
|
|
} catch {
|
|
// Still on the queue, and the channel defer has already run, so
|
|
// this is the right moment to give the socket back rather than
|
|
// leave a corpse in the pool for the idle sweep to find.
|
|
if self.isDead { self.cleanup() }
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
private func executeOnQueue(
|
|
_ command: String, workingDirectory: String?, stdin: Data?,
|
|
timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken,
|
|
onOutput: SSHExecOutputHandler?
|
|
) throws -> SSHExecResult {
|
|
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
throw TermindSSHError.library("The command is empty.")
|
|
}
|
|
guard let session, !isDead else {
|
|
throw SSHExecSessionError.channelUnavailable(TermindSSHError.disconnected)
|
|
}
|
|
// Blocking for the two setup calls, non-blocking for the pump below.
|
|
libssh2_session_set_blocking(session, 1)
|
|
guard
|
|
let channel = libssh2_channel_open_ex(
|
|
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
|
|
)
|
|
else {
|
|
throw notStarted(session, prefix: "Unable to open SSH exec channel")
|
|
}
|
|
// Only a command that ran to completion leaves a channel worth closing
|
|
// politely, and a session worth keeping.
|
|
var completed = false
|
|
defer {
|
|
if completed {
|
|
libssh2_session_set_blocking(session, 1)
|
|
_ = libssh2_channel_free(channel)
|
|
} else {
|
|
// Timed out, cancelled, or the transport failed: the command may
|
|
// still be running and the channel is mid-stream. Blocking here
|
|
// to close it courteously would stall every later command on this
|
|
// host, so give the whole session up instead — `cleanup` frees
|
|
// it, and freeing a session reclaims its channels with it.
|
|
isDead = true
|
|
}
|
|
}
|
|
|
|
let effective: String
|
|
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!cwd.isEmpty
|
|
{
|
|
effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))"
|
|
} else {
|
|
effective = "/bin/sh -lc \(Self.shellQuote(command))"
|
|
}
|
|
let started = effective.withCString {
|
|
libssh2_channel_process_startup(channel, "exec", 4, $0, UInt32(effective.utf8.count))
|
|
}
|
|
guard started == 0 else {
|
|
throw notStarted(session, prefix: "Unable to start remote command")
|
|
}
|
|
// Past this line the command is running. Nothing below may be retried.
|
|
libssh2_session_set_blocking(session, 0)
|
|
|
|
var pendingInput = stdin ?? Data()
|
|
if !pendingInput.isEmpty, pendingInput.last != 0x0A { pendingInput.append(0x0A) }
|
|
var inputOffset = 0
|
|
var sentEOF = pendingInput.isEmpty
|
|
if sentEOF { _ = libssh2_channel_send_eof(channel) }
|
|
var standardOut = Data()
|
|
var standardError = Data()
|
|
var outTruncated = false
|
|
var errorTruncated = false
|
|
var buffer = [CChar](repeating: 0, count: 16 * 1_024)
|
|
let deadline = Date().addingTimeInterval(max(1, min(timeout, 15 * 60)))
|
|
var lastKeepalive = Date()
|
|
|
|
while libssh2_channel_eof(channel) == 0 {
|
|
if cancellation.isCancelled { throw CancellationError() }
|
|
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
|
|
var progressed = false
|
|
if inputOffset < pendingInput.count {
|
|
let written = pendingInput.withUnsafeBytes { bytes in
|
|
libssh2_channel_write_ex(
|
|
channel, 0,
|
|
bytes.baseAddress!.advanced(by: inputOffset)
|
|
.assumingMemoryBound(to: CChar.self),
|
|
bytes.count - inputOffset
|
|
)
|
|
}
|
|
if written > 0 {
|
|
inputOffset += written
|
|
progressed = true
|
|
} else if written != LIBSSH2_ERROR_EAGAIN {
|
|
throw failed(session, prefix: "Unable to write command input")
|
|
}
|
|
} else if !sentEOF {
|
|
let result = libssh2_channel_send_eof(channel)
|
|
if result == 0 {
|
|
sentEOF = true
|
|
progressed = true
|
|
} else if result != LIBSSH2_ERROR_EAGAIN {
|
|
throw failed(session, prefix: "Unable to finish command input")
|
|
}
|
|
}
|
|
for stream in [Int32(0), Int32(1)] {
|
|
while true {
|
|
let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count)
|
|
if count > 0 {
|
|
progressed = true
|
|
let chunk = Data(bytes: buffer, count: count)
|
|
onOutput?(chunk, stream != 0)
|
|
if stream == 0 {
|
|
Self.appendCapped(
|
|
chunk, to: &standardOut, limit: outputLimit,
|
|
truncated: &outTruncated
|
|
)
|
|
} else {
|
|
Self.appendCapped(
|
|
chunk, to: &standardError, limit: outputLimit,
|
|
truncated: &errorTruncated
|
|
)
|
|
}
|
|
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
|
|
break
|
|
} else {
|
|
throw failed(session, prefix: "Unable to read command output")
|
|
}
|
|
}
|
|
}
|
|
if !progressed {
|
|
// The idle timer cannot fire while this queue is busy, so a
|
|
// long-running quiet command has to keep its own session alive.
|
|
if Date().timeIntervalSince(lastKeepalive) >= 30 {
|
|
lastKeepalive = Date()
|
|
var next = Int32(0)
|
|
let sent = libssh2_keepalive_send(session, &next)
|
|
if sent < 0, sent != LIBSSH2_ERROR_EAGAIN {
|
|
throw failed(session, prefix: "SSH keepalive failed")
|
|
}
|
|
}
|
|
usleep(10_000)
|
|
}
|
|
}
|
|
// Drain bytes buffered alongside EOF.
|
|
for stream in [Int32(0), Int32(1)] {
|
|
while true {
|
|
let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count)
|
|
guard count > 0 else { break }
|
|
let chunk = Data(bytes: buffer, count: count)
|
|
onOutput?(chunk, stream != 0)
|
|
if stream == 0 {
|
|
Self.appendCapped(
|
|
chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated
|
|
)
|
|
} else {
|
|
Self.appendCapped(
|
|
chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated
|
|
)
|
|
}
|
|
}
|
|
}
|
|
while true {
|
|
let closed = libssh2_channel_close(channel)
|
|
if closed == 0 { break }
|
|
if closed != LIBSSH2_ERROR_EAGAIN {
|
|
throw failed(session, prefix: "Unable to close SSH exec channel")
|
|
}
|
|
if cancellation.isCancelled { throw CancellationError() }
|
|
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
|
|
usleep(10_000)
|
|
}
|
|
// The remote is done and the channel is closed, so the session is clean
|
|
// and can carry the next command.
|
|
completed = true
|
|
var exitSignal: UnsafeMutablePointer<CChar>?
|
|
var exitSignalLength = 0
|
|
let signalResult = libssh2_channel_get_exit_signal(
|
|
channel, &exitSignal, &exitSignalLength, nil, nil, nil, nil
|
|
)
|
|
let signal: String?
|
|
if signalResult == 0, let exitSignal {
|
|
signal = String(
|
|
decoding: UnsafeBufferPointer(
|
|
start: exitSignal, count: exitSignalLength
|
|
).map(UInt8.init(bitPattern:)), as: UTF8.self)
|
|
libssh2_free(session, exitSignal)
|
|
} else {
|
|
signal = nil
|
|
}
|
|
return SSHExecResult(
|
|
stdout: String(decoding: standardOut, as: UTF8.self),
|
|
stderr: String(decoding: standardError, as: UTF8.self),
|
|
exitCode: Int32(libssh2_channel_get_exit_status(channel)),
|
|
signal: signal,
|
|
stdoutTruncated: outTruncated, stderrTruncated: errorTruncated
|
|
)
|
|
}
|
|
|
|
/// The command never started, so the caller may open a fresh connection and
|
|
/// try again exactly once.
|
|
private func notStarted(_ session: OpaquePointer, prefix: String) -> SSHExecSessionError {
|
|
SSHExecSessionError.channelUnavailable(failed(session, prefix: prefix))
|
|
}
|
|
|
|
/// Records whether the session is still usable, then describes the failure.
|
|
private func failed(_ session: OpaquePointer, prefix: String) -> TermindSSHError {
|
|
let transport: [Int32] = [
|
|
LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV,
|
|
LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT,
|
|
LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED,
|
|
]
|
|
if transport.contains(libssh2_session_last_errno(session)) { isDead = true }
|
|
return SSHSessionWorker.sessionError(session, prefix: prefix)
|
|
}
|
|
|
|
private func startKeepalive(for session: OpaquePointer) {
|
|
libssh2_keepalive_config(session, 1, 30)
|
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
timer.schedule(deadline: .now() + 30, repeating: 30)
|
|
timer.setEventHandler { [weak self] in
|
|
guard let self, self.session == session else { return }
|
|
var secondsToNext: Int32 = 0
|
|
let result = libssh2_keepalive_send(session, &secondsToNext)
|
|
if result < 0, result != LIBSSH2_ERROR_EAGAIN {
|
|
self.isDead = true
|
|
self.cleanup()
|
|
}
|
|
}
|
|
keepaliveTimer = timer
|
|
timer.resume()
|
|
}
|
|
|
|
private func cleanup() {
|
|
keepaliveTimer?.cancel()
|
|
keepaliveTimer = nil
|
|
if let session {
|
|
_ = libssh2_session_disconnect_ex(
|
|
session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the exec session", ""
|
|
)
|
|
_ = libssh2_session_free(session)
|
|
self.session = nil
|
|
}
|
|
routeTransport?.close()
|
|
routeTransport = nil
|
|
}
|
|
|
|
private static func shellQuote(_ value: String) -> String {
|
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
|
|
private static func appendCapped(
|
|
_ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool
|
|
) {
|
|
let remaining = max(0, limit - destination.count)
|
|
if chunk.count > remaining { truncated = true }
|
|
if remaining > 0 { destination.append(chunk.prefix(remaining)) }
|
|
}
|
|
}
|