105 lines
4.3 KiB
Swift
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() })
|
|
}
|
|
}
|