Files

105 lines
4.3 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
@MainActor
public struct RemoteSessionTerminator {
private var connect: (SSHConnectionRequest) async throws -> any SSHConnectionProtocol
private var backends: RemoteSessionBackendRegistry
public init(
connect: @escaping (SSHConnectionRequest) async throws -> any SSHConnectionProtocol,
backends: RemoteSessionBackendRegistry = .live
) {
self.connect = connect
self.backends = backends
}
public static func live(coordinator: RemoteConnectionCoordinator) -> Self {
Self(connect: { try await coordinator.connectConnection(using: $0) })
}
public func terminate(_ close: RemoteSessionCloseRequest, hosts: any RemoteSessionHostStore) async throws {
let reference = close.reference
guard reference.isManaged else { return }
if let existing = close.connection {
let prepared: (any RemoteSessionBackend)?
do {
let backend = try backends.backend(reference.backend, execute: await existing.executor())
try await validate(backend, reference: reference)
prepared = backend
} catch {
await existing.disconnect()
// Only connection/preflight failure may fall back. Never retry a termination command.
try Task.checkCancellation()
if let failure = error as? RemoteSessionFailure, failure != .timedOut { throw error }
prepared = nil
}
if let prepared {
do {
try Task.checkCancellation()
try await prepared.terminate(reference)
await existing.disconnect()
return
} catch {
await existing.disconnect()
throw error
}
}
}
var request = try hosts.additionalConnectionRequest(from: close.snapshot)
defer { request.configuration.clearSecrets() }
// Explicit close must not wake a sleeping host, forward an agent, or request user interaction.
request.configuration.beforeConnect = nil
request.permitsAgentForwarding = false
guard request.configuration.hostID == reference.hostID,
"\(request.configuration.host):\(request.configuration.port)" == reference.endpoint,
request.configuration.username == reference.username
else { throw RemoteSessionFailure.ownershipMismatch }
try Task.checkCancellation()
let connection = try await connect(request)
do {
try Task.checkCancellation()
let backend = try backends.backend(reference.backend) { command in
let result = try await connection.execute(
command, workingDirectory: nil, stdin: nil, timeout: 5,
outputLimit: 1024 * 1024, onOutput: nil)
return RemoteSessionCommandResult(
stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode,
truncated: result.stdoutTruncated || result.stderrTruncated)
}
try await validate(backend, reference: reference)
// Backends recheck identity and the ownership token in the remote termination command.
try await backend.terminate(reference)
await connection.disconnect()
} catch {
await connection.disconnect()
throw error
}
}
private func validate(_ backend: any RemoteSessionBackend, reference: RemoteSessionReference) async throws {
try Task.checkCancellation()
try backend.validate(reference)
let sessions = try await backend.sessions()
try Task.checkCancellation()
guard sessions.contains(where: { backend.matches(reference, session: $0) }) else {
throw RemoteSessionFailure.ownershipMismatch
}
}
}
extension RemoteSessionCloseConnection {
public init(channel: SSHPTYChannel) {
self.init(
prepare: {
try await channel.releaseTerminalForControl()
return try await channel.sessionCommandExecutor()
},
release: { await channel.disconnect() })
}
}