Add proxy identity choose UI

This commit is contained in:
Kaede Akino committed 2026-09-21 09:13:28 +08:00
1 parent 45922ea5e5
commit 4ae95df1f7
26 files changed
+1134 -119

No files matched your search

@@ -5,6 +5,53 @@ import Testing
@Suite @MainActor
struct SSHAuthenticationSubmissionTests {
@Test
func missingIdentityCannotBeOverwrittenAndCustomSaveCreatesAReplacement() throws {
let secrets = InMemorySecretStore()
let missing = Identity(
name: "Synced", username: "deploy", credentialRef: "termind.password.missing")
let host = Host(name: "Server", host: "server.invalid", identityID: missing.id)
let store = HostLibraryModel(hosts: [host], identities: [missing], secrets: secrets)
let selected = SSHAuthenticationSubmission(
username: "deploy", password: "temporary", identityID: missing.id)
#expect(throws: TermindSSHError.self) {
_ = try store.saveAuthentication(
selected, for: host, selectedIdentity: missing, suppliedPassword: "temporary")
}
let custom = SSHAuthenticationSubmission(username: "deploy", password: "replacement")
let updated = try store.saveAuthentication(
custom, for: host, selectedIdentity: nil, suppliedPassword: "replacement")
let replacement = try #require(store.identity(for: updated))
#expect(replacement.id != missing.id)
#expect(store.passwordAvailability(for: missing) == .missing)
#expect(try store.passwordCredential(for: replacement) == "replacement")
}
@Test
func proxyCustomSaveDoesNotOverwriteItsMissingIdentity() throws {
let secrets = InMemorySecretStore()
let missing = Identity(
name: "Synced Proxy", username: "identity-user",
credentialRef: "termind.password.proxy-missing")
let host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(
hostname: "proxy.invalid", username: "proxy-user", identityID: missing.id))
let store = HostLibraryModel(hosts: [host], identities: [missing], secrets: secrets)
let submission = SSHProxyAuthenticationSubmission(
username: "proxy-user", password: "replacement")
let updated = try store.saveProxyAuthentication(
submission, for: host, selectedIdentity: nil, suppliedPassword: "replacement")
let replacement = try #require(updated.proxy?.identityID.flatMap(store.identity))
#expect(replacement.id != missing.id)
#expect(updated.proxy?.username == "proxy-user")
#expect(store.passwordAvailability(for: missing) == .missing)
#expect(try store.passwordCredential(for: replacement) == "replacement")
}
@Test(arguments: [String?.none, "", "supplied"])
func selectedPasswordRemainsReferenceOnlyUnlessExplicitlySupplied(_ password: String?) throws {
let secrets = InMemorySecretStore()
@@ -5,6 +5,75 @@ import Testing
@Suite
struct SSHConnectionPreparationTests {
@Test @MainActor
func journeyPlacesProxyBeforeItsOwningHost() {
let host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(hostname: "proxy.invalid", port: 1080))
let journey = ConnectionJourney()
journey.begin(host: host, route: [host], purpose: .sshSession, username: "deploy")
#expect(journey.endpoints.map(\.id) == [.local, .proxy(host.id), .host(host.id)])
journey.waitForProxyAuthentication(at: host.id)
#expect(journey.activeEndpointIndex == 1)
journey.resume()
journey.waitForAuthentication(at: host.id)
#expect(journey.activeEndpointIndex == 2)
}
@Test
func proxySubmissionSuppliesOneShotPasswordAndPreservesProxyUsername() async throws {
let identity = Identity(name: "SSH", username: "deploy")
let proxyIdentity = Identity(
name: "Proxy", username: "identity-user", credentialRef: "proxy.missing")
let host = Host(
name: "Server", host: "server.invalid", identityID: identity.id,
proxy: HostProxy(
hostname: "proxy.invalid", username: "proxy-user", identityID: proxyIdentity.id))
let preparation = SSHConnectionPreparation(
nodes: [
.init(
host: host, identity: identity, key: nil, submission: nil,
trustedKeys: [], proxyIdentity: proxyIdentity,
proxySubmission: SSHProxyAuthenticationSubmission(
username: "custom-proxy-user", password: "one-use"))
],
secrets: MappedPreparationSecrets())
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
#expect(configuration.proxy?.username == "custom-proxy-user")
#expect(configuration.proxy?.ownerHostID == host.id)
#expect(try #require(configuration.proxy?.password) == "one-use")
}
@Test
func missingProxyPasswordReportsItsOwningHost() async {
let identity = Identity(name: "SSH", username: "deploy")
let proxyIdentity = Identity(
name: "Proxy", username: "proxy", credentialRef: "proxy.missing")
let host = Host(
name: "Server", host: "server.invalid", identityID: identity.id,
proxy: HostProxy(
hostname: "proxy.invalid", username: "proxy", identityID: proxyIdentity.id))
let preparation = SSHConnectionPreparation(
nodes: [
.init(
host: host, identity: identity, key: nil, submission: nil,
trustedKeys: [], proxyIdentity: proxyIdentity)
], secrets: MappedPreparationSecrets())
do {
_ = try await preparation.resolve(trustPolicy: .allowChallenge)
Issue.record("Expected proxy authentication")
} catch let error as TermindSSHError {
#expect(error.isProxyAuthentication)
#expect(error.routeHostID == host.id)
#expect(!error.isServerAuthenticationChallenge)
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test func usernameOnlyIdentityReachesServerAuthentication() async throws {
let preparation = makePreparation(
identity: Identity(name: "OTP", username: "deploy"),
+49 -1
View File
@@ -18923,6 +18923,36 @@
}
}
},
"Proxy authentication required" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要代理认证" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要代理驗證" } }
}
},
"Proxy connected" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "代理已连接" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "代理已連線" } }
}
},
"Proxy for %@" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } }
}
},
"Connecting to proxy" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在连接代理" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在連線代理" } }
}
},
"Waiting for proxy authentication" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理认证" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理驗證" } }
}
},
"Proxy Hostname" : {
"localizations" : {
"zh-Hans" : {
@@ -25932,6 +25962,24 @@
}
}
},
"HTTP proxy authentication failed." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理认证失败。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理驗證失敗。" } }
}
},
"The selected Identity cannot be updated on this device." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法在此设备上更新所选身份。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法在此裝置上更新所選身分。" } }
}
},
"The SOCKS5 proxy rejected authentication." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒绝了认证。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒絕了驗證。" } }
}
},
"The proxy configuration is incomplete." : {
"extractionState" : "stale",
"localizations" : {
@@ -30453,4 +30501,4 @@
}
},
"version" : "1.2"
}
}
@@ -11,6 +11,7 @@ import struct TermindDomain.Host
nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable, Sendable {
public nonisolated enum Mode: Hashable, Sendable {
case identity
case proxy(ownerHostID: Host.ID)
case keyboardInteractive(SSHKeyboardInteractiveChallenge)
case passphrase(keyID: SSHKeyRef.ID)
}
@@ -36,7 +37,12 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable,
}
public var journeyPrompt: SSHAuthenticationPrompt {
SSHAuthenticationPrompt(
let target: SSHAuthenticationPrompt.Target
switch mode {
case .proxy(let ownerHostID): target = .proxy(ownerHostID: ownerHostID)
default: target = .host
}
return SSHAuthenticationPrompt(
host: Host(id: hostID, name: displayName, host: address),
selectedIdentityID: selectedIdentityID,
username: username,
@@ -44,7 +50,7 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable,
failureMessage: failureMessage,
requiresPassphrase: requiresPassphrase,
mode: failureMessage == nil ? .localIdentity : .serverChallenge,
keyboardInteractive: keyboardInteractive
keyboardInteractive: keyboardInteractive, target: target
)
}
@@ -20,6 +20,7 @@ public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
public nonisolated enum ID: Hashable, Sendable {
case local
case proxy(Host.ID)
case host(Host.ID)
case serial(String)
}
@@ -112,13 +113,21 @@ public final class ConnectionJourney {
name: Self.localDeviceName, address: "localhost", role: .local
)
]
+ route.enumerated().map { index, node in
ConnectionEndpoint(
id: .host(node.id), hostID: node.id, name: node.name,
address: index == route.count - 1 && username?.isEmpty == false
? "\(username!)@\(node.displayAddress)" : node.displayAddress,
role: index == route.count - 1 ? .destination : .jump
)
+ route.enumerated().flatMap { index, node in
let proxy = node.proxy.map {
ConnectionEndpoint(
id: .proxy(node.id), hostID: node.id,
name: String(localized: "Proxy"), address: "\($0.hostname):\($0.port)", role: .jump
)
}
return (proxy.map { [$0] } ?? []) + [
ConnectionEndpoint(
id: .host(node.id), hostID: node.id, name: node.name,
address: index == route.count - 1 && username?.isEmpty == false
? "\(username!)@\(node.displayAddress)" : node.displayAddress,
role: index == route.count - 1 ? .destination : .jump
)
]
}
let continuesJourney =
phase == .connecting && endpoints == nextEndpoints
@@ -183,6 +192,12 @@ public final class ConnectionJourney {
let hostID: Host.ID
let message: String
switch event {
case .proxyConnecting(let id):
hostID = id
message = String(localized: "Connecting to proxy")
case .proxyConnected(let id):
hostID = id
message = String(localized: "Proxy connected")
case .connecting(let id):
hostID = id
message = String(localized: "Opening SSH transport")
@@ -199,15 +214,22 @@ public final class ConnectionJourney {
hostID = id
message = detail
}
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
let index: Int?
switch event {
case .proxyConnecting, .proxyConnected:
index = endpoints.firstIndex { $0.id == .proxy(hostID) }
default:
index = endpoints.firstIndex { $0.id == .host(hostID) }
}
guard let index else { return }
switch event {
case .connecting, .authenticating, .initializing:
case .proxyConnecting, .connecting, .authenticating, .initializing:
if index > completedEndpointIndex { activeEndpointIndex = index }
case .authenticated:
checkpoint = nil
activeEndpointIndex = max(activeEndpointIndex, index)
case .connected:
case .proxyConnected, .connected:
checkpoint = nil
completedEndpointIndex = max(completedEndpointIndex, index)
activeEndpointIndex = max(activeEndpointIndex, index)
@@ -219,11 +241,23 @@ public final class ConnectionJourney {
wait(at: hostID, kind: .authentication, message: failure ?? String(localized: "Waiting for authentication"))
}
public func waitForProxyAuthentication(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.id == .proxy(hostID) }) else { return }
activeEndpointIndex = index
checkpoint = Checkpoint(endpointIndex: index, kind: .authentication)
localRequirement = .identity
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for proxy authentication")
)
}
/// Pauses before transport authentication because Termind has no usable local
/// Identity. This is not a server challenge, so the untouched route stays
/// gray and no midpoint checkpoint is introduced.
public func waitForLocalIdentity(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
@@ -236,7 +270,7 @@ public final class ConnectionJourney {
}
public func waitForLocalPassphrase(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
@@ -287,7 +321,7 @@ public final class ConnectionJourney {
checkpoint = nil
localRequirement = nil
if let hostID,
let index = endpoints.firstIndex(where: { $0.hostID == hostID })
let index = endpoints.firstIndex(where: { $0.id == .host(hostID) })
{
activeEndpointIndex = index
}
@@ -319,7 +353,7 @@ public final class ConnectionJourney {
}
private func wait(at hostID: Host.ID, kind: CheckpointKind, message: String) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
activeEndpointIndex = index
checkpoint = Checkpoint(endpointIndex: index, kind: kind)
localRequirement = nil
@@ -19,23 +19,28 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
trustPolicy: SSHHostTrustPolicy
) throws -> Set<SSHHostKeyIdentity>
func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation
func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration
func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration]
func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation
func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration]
func saveConnectionRoute(
@@ -43,8 +48,12 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
authenticationHostID: Host.ID?, suppliedPassword: String?
) throws -> SSHConnectionRoute
func transportProxy(for host: Host) throws -> SSHTransportProxy?
func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy?
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
func passwordSecret(for identity: Identity) throws -> SSHSecret?
func passwordAvailability(for identity: Identity) -> SecretAvailability
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
func recordIdentityCredentialAccess(_ identityIDs: Set<Identity.ID>, at date: Date)
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
@@ -57,6 +66,45 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
}
extension RemoteSessionHostStore {
public func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(for: host, submissions: submissions, proxySubmissions: [:])
}
public func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration {
try sshConnectionConfiguration(
for: host, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
public func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try sshJumpConfigurations(
for: host, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(for: route, submissions: submissions, proxySubmissions: [:])
}
public func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try sshJumpConfigurations(
for: route, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
/// Captures a desktop authentication form without saving its input. A
/// selected Identity stays reference-only so its saved password is read
/// directly into a one-shot buffer only when the connection is resolved.
@@ -81,11 +129,27 @@ extension RemoteSessionHostStore {
let identity: Identity
if let selectedIdentity {
if let suppliedPassword {
switch passwordAvailability(for: selectedIdentity) {
case .missing, .failed:
throw TermindSSHError.library(
String(localized: "The selected Identity cannot be updated on this device."))
case .notConfigured, .available:
break
}
try upsert(selectedIdentity, passwordUpdate: .replace(suppliedPassword))
}
identity = selectedIdentity
} else {
let existing = self.identity(for: host)
let candidate = self.identity(for: host)
let existing: Identity?
if let candidate {
switch passwordAvailability(for: candidate) {
case .missing, .failed: existing = nil
case .notConfigured, .available: existing = candidate
}
} else {
existing = nil
}
identity = Identity(
id: existing?.id ?? UUID(), name: existing?.name ?? submission.username,
username: submission.username, credentialRef: existing?.credentialRef ?? "",
@@ -105,6 +169,40 @@ extension RemoteSessionHostStore {
return updated
}
public func saveProxyAuthentication(
_ submission: SSHProxyAuthenticationSubmission, for host: Host,
selectedIdentity: Identity?, suppliedPassword: String?
) throws -> Host {
guard var proxy = host.proxy else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
let identity: Identity
if let selectedIdentity {
if let suppliedPassword {
switch passwordAvailability(for: selectedIdentity) {
case .missing, .failed:
throw TermindSSHError.library(
String(localized: "The selected Identity cannot be updated on this device."))
case .notConfigured, .available:
break
}
try upsert(selectedIdentity, passwordUpdate: .replace(suppliedPassword))
}
identity = selectedIdentity
} else {
identity = Identity(name: submission.username, username: submission.username)
try upsert(
identity,
passwordUpdate: suppliedPassword.map(IdentityPasswordUpdate.replace) ?? .remove)
}
proxy.username = submission.username
proxy.identityID = identity.id
var updated = host
updated.proxy = proxy
upsert(updated)
return updated
}
/// Captures the authenticated route without retaining password, key, or
/// passphrase material. Once-only trust is deliberately not promoted into
/// permission for additional connections.
@@ -119,6 +217,7 @@ extension RemoteSessionHostStore {
value.map {
SSHAdditionalConnectionSnapshot.Proxy(
kind: $0.kind, hostname: $0.hostname, port: $0.port,
username: $0.username,
identityID: $0.identityID
)
}
@@ -151,13 +250,13 @@ extension RemoteSessionHostStore {
from snapshot: SSHAdditionalConnectionSnapshot
) throws -> SSHConnectionRequest {
func proxy(
_ value: SSHAdditionalConnectionSnapshot.Proxy?
_ value: SSHAdditionalConnectionSnapshot.Proxy?, ownerHostID: Host.ID
) throws -> SSHTransportProxy? {
guard let value else { return nil }
guard let identityID = value.identityID else {
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: ""
username: value.username, ownerHostID: ownerHostID
)
}
guard let identity = identity(identityID) else {
@@ -167,8 +266,8 @@ extension RemoteSessionHostStore {
guard let password else { throw TermindSSHError.missingCredential }
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: identity.username, password: password,
identityID: identity.id
username: value.username, password: password,
identityID: identity.id, ownerHostID: ownerHostID
)
}
@@ -243,7 +342,7 @@ extension RemoteSessionHostStore {
id: node.id, name: node.name, host: node.host, port: node.port,
username: resolved.username, credentials: resolved.credentials,
expectedHostKeys: node.expectedHostKeys,
proxy: try proxy(node.proxy),
proxy: try proxy(node.proxy, ownerHostID: node.id),
authenticationReference: node.authentication
)
}
@@ -252,7 +351,7 @@ extension RemoteSessionHostStore {
port: snapshot.target.port, username: targetAuthentication.username,
credentials: targetAuthentication.credentials,
expectedHostKeys: snapshot.target.expectedHostKeys,
proxy: try proxy(snapshot.target.proxy), jumps: jumps,
proxy: try proxy(snapshot.target.proxy, ownerHostID: snapshot.target.id), jumps: jumps,
authenticationReference: snapshot.target.authentication
)
return SSHConnectionRequest(
@@ -322,4 +421,20 @@ extension RemoteSessionHostStore {
}
return preparation
}
public func prepareSSHConnection(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
var preparation = try prepareSSHConnection(
for: host, submissions: submissions, proxySubmissions: proxySubmissions)
for index in preparation.nodes.indices {
if let key = temporaryHostKeys[preparation.nodes[index].host.id] {
preparation.nodes[index].trustedKeys.insert(key)
}
}
return preparation
}
}
@@ -11,15 +11,17 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
public var kind: HostProxy.ProtocolKind
public var hostname: String
public var port: Int
public var username: String
public var identityID: Identity.ID?
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int,
identityID: Identity.ID?
username: String = "", identityID: Identity.ID?
) {
self.kind = kind
self.hostname = hostname
self.port = port
self.username = username
self.identityID = identityID
}
}
@@ -64,6 +66,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
value.map {
Proxy(
kind: $0.kind, hostname: $0.hostname, port: $0.port,
username: $0.username,
identityID: $0.identityID
)
}
@@ -4,6 +4,11 @@ import TermindDomain
import struct TermindDomain.Host
public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Sendable {
public nonisolated enum Target: Hashable, Sendable {
case host
case proxy(ownerHostID: Host.ID)
}
public nonisolated enum Mode: Hashable, Sendable {
case localIdentity
case serverChallenge
@@ -18,10 +23,11 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda
public var keyboardInteractive: SSHKeyboardInteractiveChallenge?
public var requiresPassphrase = false
public var mode: Mode = .localIdentity
public var target: Target = .host
public init(
host: Host, selectedIdentityID: Identity.ID? = nil, username: String, selectedKeyID: SSHKeyRef.ID? = nil,
failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: Mode = .localIdentity,
keyboardInteractive: SSHKeyboardInteractiveChallenge? = nil
keyboardInteractive: SSHKeyboardInteractiveChallenge? = nil, target: Target = .host
) {
self.host = host
self.selectedIdentityID = selectedIdentityID
@@ -31,6 +37,11 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda
self.requiresPassphrase = requiresPassphrase
self.mode = mode
self.keyboardInteractive = keyboardInteractive
self.target = target
}
public var isProxy: Bool {
if case .proxy = target { true } else { false }
}
}
@@ -15,9 +15,11 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
public var submission: SSHAuthenticationSubmission?
public var trustedKeys: Set<SSHHostKeyIdentity>
public var proxyIdentity: Identity?
public var proxySubmission: SSHProxyAuthenticationSubmission?
public init(
host: Host, identity: Identity?, key: SSHKeyRef?, submission: SSHAuthenticationSubmission?,
trustedKeys: Set<SSHHostKeyIdentity>, proxyIdentity: Identity?
trustedKeys: Set<SSHHostKeyIdentity>, proxyIdentity: Identity?,
proxySubmission: SSHProxyAuthenticationSubmission? = nil
) {
self.host = host
self.identity = identity
@@ -25,6 +27,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
self.submission = submission
self.trustedKeys = trustedKeys
self.proxyIdentity = proxyIdentity
self.proxySubmission = proxySubmission
}
}
@@ -82,6 +85,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
} catch {
if error is CancellationError { throw error }
let ssh = error as? TermindSSHError
if ssh?.isProxyAuthentication == true { throw error }
throw TermindSSHError.routeHost(
hostID: node.host.id, authentication: ssh?.isLocalAuthenticationRequirement == true,
serverChallenge: false, hostKey: isUntrusted(ssh),
@@ -189,21 +193,35 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password: SSHSecret?
if proxy.identityID != nil {
guard let identity = node.proxyIdentity, identity.usesPassword else {
throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable."))
var password = node.proxySubmission?.passwordForConnection()
let identity =
node.proxySubmission?.identityID.flatMap { id in
node.proxyIdentity?.id == id ? node.proxyIdentity : nil
} ?? (node.proxySubmission == nil ? node.proxyIdentity : nil)
if password == nil, proxy.identityID != nil || node.proxySubmission?.identityID != nil {
guard let identity, identity.usesPassword else {
throw TermindSSHError.proxyAuthentication(
hostID: node.host.id, serverChallenge: false,
message: String(localized: "The proxy password identity is unavailable."))
}
do {
password = try secrets.sshSecret(for: identity.credentialRef, storage: identity.passwordStorage)
} catch {
throw TermindSSHError.proxyAuthentication(
hostID: node.host.id, serverChallenge: false, message: error.localizedDescription)
}
password = try secrets.sshSecret(for: identity.credentialRef, storage: identity.passwordStorage)
guard password != nil else {
throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable."))
throw TermindSSHError.proxyAuthentication(
hostID: node.host.id, serverChallenge: false,
message: String(localized: "The proxy password is unavailable."))
}
accessedIdentityIDs.insert(identity.id)
}
return SSHTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: proxy.username, password: password,
identityID: proxy.identityID
username: node.proxySubmission?.username ?? proxy.username, password: password,
identityID: node.proxySubmission?.identityID ?? proxy.identityID,
ownerHostID: node.host.id
)
}
@@ -390,16 +390,43 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// main actor. The resulting value is Sendable and contains no persistence
/// objects, so several one-shot exports can safely run in parallel.
public func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
) throws -> SSHConnectionPreparation {
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try prepareSSHConnection(for: SSHConnectionRoute(hosts: route), submissions: submissions)
return try prepareSSHConnection(
for: SSHConnectionRoute(hosts: route), submissions: submissions,
proxySubmissions: proxySubmissions)
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try prepareSSHConnection(
for: SSHConnectionRoute(hosts: route), submissions: submissions,
proxySubmissions: proxySubmissions, temporaryHostKeys: temporaryHostKeys)
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(
for: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: [:])
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
let submissions = route.submissions(merging: submissions)
@@ -411,8 +438,12 @@ public final class HostLibraryModel: RemoteSessionHostStore {
return SSHConnectionPreparation.Node(
host: node, identity: identity,
key: keyID.flatMap { key($0) },
submission: submission, trustedKeys: Set(knownHosts(for: node).map(\.identity)),
proxyIdentity: node.proxy?.identityID.flatMap { self.identity($0) }
submission: submission,
trustedKeys: Set(knownHosts(for: node).map(\.identity))
.union(temporaryHostKeys[node.id].map { [$0] } ?? []),
proxyIdentity: (proxySubmissions[node.id]?.identityID ?? node.proxy?.identityID)
.flatMap { self.identity($0) },
proxySubmission: proxySubmissions[node.id]
)
},
secrets: keychain,
@@ -427,13 +458,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func sshConnectionConfiguration(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> SSHConnectionConfiguration {
var accessedIdentityIDs: Set<Identity.ID> = []
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return try sshConnectionConfiguration(
for: host, submissions: submissions,
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
)
@@ -442,6 +474,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
private func sshConnectionConfiguration(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy,
accessedIdentityIDs: inout Set<Identity.ID>
@@ -482,10 +515,11 @@ public final class HostLibraryModel: RemoteSessionHostStore {
credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
for: host, submission: proxySubmissions[host.id],
accessedIdentityIDs: &accessedIdentityIDs
),
jumps: try sshJumpConfigurations(
for: host, submissions: submissions,
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
@@ -499,13 +533,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func sshJumpConfigurations(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> [SSHRouteHopConfiguration] {
var accessedIdentityIDs: Set<Identity.ID> = []
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return try sshJumpConfigurations(
for: host, submissions: submissions,
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
)
@@ -514,6 +549,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
private func sshJumpConfigurations(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy,
accessedIdentityIDs: inout Set<Identity.ID>
@@ -522,13 +558,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try sshJumpConfigurations(
route: route, submissions: submissions, temporaryHostKeys: temporaryHostKeys,
route: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy, accessedIdentityIDs: &accessedIdentityIDs
)
}
public func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> [SSHRouteHopConfiguration] {
@@ -537,6 +575,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return try sshJumpConfigurations(
route: route.hosts, submissions: route.submissions(merging: submissions),
proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
)
@@ -544,6 +583,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
private func sshJumpConfigurations(
route: [Host], submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy,
accessedIdentityIDs: inout Set<Identity.ID>
) throws -> [SSHRouteHopConfiguration] {
@@ -552,17 +592,20 @@ public final class HostLibraryModel: RemoteSessionHostStore {
if let submission = submissions[jump.id] {
return try sshRouteHopConfiguration(
for: jump, submission: submission,
proxySubmission: proxySubmissions[jump.id],
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
)
}
return try sshRouteHopConfiguration(
for: jump, temporaryHostKeys: temporaryHostKeys,
for: jump, proxySubmission: proxySubmissions[jump.id],
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
)
} catch {
if (error as? TermindSSHError)?.isProxyAuthentication == true { throw error }
let authentication =
switch error as? TermindSSHError {
case .missingCredential, .missingUsername, .keyPassphraseRequired: true
@@ -583,7 +626,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
private func sshRouteHopConfiguration(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
for host: Host, proxySubmission: SSHProxyAuthenticationSubmission?,
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy,
accessedIdentityIDs: inout Set<Identity.ID>
) throws -> SSHRouteHopConfiguration {
@@ -606,7 +650,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
username: username, credentials: resolution.credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
for: host, submission: proxySubmission,
accessedIdentityIDs: &accessedIdentityIDs
),
authenticationReference: additionalAuthenticationReference(
host: host, submission: nil
@@ -616,6 +661,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
private func sshRouteHopConfiguration(
for host: Host, submission: SSHAuthenticationSubmission,
proxySubmission: SSHProxyAuthenticationSubmission?,
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy,
accessedIdentityIDs: inout Set<Identity.ID>
@@ -635,7 +681,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
username: username, credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
for: host, submission: proxySubmission,
accessedIdentityIDs: &accessedIdentityIDs
),
authenticationReference: additionalAuthenticationReference(
host: host, submission: submission
@@ -703,35 +750,52 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
public func transportProxy(for host: Host) throws -> SSHTransportProxy? {
try transportProxy(for: host, submission: nil)
}
public func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy? {
var accessedIdentityIDs: Set<Identity.ID> = []
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
for: host, submission: submission, accessedIdentityIDs: &accessedIdentityIDs
)
}
private func transportProxy(
for host: Host, accessedIdentityIDs: inout Set<Identity.ID>
for host: Host, submission: SSHProxyAuthenticationSubmission?,
accessedIdentityIDs: inout Set<Identity.ID>
) throws -> SSHTransportProxy? {
guard let proxy = host.proxy else { return nil }
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password: SSHSecret?
if let identityID = proxy.identityID {
var password = submission?.passwordForConnection()
if password == nil, let identityID = submission?.identityID ?? proxy.identityID {
guard let identity = identity(identityID), identity.usesPassword else {
throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable."))
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false,
message: String(localized: "The proxy password identity is unavailable."))
}
do {
password = try passwordSecret(for: identity)
} catch {
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false, message: error.localizedDescription)
}
password = try passwordSecret(for: identity)
guard password != nil else {
throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable."))
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false,
message: String(localized: "The proxy password is unavailable."))
}
accessedIdentityIDs.insert(identity.id)
}
return SSHTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: proxy.username, password: password,
identityID: proxy.identityID
username: submission?.username ?? proxy.username, password: password,
identityID: submission?.identityID ?? proxy.identityID,
ownerHostID: host.id
)
}
@@ -66,6 +66,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
private var generations: [UUID: UUID] = [:]
private var samplers: [UUID: any MetricsSampling] = [:]
private var submissions: [UUID: [UUID: SSHAuthenticationSubmission]] = [:]
private var proxySubmissions: [UUID: [UUID: SSHProxyAuthenticationSubmission]] = [:]
private var maintenance: Task<Void, Never>?
private var detailObservers: [UUID: UUID] = [:]
private var temporaryKeys: [UUID: [UUID: SSHHostKeyIdentity]] = [:]
@@ -256,8 +257,28 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
) {
guard var host = server.authenticationHost else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
let isProxy = server.authenticationPrompt?.isProxy == true
let name = ((isProxy ? nil : identity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: name, password: password, identityID: identity?.id)
do {
if save {
host = try hosts.saveProxyAuthentication(
submission, for: host, selectedIdentity: identity,
suppliedPassword: password)
}
proxySubmissions[server.id, default: [:]][host.id] = submission
retry(server)
} catch {
server.status = error.localizedDescription
server.collectionState = .failed
server.journey.fail(error.localizedDescription, at: host.id)
}
return
}
if server.authenticationPrompt?.requiresPassphrase == true,
var staged = submissions[server.id]?[host.id]
{
@@ -309,17 +330,45 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
private func preparation(
for host: Host, server: ServerMetric,
submissions: [UUID: SSHAuthenticationSubmission]
submissions: [UUID: SSHAuthenticationSubmission],
proxySubmissions: [UUID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try hosts.prepareSSHConnection(
for: host,
submissions: submissions,
proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryKeys[server.id] ?? [:]
)
}
private func requestAuthentication(_ server: ServerMetric, hostID: UUID, error: TermindSSHError) {
guard let host = hosts.host(hostID) else { return }
if error.isProxyAuthentication, let proxy = host.proxy {
let staged = proxySubmissions[server.id]?[host.id]
let configured = (staged?.identityID ?? proxy.identityID).flatMap { hosts.identity($0) }
let selected: Identity?
if let configured {
switch hosts.passwordAvailability(for: configured) {
case .available, .notConfigured: selected = configured
case .missing, .failed: selected = nil
}
} else {
selected = nil
}
server.authenticationHost = host
server.authenticationPrompt = SSHAuthenticationPrompt(
host: Host(
id: host.id, name: String(localized: "Proxy for \(host.name)"),
host: proxy.hostname, port: proxy.port),
selectedIdentityID: selected?.id,
username: staged?.username ?? proxy.username,
failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity,
target: .proxy(ownerHostID: host.id))
server.needsAttention = true
server.journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription)
return
}
let staged = submissions[server.id]?[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
@@ -346,6 +395,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
public func suspend() {
sleeping = true
submissions.removeAll()
proxySubmissions.removeAll()
for server in servers {
cancel(server.id)
server.isConnecting = false
@@ -379,6 +429,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
let stamp = connectionStamp(host)
if let old = stamps[server.id], old != stamp {
submissions[server.id] = nil
proxySubmissions[server.id] = nil
temporaryKeys[server.id] = nil
retry(server)
}
@@ -448,6 +499,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
if !selection.enabled {
submissions[server.id] = nil
proxySubmissions[server.id] = nil
temporaryKeys[server.id] = nil
server.journey.hide()
}
@@ -462,6 +514,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
cancel(server.id)
if !configuredIDs.contains(server.id) {
submissions[server.id] = nil
proxySubmissions[server.id] = nil
temporaryKeys[server.id] = nil
stamps[server.id] = nil
detailObservers = detailObservers.filter { $0.value != server.id }
@@ -686,10 +739,14 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
)
}
var retained = submissions[server.id] ?? [:]
var retainedProxies = proxySubmissions[server.id] ?? [:]
let attemptSubmissions = retained.takeAuthenticationSecrets()
let attemptProxySubmissions = retainedProxies.takeProxyAuthenticationSecrets()
submissions[server.id] = retained
proxySubmissions[server.id] = retainedProxies
let preparation = try preparation(
for: host, server: server, submissions: attemptSubmissions
for: host, server: server, submissions: attemptSubmissions,
proxySubmissions: attemptProxySubmissions
)
let sampler = makeSampler()
samplers[serverID] = sampler
@@ -408,6 +408,7 @@ public nonisolated struct SSHCredentialResolution: Sendable {
public nonisolated enum TermindSSHError: LocalizedError, Sendable {
case socket(String)
case proxy(String)
case proxyAuthentication(hostID: Host.ID, serverChallenge: Bool, message: String)
case route(String)
case routeHost(
hostID: Host.ID, authentication: Bool, serverChallenge: Bool,
@@ -429,6 +430,7 @@ public nonisolated enum TermindSSHError: LocalizedError, Sendable {
case .socket(let message), .proxy(let message), .route(let message),
.library(let message), .authenticationFailed(let message):
message
case .proxyAuthentication(_, _, let message): message
case .handshake(let failure): failure.localizedDescription
case .routeHost(_, _, _, _, let message): message
case .hostKeyChanged(let expected, let received):
@@ -445,6 +447,7 @@ public nonisolated enum TermindSSHError: LocalizedError, Sendable {
}
public var shouldRequestAuthentication: Bool {
if case .proxyAuthentication = self { return true }
if case .authenticationFailed = self { return true }
if case .routeHost(_, let authentication, _, _, _) = self { return authentication }
return false
@@ -453,21 +456,28 @@ public nonisolated enum TermindSSHError: LocalizedError, Sendable {
public var isLocalAuthenticationRequirement: Bool {
switch self {
case .missingCredential, .keyPassphraseRequired, .missingUsername: true
case .proxyAuthentication(_, let serverChallenge, _): !serverChallenge
default: false
}
}
public var isServerAuthenticationChallenge: Bool {
if case .proxyAuthentication(_, let serverChallenge, _) = self { return serverChallenge }
if case .authenticationFailed = self { return true }
if case .routeHost(_, _, let serverChallenge, _, _) = self { return serverChallenge }
return false
}
public var routeHostID: Host.ID? {
if case .proxyAuthentication(let id, _, _) = self { return id }
guard case .routeHost(let id, _, _, _, _) = self else { return nil }
return id
}
public var isProxyAuthentication: Bool {
if case .proxyAuthentication = self { true } else { false }
}
public var shouldRequestHostKey: Bool {
guard case .routeHost(_, _, _, let hostKey, _) = self else { return false }
return hostKey
@@ -4,6 +4,8 @@ import TermindDomain
import struct TermindDomain.Host
public nonisolated enum SSHRouteEvent: Hashable, Sendable {
case proxyConnecting(Host.ID)
case proxyConnected(Host.ID)
case connecting(Host.ID)
case authenticating(Host.ID)
case authenticated(Host.ID)
@@ -73,6 +75,56 @@ public nonisolated struct SSHAuthenticationSubmission: Sendable {
}
public nonisolated struct SSHProxyAuthenticationSubmission: Sendable {
public var username: String
public var password: String?
public var identityID: Identity.ID?
public var requiresPasswordReentry = false
private var runtimePassword: SSHSecret?
public init(username: String, password: String? = nil, identityID: Identity.ID? = nil) {
self.username = username
self.password = password
self.identityID = identityID
}
public var hasPassword: Bool { password != nil || runtimePassword != nil }
public func passwordForConnection() -> SSHSecret? { runtimePassword ?? password.map(SSHSecret.init) }
public mutating func clearSecrets() {
if hasPassword { requiresPasswordReentry = true }
password = nil
runtimePassword?.clear()
runtimePassword = nil
}
fileprivate mutating func prepareForConnectionAttempt() {
if let password {
runtimePassword = SSHSecret(password)
self.password = nil
}
}
}
nonisolated extension Dictionary where Key == Host.ID, Value == SSHProxyAuthenticationSubmission {
public func firstHostRequiringProxyPassword(in route: [Host]) -> Host? {
route.first { self[$0.id]?.requiresPasswordReentry == true }
}
public mutating func takeProxyAuthenticationSecrets() -> Self {
var result: Self = [:]
for key in Array(keys) {
guard var retained = self[key] else { continue }
var attempt = retained
attempt.prepareForConnectionAttempt()
retained.clearSecrets()
self[key] = retained
result[key] = attempt
}
return result
}
}
nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticationSubmission {
public func firstHostRequiringPassword(in route: [Host]) -> Host? {
route.first { self[$0.id]?.requiresPasswordReentry == true }
@@ -10,9 +10,10 @@ public nonisolated struct SSHTransportProxy: Sendable {
public var username: String
public var password: SSHSecret?
public var identityID: Identity.ID?
public var ownerHostID: Host.ID?
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
password: SSHSecret? = nil, identityID: Identity.ID? = nil
password: SSHSecret? = nil, identityID: Identity.ID? = nil, ownerHostID: Host.ID? = nil
) {
self.kind = kind
self.hostname = hostname
@@ -20,15 +21,16 @@ public nonisolated struct SSHTransportProxy: Sendable {
self.username = username
self.password = password
self.identityID = identityID
self.ownerHostID = ownerHostID
}
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
password: String?, identityID: Identity.ID? = nil
password: String?, identityID: Identity.ID? = nil, ownerHostID: Host.ID? = nil
) {
self.init(
kind: kind, hostname: hostname, port: port, username: username,
password: password.map(SSHSecret.init), identityID: identityID
password: password.map(SSHSecret.init), identityID: identityID, ownerHostID: ownerHostID
)
}
@@ -52,7 +52,14 @@ public struct SSHAuthenticationForm: View {
self.submit = submit
self.cancel = cancel
self.usesContainerChrome = usesContainerChrome
_identityID = State(initialValue: prompt.selectedIdentityID)
let initialIdentityID = prompt.selectedIdentityID.flatMap { id -> Identity.ID? in
guard let identity = library.identity(id) else { return nil }
switch library.passwordAvailability(for: identity) {
case .missing, .failed: return nil
case .notConfigured, .available: return id
}
}
_identityID = State(initialValue: initialIdentityID)
_username = State(initialValue: prompt.username)
_keyID = State(initialValue: prompt.selectedKeyID)
}
@@ -64,10 +71,25 @@ public struct SSHAuthenticationForm: View {
private var canContinue: Bool {
!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (!needsPassphrase || !passphrase.isEmpty)
&& (!prompt.isProxy || !needsPasswordEntry || !password.isEmpty)
}
private var canSave: Bool {
guard canContinue, let selectedIdentity else { return canContinue }
switch library.passwordAvailability(for: selectedIdentity) {
case .missing, .failed: return false
case .notConfigured, .available: return true
}
}
private var needsPasswordEntry: Bool {
guard let selectedIdentity else { return true }
return library.passwordAvailability(for: selectedIdentity) != .available
}
private var title: LocalizedStringKey {
if needsPassphrase { return "Passphrase required" }
if prompt.isProxy { return "Proxy authentication required" }
return prompt.mode == .localIdentity
? "Identity required" : "Authentication required"
}
@@ -85,7 +107,9 @@ public struct SSHAuthenticationForm: View {
.connectionFormChrome(isEnabled: usesContainerChrome)
.background {
if prompt.keyboardInteractive == nil, let keyboard {
keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel)
keyboard.bridge(
canSubmit: canContinue,
submit: { commit(save: !needsPassphrase && canSave) }, cancel: cancel)
}
}
.onAppear {
@@ -144,36 +168,40 @@ public struct SSHAuthenticationForm: View {
}
}
}
if identityID == nil {
if identityID == nil || prompt.isProxy {
GridRow {
Text("Username").foregroundStyle(.secondary)
Text(prompt.isProxy ? "Proxy Username" : "Username").foregroundStyle(.secondary)
TextField("Username", text: usernameInput.singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
.focused($focusedField, equals: .username)
}
GridRow {
Text("Password").foregroundStyle(.secondary)
SecureField("Optional", text: passwordInput)
.focused($focusedField, equals: .password)
}
GridRow {
Text("Key").foregroundStyle(.secondary)
fullWidthMenu(title: keyLabel) {
Button {
keySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: "None"), selected: keyID == nil
)
}
if !library.keys.isEmpty { Divider() }
ForEach(library.keys) { key in
Button {
keySelection.wrappedValue = key.id
} label: {
selectionMenuLabel(
key.label, selected: keyID == key.id
)
if needsPasswordEntry {
GridRow {
Text("Password").foregroundStyle(.secondary)
SecureField(prompt.isProxy ? "Required" : "Optional", text: passwordInput)
.focused($focusedField, equals: .password)
}
if !prompt.isProxy {
GridRow {
Text("Key").foregroundStyle(.secondary)
fullWidthMenu(title: keyLabel) {
Button {
keySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: "None"), selected: keyID == nil
)
}
if !library.keys.isEmpty { Divider() }
ForEach(library.keys) { key in
Button {
keySelection.wrappedValue = key.id
} label: {
selectionMenuLabel(
key.label, selected: keyID == key.id
)
}
}
}
}
}
@@ -255,7 +283,7 @@ public struct SSHAuthenticationForm: View {
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canContinue)
.disabled(!canSave)
}
private func updateFocusForRequirement() {
@@ -342,7 +370,7 @@ public struct SSHAuthenticationForm: View {
} set: { id in
identityID = id
guard let identity = id.flatMap({ library.identity($0) }) else { return }
username = identity.username
if !prompt.isProxy { username = identity.username }
password = ""
passphrase = ""
keyID = identity.keyID
@@ -354,7 +382,7 @@ public struct SSHAuthenticationForm: View {
username
} set: { value in
username = value
if value != selectedIdentity?.username { identityID = nil }
if !prompt.isProxy, value != selectedIdentity?.username { identityID = nil }
}
}
@@ -363,7 +391,6 @@ public struct SSHAuthenticationForm: View {
password
} set: { value in
password = value
if !value.isEmpty { identityID = nil }
}
}
@@ -169,6 +169,7 @@ public final class AgentHostResolver: Sendable {
fileprivate struct InteractiveResolution: Sendable {
var configuration: SSHConnectionConfiguration
var submissions: [Host.ID: SSHAuthenticationSubmission]
var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
}
@@ -180,8 +181,10 @@ public final class AgentHostResolver: Sendable {
invocationID: AgentToolInvocation.ID,
interaction: AgentSSHInteraction,
submissions initial: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions initialProxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys initialTemporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
forcedHostID initialForcedHostID: Host.ID? = nil,
forcedProxy initialForcedProxy: Bool = false,
failure initialFailure: String? = nil
) async throws -> InteractiveResolution {
guard let destination = store.host(id) else {
@@ -190,6 +193,7 @@ public final class AgentHostResolver: Sendable {
if let configuration = try temporaryConfiguration(id) {
return InteractiveResolution(
configuration: configuration, submissions: initial,
proxySubmissions: initialProxySubmissions,
temporaryHostKeys: initialTemporaryHostKeys
)
}
@@ -197,14 +201,56 @@ public final class AgentHostResolver: Sendable {
}
var submissions = initial
var proxySubmissions = initialProxySubmissions
let temporaryHostKeys = initialTemporaryHostKeys
var forcedHostID = initialForcedHostID
var forcedProxy = initialForcedProxy
var failure = initialFailure
// Match Terminal and SFTP: resolve local credential requirements first,
// then begin the network handshake and host-key verification. In
// particular, a protected key must raise Passphrase even when the peer
// later fails before returning its SSH banner.
for node in store.route(for: destination) {
while proxyAuthenticationRequired(
for: node, submissions: proxySubmissions,
forcedHostID: forcedHostID, forcedProxy: forcedProxy
) {
guard let proxy = node.proxy else { break }
let existing = proxySubmissions[node.id]
let configuredIdentity = (existing?.identityID ?? proxy.identityID).flatMap(store.identity)
let selectedIdentity: Identity?
if let configuredIdentity {
switch store.passwordAvailability(for: configuredIdentity) {
case .available, .notConfigured: selectedIdentity = configuredIdentity
case .missing, .failed: selectedIdentity = nil
}
} else {
selectedIdentity = nil
}
let request = AgentSSHAuthenticationRequest(
invocationID: invocationID, hostID: node.id,
displayName: String(localized: "Proxy for \(node.name)"),
address: "\(proxy.hostname):\(proxy.port)", mode: .proxy(ownerHostID: node.id),
selectedIdentityID: selectedIdentity?.id,
username: existing?.username ?? proxy.username, failureMessage: failure)
switch await interaction.authenticate(request) {
case .cancelled, .keyboardInteractive:
throw AgentToolError.cancelled
case .submit(let answer):
let submission = SSHProxyAuthenticationSubmission(
username: answer.username, password: answer.password, identityID: answer.identityID)
if answer.save {
_ = try store.saveProxyAuthentication(
submission, for: node,
selectedIdentity: answer.identityID.flatMap(store.identity),
suppliedPassword: answer.password)
}
proxySubmissions[node.id] = submission
}
forcedHostID = nil
forcedProxy = false
failure = nil
}
while let requirement = authenticationRequirement(
for: node, submissions: submissions, forcedHostID: forcedHostID
) {
@@ -243,6 +289,7 @@ public final class AgentHostResolver: Sendable {
}
}
forcedHostID = nil
forcedProxy = false
failure = nil
}
@@ -253,13 +300,33 @@ public final class AgentHostResolver: Sendable {
return InteractiveResolution(
configuration: try store.sshConnectionConfiguration(
for: destination, submissions: attemptSubmissions,
proxySubmissions: proxySubmissions.takeProxyAuthenticationSecrets(),
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
submissions: retainedSubmissions, temporaryHostKeys: temporaryHostKeys
submissions: retainedSubmissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys
)
}
private func proxyAuthenticationRequired(
for host: Host, submissions: [Host.ID: SSHProxyAuthenticationSubmission],
forcedHostID: Host.ID?, forcedProxy: Bool
) -> Bool {
guard let proxy = host.proxy else { return false }
if forcedProxy, forcedHostID == host.id { return true }
if let submission = submissions[host.id] {
return submission.requiresPasswordReentry
}
guard let identityID = proxy.identityID, let identity = store.identity(identityID) else {
return proxy.identityID != nil
}
switch store.passwordAvailability(for: identity) {
case .available: return false
case .notConfigured, .missing, .failed: return true
}
}
fileprivate func confirmHostKey(
_ challenge: SSHHostKeyChallenge,
invocationID: AgentToolInvocation.ID,
@@ -471,11 +538,13 @@ public actor AgentSSHAuthenticationContext {
private let invocationID: AgentToolInvocation.ID
private let interaction: AgentSSHInteraction
private var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
private var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
private var cached: SSHConnectionConfiguration?
private var generation: UInt64 = 0
private var pending: Pending?
private var forcedHostID: Host.ID?
private var forcedProxy = false
private var failure: String?
private struct PendingHostKey {
var fingerprint: String
@@ -508,14 +577,17 @@ public actor AgentSSHAuthenticationContext {
let invocationID = invocationID
let interaction = interaction
let submissions = submissions
let proxySubmissions = proxySubmissions
let temporaryHostKeys = temporaryHostKeys
let forcedHostID = forcedHostID
let forcedProxy = forcedProxy
let failure = failure
let task = Task {
try await resolver.interactiveConfiguration(
for: hostID, invocationID: invocationID, interaction: interaction,
submissions: submissions, temporaryHostKeys: temporaryHostKeys,
forcedHostID: forcedHostID, failure: failure
submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
forcedHostID: forcedHostID, forcedProxy: forcedProxy, failure: failure
)
}
active = Pending(generation: currentGeneration, task: task)
@@ -526,9 +598,11 @@ public actor AgentSSHAuthenticationContext {
let resolution = try await active.task.value
if generation == active.generation {
submissions = resolution.submissions
proxySubmissions = resolution.proxySubmissions
temporaryHostKeys = resolution.temporaryHostKeys
cached = resolution.configuration
forcedHostID = nil
forcedProxy = false
failure = nil
if pending?.generation == active.generation { pending = nil }
}
@@ -570,12 +644,14 @@ public actor AgentSSHAuthenticationContext {
pending?.task.cancel()
pending = nil
forcedHostID = target
forcedProxy = error.isProxyAuthentication
failure = error.localizedDescription
}
public func didAuthenticate(generation authenticatedGeneration: UInt64) {
guard authenticatedGeneration == generation else { return }
submissions.removeAll()
proxySubmissions.removeAll()
cached?.clearSecrets()
cached = nil
pending?.task.cancel()
@@ -15,6 +15,7 @@ nonisolated enum RemoteLogging {
switch error {
case .socket: return "socket"
case .proxy: return "proxy"
case .proxyAuthentication: return "proxy_authentication"
case .route: return "route"
case .routeHost(_, let authentication, _, let hostKey, _):
if authentication { return "route_authentication" }
@@ -71,6 +71,7 @@ public final class PortForwardingConnection {
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>?
@@ -255,8 +256,32 @@ public final class PortForwardingController {
var host = hosts.host(prompt.host.id)
else { return }
let identity = identityID.flatMap(hosts.identity)
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
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, var staged = context.submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
@@ -327,6 +352,15 @@ public final class PortForwardingController {
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,
@@ -355,6 +389,7 @@ public final class PortForwardingController {
}
}
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
@@ -393,6 +428,7 @@ public final class PortForwardingController {
} else {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: attemptSubmissions,
proxySubmissions: attemptProxySubmissions,
temporaryHostKeys: context.connection.temporaryHostKeys)
configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
}
@@ -436,6 +472,7 @@ public final class PortForwardingController {
}
}
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) }
@@ -496,6 +533,32 @@ public final class PortForwardingController {
fail(context, message: error.localizedDescription)
return
}
if error.isProxyAuthentication, let proxy = host.proxy {
let staged = context.proxySubmissions.removeValue(forKey: host.id)
let configured = (staged?.identityID ?? proxy.identityID).flatMap(hosts.identity)
let selected: Identity?
if let configured {
switch hosts.passwordAvailability(for: configured) {
case .available, .notConfigured: selected = configured
case .missing, .failed: selected = nil
}
} else {
selected = nil
}
context.setPrompt(
SSHAuthenticationPrompt(
host: Host(
id: host.id, name: String(localized: "Proxy for \(host.name)"),
host: proxy.hostname, port: proxy.port),
selectedIdentityID: selected?.id,
username: staged?.username ?? proxy.username,
failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity,
target: .proxy(ownerHostID: host.id)))
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
@@ -630,6 +693,7 @@ public final class PortForwardingController {
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 }
@@ -127,12 +127,15 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable {
)
return try await withTaskCancellationHandler {
do {
let firstProxy = jumps.first?.proxy ?? proxy
if let id = firstProxy?.ownerHostID { progress?(.proxyConnecting(id)) }
try await queue.perform {
try state.openFirst(
destinationHost: host, destinationPort: port,
destinationProxy: proxy, jumps: jumps, timeout: timeout
)
}
if let id = firstProxy?.ownerHostID { progress?(.proxyConnected(id)) }
for index in jumps.indices {
try Task.checkCancellation()
let hop = jumps[index]
@@ -361,11 +364,18 @@ private nonisolated final class SSHRouteOpeningState: @unchecked Sendable {
setCancellableSocket(currentSocket)
if let nextProxy {
do {
if let id = nextProxy.ownerHostID { progress?(.proxyConnecting(id)) }
try SSHSocketConnector.negotiate(
socket: currentSocket, host: nextHost,
port: nextPort, proxy: nextProxy
)
if let id = nextProxy.ownerHostID { progress?(.proxyConnected(id)) }
} catch {
if error is TermindSSHError,
(error as? TermindSSHError)?.isProxyAuthentication == true
{
throw error
}
throw TermindSSHError.route(
"Proxy after Jump Host \(level) (\(hop.name)): \(error.localizedDescription)"
)
@@ -129,6 +129,11 @@ public nonisolated enum SSHSocketConnector {
let fields = firstLine.split(separator: " ")
guard fields.count >= 2, let status = Int(fields[1]), (200..<300).contains(status) else {
let status = fields.count >= 2 ? String(fields[1]) : "invalid"
if status == "407", let hostID = proxy.ownerHostID {
throw TermindSSHError.proxyAuthentication(
hostID: hostID, serverChallenge: true,
message: String(localized: "HTTP proxy authentication failed."))
}
throw TermindSSHError.proxy("HTTP CONNECT failed with status \(status).")
}
}
@@ -140,10 +145,22 @@ public nonisolated enum SSHSocketConnector {
try writeAll(socket, Data(usesAuthentication ? [0x05, 0x01, 0x02] : [0x05, 0x01, 0x00]))
let selection = try readExactly(socket, count: 2)
guard selection[0] == 0x05, selection[1] != 0xff else {
if let hostID = proxy.ownerHostID {
throw TermindSSHError.proxyAuthentication(
hostID: hostID, serverChallenge: true,
message: usesAuthentication
? String(localized: "The SOCKS5 proxy rejected authentication.")
: String(localized: "The SOCKS5 proxy requires a password."))
}
throw TermindSSHError.proxy("The SOCKS5 proxy does not accept the configured authentication method.")
}
if selection[1] == 0x02 {
guard let password = proxy.password else {
if let hostID = proxy.ownerHostID {
throw TermindSSHError.proxyAuthentication(
hostID: hostID, serverChallenge: true,
message: String(localized: "The SOCKS5 proxy requires a password."))
}
throw TermindSSHError.proxy("The SOCKS5 proxy requires a password.")
}
let username = Data(proxy.username.utf8)
@@ -164,6 +181,11 @@ public nonisolated enum SSHSocketConnector {
}
let result = try readExactly(socket, count: 2)
guard result[1] == 0 else {
if let hostID = proxy.ownerHostID {
throw TermindSSHError.proxyAuthentication(
hostID: hostID, serverChallenge: true,
message: String(localized: "SOCKS5 proxy authentication failed."))
}
throw TermindSSHError.proxy("SOCKS5 proxy authentication failed.")
}
} else if selection[1] != 0x00 {
+5
View File
@@ -133,6 +133,11 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
temporary passwords never enter reusable snapshots. Username-only/SSH `none` remain eligible. Cancellation interrupts
opening. A terminal drop tries an independent SFTP preflight when durable references are available; any non-cancellation
failure falls back to SFTP Channels on the already authenticated PTY connection without presenting a form.
- Each configured proxy is an explicit Journey endpoint owned by its SSH Host. Missing local proxy passwords and explicit
HTTP/SOCKS authentication rejection use the same local authentication surfaces as SSH across sessions, Metrics,
forwarding, Assistant and external MCP requests. Proxy submissions are separate from SSH submissions; snapshots retain
only proxy network data, username and Identity ID. An Identity whose password lookup is missing or failed opens as Custom
with the inherited username and cannot be overwritten while selected.
- Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret
reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/
process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply.
+77 -4
View File
@@ -77,6 +77,7 @@ final class MobileWorkspace: Identifiable {
set { connection.temporaryHostKeys = newValue }
}
private var submissions: [UUID: SSHAuthenticationSubmission] = [:]
private var proxySubmissions: [UUID: SSHProxyAuthenticationSubmission] = [:]
let sftpChannel: MobileSFTPChannelModel
let sftpEdits: SFTPEditService?
let transfers: SFTPTransferCoordinator?
@@ -277,6 +278,27 @@ final class MobileWorkspace: Identifiable {
guard authenticationRequired, let host = hosts.host(authenticationHostID ?? descriptor.hostID) else {
return nil
}
if let proxy = host.proxy, authenticationIsProxy {
let staged = proxySubmissions[host.id]
let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap { hosts.identity($0) }
let selectedIdentity: Identity?
if let configuredIdentity {
switch hosts.passwordAvailability(for: configuredIdentity) {
case .available, .notConfigured: selectedIdentity = configuredIdentity
case .missing, .failed: selectedIdentity = nil
}
} else {
selectedIdentity = nil
}
return SSHAuthenticationPrompt(
host: Host(
id: host.id, name: String(localized: "Proxy for \(host.name)"),
host: proxy.hostname, port: proxy.port),
selectedIdentityID: selectedIdentity?.id,
username: staged?.username ?? proxy.username,
failureMessage: connectionFailure, mode: authenticationMode,
target: .proxy(ownerHostID: host.id))
}
let staged = submissions[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
@@ -289,6 +311,8 @@ final class MobileWorkspace: Identifiable {
mode: authenticationMode)
}
private var authenticationIsProxy = false
var hostKeyPrompt: SSHHostKeyPrompt? { connection.pendingHostKey }
private func preparation(
@@ -306,8 +330,32 @@ final class MobileWorkspace: Identifiable {
) {
guard var host = hosts.host(authenticationHostID ?? descriptor.hostID) else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
let isProxy = authenticationPrompt?.isProxy == true
let name = ((isProxy ? nil : identity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: name, password: password, identityID: identity?.id)
do {
if save {
host = try hosts.saveProxyAuthentication(
submission, for: host, selectedIdentity: identity,
suppliedPassword: password)
}
proxySubmissions[host.id] = submission
} catch {
connectionFailure = error.localizedDescription
authenticationPrompt = makeAuthenticationPrompt()
return
}
authenticationRequired = false
authenticationIsProxy = false
authenticationPrompt = nil
journey.resume()
connect()
return
}
if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
@@ -559,6 +607,17 @@ final class MobileWorkspace: Identifiable {
purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
username: hosts.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection)
if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: route) {
authenticationRequired = true
authenticationIsProxy = true
authenticationMode = .localIdentity
authenticationHostID = proxyHost.id
username = proxySubmissions[proxyHost.id]?.username ?? proxyHost.proxy?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForProxyAuthentication(at: proxyHost.id)
busy = false
return
}
if let passwordHost = submissions.firstHostRequiringPassword(in: route) {
authenticationRequired = true
authenticationMode = .localIdentity
@@ -586,6 +645,7 @@ final class MobileWorkspace: Identifiable {
return
}
let attemptSubmissions = submissions.takeAuthenticationSecrets()
let attemptProxySubmissions = proxySubmissions.takeProxyAuthenticationSecrets()
let progressHandler = connection.progressHandler(attemptID: token) { [weak self] in
self?.generation == $0
}
@@ -595,7 +655,10 @@ final class MobileWorkspace: Identifiable {
defer { if generation == token { busy = false } }
guard generation == token, !Task.isCancelled else { return }
do {
let preparation = try preparation(for: host, submissions: attemptSubmissions)
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: attemptSubmissions,
proxySubmissions: attemptProxySubmissions,
temporaryHostKeys: temporaryKeys)
let config = try await preparation.resolve(
trustPolicy: .allowChallenge
)
@@ -676,6 +739,7 @@ final class MobileWorkspace: Identifiable {
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
}
submissions.removeAll()
proxySubmissions.removeAll()
let wasReconnecting = connection.markConnected()
connected = true
status = String(localized: "Connected")
@@ -707,12 +771,19 @@ final class MobileWorkspace: Identifiable {
authenticationMode =
sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
authenticationHostID = sshError.routeHostID ?? descriptor.hostID
authenticationIsProxy = sshError.isProxyAuthentication
if let endpoint = hosts.host(authenticationHostID ?? descriptor.hostID) {
username =
submissions[endpoint.id]?.username ?? hosts.identity(for: endpoint)?.username ?? ""
authenticationIsProxy
? (proxySubmissions[endpoint.id]?.username ?? endpoint.proxy?.username ?? "")
: (submissions[endpoint.id]?.username ?? hosts.identity(for: endpoint)?.username ?? "")
}
authenticationPrompt = makeAuthenticationPrompt()
if authenticationPrompt?.requiresPassphrase == true {
if authenticationIsProxy {
journey.waitForProxyAuthentication(
at: authenticationHostID ?? descriptor.hostID,
failure: error.localizedDescription)
} else if authenticationPrompt?.requiresPassphrase == true {
journey.waitForLocalPassphrase(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else if sshError.isServerAuthenticationChallenge {
@@ -836,6 +907,7 @@ final class MobileWorkspace: Identifiable {
connection.clearHostKeyPrompt()
temporaryKeys.removeAll()
submissions.removeAll()
proxySubmissions.removeAll()
connectionFailure = nil
operationError = nil
connection.suspendForForegroundResume(reconnecting: reconnecting)
@@ -938,6 +1010,7 @@ final class MobileWorkspace: Identifiable {
authenticationRequired = false
authenticationPrompt = nil
submissions.removeAll()
proxySubmissions.removeAll()
activeHistoryID = nil
pendingCommand = nil
@@ -31,43 +31,51 @@ extension TransferLibraryModel: RemoteSessionHostStore {
)
}
func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try library.prepareSSHConnection(for: host, submissions: submissions)
try library.prepareSSHConnection(
for: host, submissions: submissions, proxySubmissions: proxySubmissions)
}
func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration {
try library.sshConnectionConfiguration(
for: host, submissions: submissions,
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try library.sshJumpConfigurations(
for: host, submissions: submissions,
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try library.prepareSSHConnection(for: route, submissions: submissions)
try library.prepareSSHConnection(
for: route, submissions: submissions, proxySubmissions: proxySubmissions)
}
func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try library.sshJumpConfigurations(
for: route, submissions: submissions, temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
for: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
)
}
func saveConnectionRoute(
@@ -82,12 +90,20 @@ extension TransferLibraryModel: RemoteSessionHostStore {
func transportProxy(for host: Host) throws -> SSHTransportProxy? {
try library.transportProxy(for: host)
}
func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy? {
try library.transportProxy(for: host, submission: submission)
}
func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
library.sshCredentials(for: identity)
}
func passwordSecret(for identity: Identity) throws -> SSHSecret? {
try library.passwordSecret(for: identity)
}
func passwordAvailability(for identity: Identity) -> SecretAvailability {
library.passwordAvailability(for: identity)
}
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try library.keySecretCredential(for: key)
}
+71 -5
View File
@@ -158,6 +158,7 @@ final class SFTPChannelModel: Identifiable {
private var automaticRefreshTask: Task<Void, Never>?
private var uploadRefreshTask: Task<Void, Never>?
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:]
private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] {
get { connection.temporaryHostKeys }
set { connection.temporaryHostKeys = newValue }
@@ -314,6 +315,10 @@ final class SFTPChannelModel: Identifiable {
username: stagedAuthentication?.username ?? store.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection
)
if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) {
requestProxyAuthentication(host: proxyHost, store: store)
return
}
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route) {
requestAuthentication(
host: passwordHost, store: store,
@@ -335,6 +340,7 @@ final class SFTPChannelModel: Identifiable {
return
}
let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets()
let suppliedAuthentication = attemptAuthentications[host.id]
status = reconnecting ? .reconnecting : .connecting
hint =
@@ -379,9 +385,11 @@ final class SFTPChannelModel: Identifiable {
username: suppliedAuthentication.username,
credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try store.transportProxy(for: host),
proxy: try store.transportProxy(
for: host, submission: attemptProxyAuthentications[host.id]),
jumps: try store.sshJumpConfigurations(
for: host, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
@@ -399,6 +407,7 @@ final class SFTPChannelModel: Identifiable {
} else {
configuration = try store.sshConnectionConfiguration(
for: host, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
)
@@ -448,6 +457,7 @@ final class SFTPChannelModel: Identifiable {
snapshot: additionalSnapshot, hostStore: store, channel: client)
self.additionalSnapshot = additionalSnapshot
self.suppliedAuthentications.removeAll()
self.suppliedProxyAuthentications.removeAll()
self.temporaryHostKeys.removeAll()
self.pendingAuthentication = nil
let presentsConnectionJourney = self.connectionJourney.isPresented
@@ -479,6 +489,13 @@ final class SFTPChannelModel: Identifiable {
} else {
authenticationHost = host
}
if error.isProxyAuthentication {
self.requestProxyAuthentication(
host: authenticationHost, store: store,
failure: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
return
}
let submitted = self.suppliedAuthentications[authenticationHost.id]
let retryNeedsPassphrase =
submitted?.requiresPasswordReentry != true
@@ -526,6 +543,7 @@ final class SFTPChannelModel: Identifiable {
cancelPendingBatch()
pendingAuthentication = nil
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
connectionTask?.cancel()
connectionTask = nil
connectionAttemptID = nil
@@ -611,6 +629,30 @@ final class SFTPChannelModel: Identifiable {
let host = store.host(prompt.host.id)
else { return }
let selectedIdentity = identityID.flatMap { store.identity($0) }
let submittedUsername = ((prompt.isProxy ? nil : selectedIdentity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !submittedUsername.isEmpty else { return }
if prompt.isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: submittedUsername, password: password, identityID: selectedIdentity?.id)
do {
if save {
_ = try store.saveProxyAuthentication(
submission, for: host, selectedIdentity: selectedIdentity,
suppliedPassword: password)
}
suppliedProxyAuthentications[host.id] = submission
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
requestProxyAuthentication(host: host, store: store, failure: error.localizedDescription)
}
return
}
if prompt.requiresPassphrase,
var staged = suppliedAuthentications[host.id]
{
@@ -623,10 +665,6 @@ final class SFTPChannelModel: Identifiable {
return
}
let selectedIdentity = identityID.flatMap { store.identity($0) }
let submittedUsername = (selectedIdentity?.username ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !submittedUsername.isEmpty else { return }
let submittedKeyID = selectedIdentity?.keyID ?? keyID
let submission: SSHAuthenticationSubmission
do {
@@ -1075,6 +1113,34 @@ final class SFTPChannelModel: Identifiable {
}
}
private func requestProxyAuthentication(
host: Host, store: any RemoteSessionHostStore, failure: String? = nil,
mode: SSHAuthenticationPrompt.Mode = .localIdentity
) {
guard let proxy = host.proxy else { return }
connection.pauseForUserInteraction()
let staged = suppliedProxyAuthentications.removeValue(forKey: host.id)
let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap(store.identity)
let selectedIdentity: Identity?
if let configuredIdentity {
switch store.passwordAvailability(for: configuredIdentity) {
case .available, .notConfigured: selectedIdentity = configuredIdentity
case .missing, .failed: selectedIdentity = nil
}
} else {
selectedIdentity = nil
}
pendingAuthentication = SSHAuthenticationPrompt(
host: Host(
id: host.id, name: String(localized: "Proxy for \(host.name)"),
host: proxy.hostname, port: proxy.port),
selectedIdentityID: selectedIdentity?.id,
username: staged?.username ?? proxy.username, failureMessage: failure,
mode: mode, target: .proxy(ownerHostID: host.id))
status = .disconnected
connectionJourney.waitForProxyAuthentication(at: host.id, failure: failure)
}
private func progressHandler(attemptID: UUID) -> SSHRouteProgressHandler {
connection.progressHandler(attemptID: attemptID) { [weak self] id in
self?.connectionAttemptID == id
+83 -2
View File
@@ -237,6 +237,7 @@ final class SSHSessionModel: Identifiable {
private var transientHost: Host?
private var usernameHint: String?
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:]
private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] {
get { connection.temporaryHostKeys }
set { connection.temporaryHostKeys = newValue }
@@ -489,6 +490,10 @@ final class SSHSessionModel: Identifiable {
host: host, route: route, purpose: .sshSession, username: username,
mode: reconnecting ? .reconnection : .connection
)
if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) {
requestProxyAuthentication(for: proxyHost, using: store)
return
}
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route),
let submission = suppliedAuthentications[passwordHost.id]
{
@@ -516,6 +521,7 @@ final class SSHSessionModel: Identifiable {
return
}
let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets()
let suppliedAuthentication = attemptAuthentications[host.id]
var credentials: [SSHCredentials] = []
var credentialFailures: [String] = []
@@ -604,15 +610,18 @@ final class SSHSessionModel: Identifiable {
let jumps: [SSHRouteHopConfiguration]
let expectedHostKeys: Set<SSHHostKeyIdentity>
do {
proxy = try store.transportProxy(for: host)
proxy = try store.transportProxy(
for: host, submission: attemptProxyAuthentications[host.id])
if let connectionRoute {
jumps = try store.sshJumpConfigurations(
for: connectionRoute, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
} else {
jumps = try store.sshJumpConfigurations(
for: host, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
}
@@ -626,6 +635,14 @@ final class SSHSessionModel: Identifiable {
let id = sshError.routeHostID,
let jump = connectionHost(id, using: store)
{
if sshError.isProxyAuthentication {
connectionAttemptID = nil
requestProxyAuthentication(
for: jump, using: store,
failureMessage: sshError.localizedDescription,
mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
return
}
let identity = store.identity(for: jump)
let submitted = suppliedAuthentications[jump.id]
let retryNeedsPassphrase =
@@ -703,6 +720,13 @@ final class SSHSessionModel: Identifiable {
authenticationHost = host
}
let authenticationIdentity = store.identity(for: authenticationHost)
if sshError.isProxyAuthentication {
requestProxyAuthentication(
for: authenticationHost, using: store,
failureMessage: sshError.localizedDescription,
mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
return
}
let submitted = suppliedAuthentications[authenticationHost.id]
let authenticationKeyID =
submitted?.keyID
@@ -741,13 +765,33 @@ final class SSHSessionModel: Identifiable {
using store: any RemoteSessionHostStore
) {
let selectedIdentity = identityID.flatMap { store.identity($0) }
let trimmedUsername = (selectedIdentity?.username ?? username)
let trimmedUsername = ((pendingAuthentication?.isProxy == true ? nil : selectedIdentity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUsername.isEmpty else { return }
guard let prompt = pendingAuthentication,
var host = connectionHost(prompt.host.id, using: store)
else { return }
if prompt.isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: trimmedUsername, password: password, identityID: selectedIdentity?.id)
do {
if save {
host = try store.saveProxyAuthentication(
submission, for: host, selectedIdentity: selectedIdentity,
suppliedPassword: password)
}
suppliedProxyAuthentications[host.id] = submission
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
requestProxyAuthentication(
for: host, using: store, failureMessage: error.localizedDescription)
}
return
}
if prompt.requiresPassphrase,
var staged = suppliedAuthentications[host.id]
{
@@ -817,6 +861,7 @@ final class SSHSessionModel: Identifiable {
commandExcludedFromHistory = nil
pendingAuthentication = nil
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
connectionAttemptID = nil
connection.disconnect()
connectionTask?.cancel()
@@ -895,6 +940,7 @@ final class SSHSessionModel: Identifiable {
statusStore?.additionalConnectionSnapshot(from: configuration)
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
temporaryHostKeys.removeAll()
_ = connection.markConnected()
status = .connected
@@ -1033,6 +1079,37 @@ final class SSHSessionModel: Identifiable {
}
}
private func requestProxyAuthentication(
for host: Host, using store: any RemoteSessionHostStore,
failureMessage: String? = nil,
mode: SSHAuthenticationPrompt.Mode = .localIdentity
) {
guard let proxy = host.proxy else { return }
connection.pauseForUserInteraction()
let staged = suppliedProxyAuthentications[host.id]
let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap(store.identity)
let selectedIdentity: Identity?
if let configuredIdentity {
switch store.passwordAvailability(for: configuredIdentity) {
case .available, .notConfigured: selectedIdentity = configuredIdentity
case .missing, .failed: selectedIdentity = nil
}
} else {
selectedIdentity = nil
}
suppliedProxyAuthentications[host.id] = nil
pendingAuthentication = SSHAuthenticationPrompt(
host: Host(
id: host.id, name: String(localized: "Proxy for \(host.name)"),
host: proxy.hostname, port: proxy.port),
selectedIdentityID: selectedIdentity?.id,
username: staged?.username ?? proxy.username,
failureMessage: failureMessage, mode: mode,
target: .proxy(ownerHostID: host.id))
status = .disconnected
connectionJourney.waitForProxyAuthentication(at: host.id, failure: failureMessage)
}
private func progressHandler(attemptID: UUID) -> SSHRouteProgressHandler {
connection.progressHandler(attemptID: attemptID) { [weak self] id in
self?.connectionAttemptID == id
@@ -1051,6 +1128,10 @@ final class SSHSessionModel: Identifiable {
uniqueKeysWithValues: suppliedAuthentications.map {
(mapping[$0.key] ?? $0.key, $0.value)
})
suppliedProxyAuthentications = Dictionary(
uniqueKeysWithValues: suppliedProxyAuthentications.map {
(mapping[$0.key] ?? $0.key, $0.value)
})
temporaryHostKeys = Dictionary(
uniqueKeysWithValues: temporaryHostKeys.map {
(mapping[$0.key] ?? $0.key, $0.value)
@@ -15,6 +15,42 @@ import struct TermindDomain.Host
@Suite(.serialized)
@MainActor
struct AgentHostAuthenticationTests {
@Test
func proxyAuthenticationUsesTheAgentInteractionWithoutChangingItsUsername() async throws {
let sshIdentity = Identity(name: "SSH", username: "deploy")
let missingProxy = Identity(
name: "Proxy", username: "identity-user",
credentialRef: "termind.password.proxy-missing")
let host = Host(
name: "Server", host: "server.example", identityID: sshIdentity.id,
proxy: HostProxy(
hostname: "proxy.example", username: "proxy-user",
identityID: missingProxy.id))
let store = HostLibraryModel(
hosts: [host], identities: [sshIdentity, missingProxy],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
secrets: MemoryHostSecretStore())
let interaction = AuthenticationResponder { request in
guard case .proxy(let ownerHostID) = request.mode else {
return .cancelled
}
#expect(ownerHostID == host.id)
#expect(request.selectedIdentityID == nil)
#expect(request.username == "proxy-user")
return .submit(
AgentSSHAuthenticationAnswer(
username: request.username, password: "one-use", save: false))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction)
let configuration = try await session.configuration()
#expect(configuration.proxy?.username == "proxy-user")
#expect(try #require(configuration.proxy?.password) == "one-use")
#expect(store.host(host.id)?.proxy?.identityID == missingProxy.id)
}
@Test func usernameOnlyIdentityDoesNotRequireCredentialEntry() async throws {
let identity = Identity(name: "OTP", username: "deploy")
let host = Host(name: "Server", host: "server.example", identityID: identity.id)
@@ -179,6 +215,8 @@ struct AgentHostAuthenticationTests {
))
case .keyboardInteractive:
return .cancelled
case .proxy:
return .cancelled
case .passphrase(let keyID):
#expect(keyID == key.id)
return .submit(