Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift
T
Kaede Akino 816a6ff121 feat(ssh): support hardware security key authentication
Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows.

Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
2026-09-28 23:37:49 +08:00

709 lines
32 KiB
Swift

import Foundation
import Logging
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
case stopped
case preparing
case awaitingTrust
case awaitingAuthentication
case connecting
case reconnecting
case starting
case running(activeConnections: Int, sshConnections: Int = 1, targetSSHConnections: Int = 1)
case failed(String)
public var isActive: Bool {
switch self {
case .stopped, .failed: false
default: true
}
}
public var isFailed: Bool {
if case .failed = self { return true }
return false
}
public var label: String {
switch self {
case .stopped: String(localized: "Stopped")
case .preparing: String(localized: "Preparing")
case .awaitingTrust: String(localized: "Awaiting trust")
case .awaitingAuthentication: String(localized: "Awaiting authentication")
case .connecting: String(localized: "Connecting")
case .reconnecting: String(localized: "Reconnecting")
case .starting: String(localized: "Starting")
case .running(let count, let sshConnections, let targetSSHConnections):
if targetSSHConnections > 1 {
String(localized: "Running · SSH \(sshConnections)/\(targetSSHConnections) · \(count) TCP flows")
} else if count == 0 {
String(localized: "Running")
} else if count == 1 {
String(localized: "Running · 1 connection")
} else {
String(localized: "Running · \(count) connections")
}
case .failed(let message): message
}
}
public var compactLabel: String {
switch self {
case .running: String(localized: "Running")
case .failed: String(localized: "Failed")
default: label
}
}
}
/// A host's manually opened transport and its shared authentication surface.
@Observable
public final class PortForwardingConnection {
public let hostID: Host.ID
public let connection: RemoteSessionConnectionController
public var journey: ConnectionJourney { connection.journey }
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
public fileprivate(set) var poolStatus = SSHForwardingPoolStatus.single
public fileprivate(set) var hostKeyRequestID: UUID?
fileprivate var route: [Host]
fileprivate var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
fileprivate var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
fileprivate var attemptID: UUID?
fileprivate var forwardingConnection: (any SSHForwardingConnectionProtocol)?
fileprivate var task: Task<Void, Never>?
fileprivate var connected = false
fileprivate var ready = false
fileprivate var silentAttempt = false
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
fileprivate let maximumConnections: Int
fileprivate init(
host: Host, route: [Host], maximumConnections: Int, reconnect: SessionReconnectController
) {
hostID = host.id
self.route = route
self.maximumConnections = maximumConnections
connection = RemoteSessionConnectionController(reconnect: reconnect)
}
fileprivate func setPrompt(_ prompt: SSHAuthenticationPrompt?) { authenticationPrompt = prompt }
}
/// Explicit Start owns runtime intent. Established connections recover silently until Stop or a terminal failure.
@Observable
public final class PortForwardingController {
public nonisolated enum PoolPolicy: Sendable {
case singleConnection
case hostConfigured
fileprivate func maximumConnections(for host: Host) -> Int {
switch self {
case .singleConnection: 1
case .hostConfigured:
Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections)
}
}
}
public typealias ConnectionFactory =
@MainActor (
@escaping SSHForwardingConnection.ConnectionHandler, @escaping SSHForwardingConnection.FailureHandler
) -> any SSHForwardingConnectionProtocol
private let hosts: HostLibraryModel
private let makeConnection:
@MainActor (
Int, @escaping SSHForwardingConnection.ConnectionHandler,
@escaping SSHForwardingConnection.FailureHandler
) -> any SSHForwardingConnectionProtocol
private let poolPolicy: PoolPolicy
private let logger: Logger
private let makeReconnectController: @MainActor () -> SessionReconnectController
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
private var connections: [Host.ID: PortForwardingConnection] = [:]
private var presentations: [PortForwardingRule.ID: PortForwardingConnection] = [:]
private var generations: [PortForwardingRule.ID: UUID] = [:]
private var ruleTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
private var cleanupTasks: [Host.ID: Task<Void, Never>] = [:]
private var savingAuthentication: PortForwardingConnection?
public init(
hosts: HostLibraryModel,
poolPolicy: PoolPolicy = .singleConnection,
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding"),
makeConnection: ConnectionFactory? = nil,
makeReconnectController: @escaping @MainActor () -> SessionReconnectController = {
SessionReconnectController()
}
) {
self.hosts = hosts
self.logger = logger
self.poolPolicy = poolPolicy
self.makeReconnectController = makeReconnectController
if let makeConnection {
self.makeConnection = { _, connectionHandler, failureHandler in
makeConnection(connectionHandler, failureHandler)
}
} else {
self.makeConnection = { maximumConnections, connectionHandler, failureHandler in
if maximumConnections > 1 {
SSHForwardingConnectionPool(
maximumConnections: maximumConnections,
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
} else {
SSHForwardingConnection(
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
}
}
}
}
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
public func start(_ id: PortForwardingRule.ID) {
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
let host = hosts.host(rule.hostID), host.sshEnabled
else { return }
logger.info(
"forwarding.requested",
metadata: [
"forwarding_kind": RemoteLogging.forwardingKind(rule.kind),
"host_id": RemoteLogging.hostID(rule.hostID),
"operation_id": "\(UUID().uuidString.lowercased())", "stage": "requested",
])
let context: PortForwardingConnection
if let existing = connections[host.id], !activeIDs(existing).isEmpty {
context = existing
} else {
context = PortForwardingConnection(
host: host, route: hosts.route(for: host),
maximumConnections: poolPolicy.maximumConnections(for: host),
reconnect: makeReconnectController())
connections[host.id] = context
}
generations[id] = UUID()
presentations[id] = context
states[id] = .preparing
if context.connected {
startRule(rule, in: context)
} else if context.connection.isRecovering {
states[id] = .reconnecting
} else if context.task == nil && context.authenticationPrompt == nil {
open(context)
} else {
states[id] =
context.authenticationPrompt != nil || context.connection.keyboardAuthentication.current != nil
? .awaitingAuthentication : context.connection.pendingHostKey != nil ? .awaitingTrust : .connecting
}
}
public func retry(_ id: PortForwardingRule.ID) { start(id) }
public func stop(_ id: PortForwardingRule.ID) {
generations[id] = nil
states[id] = .stopped
guard let context = presentations.removeValue(forKey: id) else { return }
let forwardingConnection = context.forwardingConnection
let previous = ruleTasks[id]
ruleTasks[id] = Task {
if let forwardingConnection { await forwardingConnection.stop(id) }
if let previous { await previous.value }
}
if activeIDs(context).isEmpty { retire(context) }
}
public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) }
public func stopAll() { stop(Array(presentations.keys)) }
public func remove(_ ids: [PortForwardingRule.ID]) {
stop(ids)
for id in ids { states[id] = nil }
}
/// Connection edits include jump hosts. Authentication's own explicit save may continue its opening.
public func hostConfigurationChanged(_ hostID: Host.ID) {
for context in Array(connections.values) where context.route.contains(where: { $0.id == hostID }) {
guard context !== savingAuthentication else { continue }
stop(activeIDs(context))
}
}
public func reconcileConfiguration() {
for context in Array(connections.values) {
guard
context.route.allSatisfy({ old in
hosts.host(old.id).map { old.hasSameConnectionRoute(as: $0) } == true
})
else {
stop(activeIDs(context))
continue
}
}
remove(presentations.keys.filter { hosts.portForwarding($0) == nil })
}
public func submitAuthentication(
for id: PortForwardingRule.ID, promptID: UUID,
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
) {
guard generations[id] != nil, let context = presentations[id],
let prompt = context.authenticationPrompt, prompt.id == promptID,
var host = hosts.host(prompt.host.id)
else { return }
let identity = identityID.flatMap(hosts.identity)
let name = ((prompt.isProxy ? nil : identity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if prompt.isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: name, password: password, identityID: identity?.id)
do {
if save {
savingAuthentication = context
defer { savingAuthentication = nil }
host = try hosts.saveProxyAuthentication(
submission, for: host, selectedIdentity: identity,
suppliedPassword: password)
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
}
context.proxySubmissions[host.id] = submission
context.setPrompt(nil)
context.journey.resume()
open(context)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
context.setPrompt(failed)
}
return
}
if prompt.requiresPassphrase {
do {
context.submissions[host.id] = try hosts.passphraseSubmission(
for: prompt, existing: context.submissions[host.id], passphrase: passphrase, save: save)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
context.setPrompt(failed)
return
}
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
savingAuthentication = context
defer { savingAuthentication = nil }
do {
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = saved.id
guard hosts.saveHost(host) else {
throw TermindSSHError.library(hosts.storageError ?? String(localized: "Unable to Save"))
}
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
context.setPrompt(failed)
return
}
}
context.submissions[host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase, keyID: chosenKey, identityID: identity?.id)
}
context.setPrompt(nil)
context.journey.resume()
open(context)
}
public func acceptHostKey(for id: UUID, requestID: UUID, observation: SSHHostKeyObservation, remember: Bool) {
guard generations[id] != nil, let context = presentations[id],
context.hostKeyRequestID == requestID,
context.connection.pendingHostKey?.observation == observation
else { return }
if remember {
context.connection.trustHostKey(observation, using: hosts)
} else {
_ = context.connection.useTemporaryHostKey(observation)
}
update(context, state: .connecting)
}
private func activeIDs(_ context: PortForwardingConnection) -> [UUID] {
generations.keys.filter { presentations[$0] === context }
}
private func isCurrent(_ context: PortForwardingConnection, attempt: UUID) -> Bool {
connections[context.hostID] === context && context.attemptID == attempt && !activeIDs(context).isEmpty
}
private func update(_ context: PortForwardingConnection, state: PortForwardingRuntimeState) {
for id in activeIDs(context) { states[id] = state }
}
private func open(_ context: PortForwardingConnection) {
guard let host = hosts.host(context.hostID), connections[host.id] === context,
!activeIDs(context).isEmpty, context.task == nil, !context.connected
else { return }
let silently = context.connection.isRecovering
let attempt = UUID()
context.attemptID = attempt
context.silentAttempt = silently
if !silently {
context.journey.begin(
host: host, route: hosts.route(for: host), purpose: .portForwarding,
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
update(context, state: .preparing)
if let proxyHost = context.proxySubmissions.firstHostRequiringProxyPassword(in: context.route) {
requestAuthentication(
context,
error: .proxyAuthentication(
hostID: proxyHost.id, serverChallenge: false,
message: String(localized: "The proxy password is unavailable.")))
context.attemptID = nil
return
}
if let passwordHost = context.submissions.firstHostRequiringPassword(in: context.route) {
requestAuthentication(
context,
error: .routeHost(
hostID: passwordHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.missingCredential.localizedDescription
)
)
context.attemptID = nil
return
}
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
in: context.route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
) {
requestAuthentication(
context,
error: .routeHost(
hostID: passphraseHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.keyPassphraseRequired.localizedDescription
)
)
context.attemptID = nil
return
}
}
let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets()
let attemptProxySubmissions = silently ? [:] : context.proxySubmissions.takeProxyAuthenticationSecrets()
let forwardingConnection = makeConnection(
context.maximumConnections,
{ [weak self, weak context] id, generation, count in
Task { @MainActor in
guard let self, let context, self.isCurrent(context, attempt: attempt),
self.generations[id] == generation, self.presentations[id] === context
else { return }
context.connectionCounts[id] = count
let poolStatus = await context.forwardingConnection?.connectionPoolStatus() ?? .single
context.poolStatus = poolStatus
let running = PortForwardingRuntimeState.running(
activeConnections: count, sshConnections: poolStatus.connected,
targetSSHConnections: poolStatus.target)
if case .running = self.states[id], self.states[id] != running {
self.states[id] = running
}
}
},
{ [weak self, weak context] message in
Task { @MainActor in
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
self.recover(context, message: message)
}
})
context.forwardingConnection = forwardingConnection
let cleanup = cleanupTasks[context.hostID]
context.task = Task { [weak self, weak context] in
guard let self, let context else { return }
do {
await cleanup?.value
try Task.checkCancellation()
let configuration: SSHConnectionConfiguration
if silently {
guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential }
configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration
} else {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: attemptSubmissions,
proxySubmissions: attemptProxySubmissions,
temporaryHostKeys: context.connection.temporaryHostKeys)
configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
}
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
if silently {
try await forwardingConnection.connect(
SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
.enforcingInteractionPolicy(), progress: nil)
} else {
update(context, state: .connecting)
let request = SSHConnectionRequest(
configuration: configuration,
authorizeHostKey: { [weak self, weak context] challenge in
guard let self, let context else { throw CancellationError() }
return await self.authorize(challenge, in: context, attempt: attempt)
},
keyboardInteractive: { [weak self, weak context] challenge in
guard let self, let context else { throw CancellationError() }
return try await self.answer(challenge, in: context, attempt: attempt)
},
securityKeySigning: { [weak self, weak context] request in
guard let self, let context else { throw CancellationError() }
return try await self.sign(request, in: context, attempt: attempt)
})
try await forwardingConnection.connect(
request,
progress: { [weak self, weak context] event in
Task { @MainActor in
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
context.journey.receive(event)
}
})
}
try Task.checkCancellation()
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
context.task = nil
context.connected = true
if !silently {
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
}
if let snapshot = context.additionalSnapshot {
await forwardingConnection.configureAdditionalConnections { [weak self] in
guard let self else { throw CancellationError() }
return try self.hosts.additionalConnectionRequest(from: snapshot)
}
}
context.submissions.removeAll()
context.proxySubmissions.removeAll()
context.connection.temporaryHostKeys.removeAll()
for id in activeIDs(context) {
if let rule = hosts.portForwarding(id) { startRule(rule, in: context) }
}
} catch {
await forwardingConnection.disconnect()
guard isCurrent(context, attempt: attempt) else { return }
context.task = nil
context.attemptID = nil
context.forwardingConnection = nil
if silently {
if Self.requiresManualRetry(error) {
fail(context, message: error.localizedDescription, presentJourney: false)
} else {
recover(context, message: error.localizedDescription)
}
} else if let ssh = error as? TermindSSHError,
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
{
requestAuthentication(context, error: ssh)
} else {
fail(context, message: error.localizedDescription)
}
}
}
}
private func authorize(
_ challenge: SSHHostKeyChallenge, in context: PortForwardingConnection, attempt: UUID
) async -> SSHHostKeyDecision {
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
let requestID = UUID()
context.hostKeyRequestID = requestID
defer {
if context.hostKeyRequestID == requestID { context.hostKeyRequestID = nil }
}
update(context, state: .awaitingTrust)
let decision = await context.connection.authorizeHostKey(challenge)
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
update(context, state: .connecting)
return decision
}
private func sign(_ request: SecurityKeySigningRequest, in context: PortForwardingConnection, attempt: UUID)
async throws -> SecurityKeyAssertion
{
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
update(context, state: .awaitingAuthentication)
let assertion = try await context.connection.signWithSecurityKey(request)
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
update(context, state: .connecting)
return assertion
}
private func answer(
_ challenge: SSHKeyboardInteractiveChallenge, in context: PortForwardingConnection, attempt: UUID
) async throws -> [String] {
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
try Task.checkCancellation()
update(context, state: .awaitingAuthentication)
let answers = try await context.connection.answerKeyboardInteractive(challenge)
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
update(context, state: .connecting)
return answers
}
private func requestAuthentication(_ context: PortForwardingConnection, error: TermindSSHError) {
guard let host = hosts.host(error.routeHostID ?? context.hostID) else {
fail(context, message: error.localizedDescription)
return
}
if error.isProxyAuthentication, host.proxy != nil {
let staged = context.proxySubmissions.removeValue(forKey: host.id)
context.setPrompt(
SSHAuthenticationPromptResolver.proxy(
for: host, using: hosts, submission: staged,
failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity))
context.journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription)
update(context, state: .awaitingAuthentication)
return
}
let staged = context.submissions[host.id]
let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase =
staged?.requiresPasswordReentry != true
&& keyID.flatMap(hosts.key)?.hasPassphrase == true
&& staged?.passphrase == nil
context.setPrompt(
SSHAuthenticationPromptResolver.host(
host, using: hosts, submission: staged,
failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity,
requiresPassphrase: passphrase))
if error.isServerAuthenticationChallenge {
context.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else if passphrase {
context.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
} else {
context.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
}
update(context, state: .awaitingAuthentication)
}
private func startRule(_ rule: PortForwardingRule, in context: PortForwardingConnection) {
guard let token = generations[rule.id], let forwardingConnection = context.forwardingConnection,
let attempt = context.attemptID
else {
return
}
let silently = context.silentAttempt
context.connectionCounts[rule.id] = 0
states[rule.id] = silently ? .reconnecting : .starting
let previous = ruleTasks[rule.id]
ruleTasks[rule.id] = Task { [weak self] in
if let previous { await previous.value }
guard let self, generations[rule.id] == token, isCurrent(context, attempt: attempt) else { return }
do {
try await forwardingConnection.start(rule, generation: token)
guard generations[rule.id] == token, isCurrent(context, attempt: attempt) else {
await forwardingConnection.stop(rule.id)
return
}
let poolStatus = await forwardingConnection.connectionPoolStatus()
context.poolStatus = poolStatus
states[rule.id] = .running(
activeConnections: context.connectionCounts[rule.id] ?? 0,
sshConnections: poolStatus.connected, targetSSHConnections: poolStatus.target)
if !context.ready {
context.ready = true
context.connection.markConnected()
}
if !silently { context.journey.succeed() }
} catch {
guard generations[rule.id] == token, isCurrent(context, attempt: attempt) else { return }
// Transport cleanup can finish a pending remote listen before its failure callback arrives.
if error is CancellationError
|| (error as? TermindSSHError).map({
if case .disconnected = $0 { return true }
return false
}) == true
{
recover(context, message: error.localizedDescription)
return
}
generations[rule.id] = nil
states[rule.id] = .failed(error.localizedDescription)
if activeIDs(context).isEmpty {
retire(context)
if !silently { context.journey.fail(error.localizedDescription) }
}
}
}
}
private static func requiresManualRetry(_ error: any Error) -> Bool {
if error is SSHInteractionRequired || error is CancellationError { return true }
guard let ssh = error as? TermindSSHError else { return false }
if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication || ssh.shouldRequestHostKey {
return true
}
switch ssh {
case .untrustedHost, .mismatchedHostKey, .hostKeyChanged: return true
default: return false
}
}
private func recover(_ context: PortForwardingConnection, message: String) {
guard context.connection.hasConnectedOnce, context.additionalSnapshot != nil else {
fail(context, message: message)
return
}
closeTransport(context)
context.journey.hide()
update(context, state: .reconnecting)
context.connection.scheduleRecovery(after: message) { [weak self, weak context] in
guard let self, let context, self.connections[context.hostID] === context,
context.connection.isRecovering
else { return }
self.open(context)
}
}
private func fail(_ context: PortForwardingConnection, message: String, presentJourney: Bool = true) {
for id in activeIDs(context) {
generations[id] = nil
states[id] = .failed(message)
}
retire(context)
if presentJourney { context.journey.fail(message) }
}
private func closeTransport(_ context: PortForwardingConnection) {
context.attemptID = nil
let opening = context.task
context.task?.cancel()
context.task = nil
context.connected = false
context.ready = false
context.connectionCounts.removeAll()
context.poolStatus = SSHForwardingPoolStatus(connected: 0, target: context.maximumConnections)
let forwardingConnection = context.forwardingConnection
context.forwardingConnection = nil
let previous = cleanupTasks[context.hostID]
cleanupTasks[context.hostID] = Task {
await previous?.value
await opening?.value
await forwardingConnection?.disconnect()
}
}
private func retire(_ context: PortForwardingConnection) {
closeTransport(context)
context.setPrompt(nil)
context.hostKeyRequestID = nil
context.submissions.removeAll()
context.proxySubmissions.removeAll()
context.additionalSnapshot = nil
context.connection.disconnect()
if connections[context.hostID] === context { connections[context.hostID] = nil }
}
}