diff --git a/FrameworkTests/SSHAuthenticationSubmissionTests.swift b/FrameworkTests/SSHAuthenticationSubmissionTests.swift index f97ea0b2..0c8f7bfb 100644 --- a/FrameworkTests/SSHAuthenticationSubmissionTests.swift +++ b/FrameworkTests/SSHAuthenticationSubmissionTests.swift @@ -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() diff --git a/FrameworkTests/SSHConnectionPreparationTests.swift b/FrameworkTests/SSHConnectionPreparationTests.swift index ab03f64d..e1c2e0ae 100644 --- a/FrameworkTests/SSHConnectionPreparationTests.swift +++ b/FrameworkTests/SSHConnectionPreparationTests.swift @@ -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"), diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 07393f77..4a011f36 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" -} \ No newline at end of file +} diff --git a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift index 4c8d4c47..31807b03 100644 --- a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift +++ b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift @@ -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 ) } diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index 7c9697e9..4a037318 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -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 diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index 6a8e2803..486f382e 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -19,23 +19,28 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { trustPolicy: SSHHostTrustPolicy ) throws -> Set 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, 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 + } } diff --git a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift index f6ac312a..813fce5d 100644 --- a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift +++ b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift @@ -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 ) } diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index b1f595bf..01f85124 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -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 } } } diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index bf07ba43..660dfb0f 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -15,9 +15,11 @@ public nonisolated struct SSHConnectionPreparation: Sendable { public var submission: SSHAuthenticationSubmission? public var trustedKeys: Set public var proxyIdentity: Identity? + public var proxySubmission: SSHProxyAuthenticationSubmission? public init( host: Host, identity: Identity?, key: SSHKeyRef?, submission: SSHAuthenticationSubmission?, - trustedKeys: Set, proxyIdentity: Identity? + trustedKeys: Set, 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 ) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index a490f1f8..9e924c87 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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 = [] 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 @@ -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 = [] 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 @@ -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 ) 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 ) 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 @@ -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 = [] 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 + for host: Host, submission: SSHProxyAuthenticationSubmission?, + accessedIdentityIDs: inout Set ) 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 ) } diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index e048d5fa..a49cd6c9 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -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? 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 diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index fcba3ea9..56a9aebb 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -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 diff --git a/Frameworks/TermindApplication/SSHRouteEvent.swift b/Frameworks/TermindApplication/SSHRouteEvent.swift index 7931fb43..f50fd19d 100644 --- a/Frameworks/TermindApplication/SSHRouteEvent.swift +++ b/Frameworks/TermindApplication/SSHRouteEvent.swift @@ -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 } diff --git a/Frameworks/TermindApplication/SSHTransportProxy.swift b/Frameworks/TermindApplication/SSHTransportProxy.swift index 354336a0..eabb2ede 100644 --- a/Frameworks/TermindApplication/SSHTransportProxy.swift +++ b/Frameworks/TermindApplication/SSHTransportProxy.swift @@ -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 ) } diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index a2243ba3..016f2b67 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -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 } } } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 734f8a21..40c4b44a 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -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() diff --git a/Frameworks/TermindRemoteInfrastructure/Logging/RemoteLogging.swift b/Frameworks/TermindRemoteInfrastructure/Logging/RemoteLogging.swift index bb44487d..d637e80e 100644 --- a/Frameworks/TermindRemoteInfrastructure/Logging/RemoteLogging.swift +++ b/Frameworks/TermindRemoteInfrastructure/Logging/RemoteLogging.swift @@ -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" } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 98e4ca9f..348475c8 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -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? @@ -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 } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index aaf0538b..85aec3b1 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -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)" ) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift index c11b89fb..55ac0dfe 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift @@ -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 { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index b55d09e5..21ac1c5b 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 78f120d2..11992432 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index 375f30f4..63f89dba 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -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) } diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 54abac11..6b8a58c7 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -158,6 +158,7 @@ final class SFTPChannelModel: Identifiable { private var automaticRefreshTask: Task? private var uploadRefreshTask: Task? 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 diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 4240ca80..f5d88fbc 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 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) diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index b6d44a51..3b359ea2 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -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(