Add proxy identity choose UI
This commit is contained in:
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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user