diff --git a/FrameworkTests/RemoteConnectionURLTests.swift b/FrameworkTests/RemoteConnectionURLTests.swift new file mode 100644 index 00000000..a1952be6 --- /dev/null +++ b/FrameworkTests/RemoteConnectionURLTests.swift @@ -0,0 +1,76 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite +struct RemoteConnectionURLTests { + @Test + func parsesSSHAndLegacyFingerprint() throws { + let value = try RemoteConnectionURLParser.parse( + #require(URL(string: "ssh://deploy;fingerprint=ssh-rsa-00-11-22-33-44-55-66-77-88-99-aa-bb-cc-dd-ee-ff@example.com:2222/ignored")) + ) + #expect(value.kind == .ssh) + #expect(value.username == "deploy") + #expect(value.host == "example.com") + #expect(value.port == 2222) + #expect(value.path == nil) + #expect(value.hostKeyFingerprint?.algorithm == .rsa) + #expect(value.hostKeyFingerprint?.md5 == Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])) + } + + @Test + func parsesSFTPDirectoryAndIgnoresUnknownParameters() throws { + let value = try RemoteConnectionURLParser.parse( + #require(URL(string: "sftp://user@[2001:db8::1]/~/Documents/report%20one;unknown=x,type=d")) + ) + #expect(value.kind == .sftp) + #expect(value.host == "2001:db8::1") + #expect(value.port == 22) + #expect(value.path == "/~/Documents/report one") + #expect(value.sftpType == .directory) + } + + @Test + func parsesTelnetAdvisoryCredentials() throws { + let value = try RemoteConnectionURLParser.parse( + #require(URL(string: "telnet://operator:p%40ss@example.com:2323/")) + ) + #expect(value.kind == .telnet) + #expect(value.username == "operator") + #expect(value.advisoryPassword == "p@ss") + #expect(value.port == 2323) + } + + @Test(arguments: [ + "ssh://user:password@example.com", + "sftp://user:password@example.com/file", + "ssh://example.com?command=uptime", + "telnet://example.com/path", + "ssh://example.com:0", + "ssh://user;fingerprint=ssh-rsa-SHA256:abc@example.com", + "sftp://example.com/file;type=a", + ]) + func rejectsUnsupportedOrMalformedLinks(_ raw: String) { + #expect(throws: (any Error).self) { + try RemoteConnectionURLParser.parse(#require(URL(string: raw))) + } + } + + @Test + func legacyFingerprintMatchesOnlyTheSameRSAKey() throws { + let parsed = try RemoteConnectionURLParser.parse( + #require(URL(string: "ssh://u;fingerprint=ssh-rsa-00-11-22-33-44-55-66-77-88-99-aa-bb-cc-dd-ee-ff@host")) + ) + let fingerprint = try #require(parsed.hostKeyFingerprint) + let matching = SSHHostKeyObservation( + hostname: "host", port: 22, algorithm: .rsa, fingerprint: "SHA256:test", + legacyMD5Fingerprint: fingerprint.md5) + let different = SSHHostKeyObservation( + hostname: "host", port: 22, algorithm: .rsa, fingerprint: "SHA256:test", + legacyMD5Fingerprint: Data(repeating: 0xff, count: 16)) + #expect(fingerprint.matches(matching)) + #expect(!fingerprint.matches(different)) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index cb0ed0ad..44287005 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1,6 +1,111 @@ { "sourceLanguage" : "en", "strings" : { + "Connection Link Failed" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "连接链接失败" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "連線連結失敗" } } + } + }, + "Open Telnet Connection?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "打开Telnet连接?" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "開啟Telnet連線?" } } + } + }, + "SFTP text transfer mode is not supported." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不支持SFTP文本传输模式。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "不支援SFTP文字傳輸模式。" } } + } + }, + "SSH and SFTP connection links cannot contain passwords." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "SSH和SFTP连接链接不能包含密码。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "SSH和SFTP連線連結不能包含密碼。" } } + } + }, + "The legacy fingerprint in the link matches this host key." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "链接中的旧版指纹与此主机密钥匹配。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "連結中的舊版指紋與此主機金鑰相符。" } } + } + }, + "The remote home directory is unavailable." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "远程主目录不可用。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "遠端個人專屬目錄無法使用。" } } + } + }, + "The SFTP link requires a directory, but its path is a file." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "SFTP链接要求目录,但路径指向文件。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "SFTP連結要求目錄,但路徑指向檔案。" } } + } + }, + "This connection link contains an invalid host-key fingerprint." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接包含无效的主机密钥指纹。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結包含無效的主機金鑰指紋。" } } + } + }, + "This connection link contains an unsupported standard parameter." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接包含不支持的标准参数。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結包含不支援的標準參數。" } } + } + }, + "This connection link does not contain a valid host." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接未包含有效的主机。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結未包含有效的主機。" } } + } + }, + "This connection link does not contain a valid port." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接未包含有效的端口。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結未包含有效的連接埠。" } } + } + }, + "This connection link is invalid." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接无效。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結無效。" } } + } + }, + "This connection link uses an unsupported protocol." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此连接链接使用了不支持的协议。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連線連結使用了不支援的通訊協定。" } } + } + }, + "This link includes an advisory Telnet password. It will be used once and will not be saved." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此链接包含建议的Telnet密码。该密码仅使用一次且不会保存。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此連結包含建議的Telnet密碼。該密碼僅使用一次且不會儲存。" } } + } + }, + "This remote file may contain executable content. Review it before opening." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此远程文件可能包含可执行内容。请检查后再打开。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此遠端檔案可能包含可執行內容。請檢查後再開啟。" } } + } + }, "" : { "localizations" : { "zh-Hans" : { diff --git a/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift b/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift new file mode 100644 index 00000000..08d38b32 --- /dev/null +++ b/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift @@ -0,0 +1,238 @@ +import Foundation +import TermindDomain + +public nonisolated struct RemoteConnectionURL: Equatable, Sendable { + public enum Kind: String, Equatable, Sendable { + case ssh + case sftp + case telnet + + public var defaultPort: Int { + switch self { + case .ssh, .sftp: 22 + case .telnet: 23 + } + } + } + + public enum SFTPType: String, Equatable, Sendable { + case binary = "i" + case directory = "d" + } + + public var kind: Kind + public var username: String? + /// RFC 4248 credentials are advisory. SSH and SFTP URL passwords are rejected. + public var advisoryPassword: String? + public var host: String + public var port: Int + public var path: String? + public var sftpType: SFTPType? + public var hostKeyFingerprint: SSHURIHostKeyFingerprint? + + public init( + kind: Kind, username: String?, advisoryPassword: String?, host: String, port: Int, + path: String?, sftpType: SFTPType?, hostKeyFingerprint: SSHURIHostKeyFingerprint? + ) { + self.kind = kind + self.username = username + self.advisoryPassword = advisoryPassword + self.host = host + self.port = port + self.path = path + self.sftpType = sftpType + self.hostKeyFingerprint = hostKeyFingerprint + } +} + +public nonisolated struct SSHURIHostKeyFingerprint: Equatable, Sendable { + public enum Algorithm: String, Equatable, Sendable { + case rsa = "ssh-rsa" + case dsa = "ssh-dss" + } + + public var algorithm: Algorithm + public var md5: Data + + public init(algorithm: Algorithm, md5: Data) { + self.algorithm = algorithm + self.md5 = md5 + } + + public func matches(_ observation: SSHHostKeyObservation) -> Bool { + let algorithmMatches = + switch algorithm { + case .rsa: observation.algorithm == .rsa + case .dsa: false // The current transport deliberately does not negotiate ssh-dss. + } + guard algorithmMatches, let candidate = observation.legacyMD5Fingerprint, + candidate.count == md5.count + else { return false } + return zip(candidate, md5).reduce(UInt8(0)) { $0 | ($1.0 ^ $1.1) } == 0 + } +} + +public nonisolated enum RemoteConnectionURLParseError: LocalizedError, Equatable, Sendable { + case unsupportedScheme + case invalidURL + case invalidHost + case invalidPort + case invalidCredentials + case invalidFingerprint + case unsupportedSFTPTextMode + case unsupportedParameter + + public var errorDescription: String? { + switch self { + case .unsupportedScheme: String(localized: "This connection link uses an unsupported protocol.") + case .invalidURL: String(localized: "This connection link is invalid.") + case .invalidHost: String(localized: "This connection link does not contain a valid host.") + case .invalidPort: String(localized: "This connection link does not contain a valid port.") + case .invalidCredentials: + String(localized: "SSH and SFTP connection links cannot contain passwords.") + case .invalidFingerprint: String(localized: "This connection link contains an invalid host-key fingerprint.") + case .unsupportedSFTPTextMode: + String(localized: "SFTP text transfer mode is not supported.") + case .unsupportedParameter: + String(localized: "This connection link contains an unsupported standard parameter.") + } + } +} + +public nonisolated enum RemoteConnectionURLParser { + public static func parse(_ url: URL) throws -> RemoteConnectionURL { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let rawScheme = components.scheme, + let kind = RemoteConnectionURL.Kind(rawValue: rawScheme.lowercased()) + else { throw RemoteConnectionURLParseError.unsupportedScheme } + guard components.query == nil, components.fragment == nil else { + throw RemoteConnectionURLParseError.invalidURL + } + guard var host = components.host, !host.isEmpty else { + throw RemoteConnectionURLParseError.invalidHost + } + if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) } + guard !host.contains(where: { $0.isWhitespace || $0.isNewline || $0 == "\0" }) else { + throw RemoteConnectionURLParseError.invalidHost + } + let port = components.port ?? kind.defaultPort + guard (1...65_535).contains(port) else { throw RemoteConnectionURLParseError.invalidPort } + + let credentials = try parseCredentials(components, kind: kind) + let resource = try parseResource(components.percentEncodedPath, kind: kind) + return RemoteConnectionURL( + kind: kind, + username: credentials.username, + advisoryPassword: credentials.password, + host: host, + port: port, + path: resource.path, + sftpType: resource.type, + hostKeyFingerprint: credentials.fingerprint + ) + } + + private static func parseCredentials( + _ components: URLComponents, kind: RemoteConnectionURL.Kind + ) throws -> (username: String?, password: String?, fingerprint: SSHURIHostKeyFingerprint?) { + let password = try decode(components.percentEncodedPassword) + if kind != .telnet, password != nil { throw RemoteConnectionURLParseError.invalidCredentials } + + guard kind != .telnet else { + let username = try nonempty(decode(components.percentEncodedUser)) + return (username, try nonempty(password), nil) + } + + let pieces = (components.percentEncodedUser ?? "").split( + separator: ";", maxSplits: 1, omittingEmptySubsequences: false) + let username = try nonempty(decode(String(pieces.first ?? ""))) + guard pieces.count == 2 else { return (username, nil, nil) } + + var fingerprint: SSHURIHostKeyFingerprint? + for parameter in pieces[1].split(separator: ",", omittingEmptySubsequences: false) { + let pair = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, !pair[0].isEmpty else { + throw RemoteConnectionURLParseError.invalidURL + } + guard pair[0] == "fingerprint" else { continue } + guard fingerprint == nil else { throw RemoteConnectionURLParseError.invalidFingerprint } + fingerprint = try parseFingerprint(String(pair[1])) + } + return (username, nil, fingerprint) + } + + private static func parseResource( + _ encodedPath: String, kind: RemoteConnectionURL.Kind + ) throws -> (path: String?, type: RemoteConnectionURL.SFTPType?) { + switch kind { + case .ssh: + return (nil, nil) + case .telnet: + guard encodedPath.isEmpty || encodedPath == "/" else { + throw RemoteConnectionURLParseError.invalidURL + } + return (nil, nil) + case .sftp: + let pathAndParameters = encodedPath.split( + separator: ";", maxSplits: 1, omittingEmptySubsequences: false) + guard let decodedPath = try decode(String(pathAndParameters[0])), decodedPath.hasPrefix("/") else { + throw RemoteConnectionURLParseError.invalidURL + } + var type: RemoteConnectionURL.SFTPType? + if pathAndParameters.count == 2 { + for parameter in pathAndParameters[1].split(separator: ",", omittingEmptySubsequences: false) { + let pair = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, !pair[0].isEmpty else { + throw RemoteConnectionURLParseError.invalidURL + } + guard pair[0] == "type" else { continue } + guard type == nil else { throw RemoteConnectionURLParseError.unsupportedParameter } + if pair[1] == "a" { throw RemoteConnectionURLParseError.unsupportedSFTPTextMode } + guard let parsed = RemoteConnectionURL.SFTPType(rawValue: String(pair[1])) else { + throw RemoteConnectionURLParseError.unsupportedParameter + } + type = parsed + } + } + return (decodedPath.isEmpty ? "/" : decodedPath, type) + } + } + + private static func parseFingerprint(_ value: String) throws -> SSHURIHostKeyFingerprint { + let algorithm: SSHURIHostKeyFingerprint.Algorithm + let digest: Substring + if value.hasPrefix("ssh-rsa-") { + algorithm = .rsa + digest = value.dropFirst("ssh-rsa-".count) + } else if value.hasPrefix("ssh-dss-") { + algorithm = .dsa + digest = value.dropFirst("ssh-dss-".count) + } else { + throw RemoteConnectionURLParseError.invalidFingerprint + } + let octets = digest.split(separator: "-", omittingEmptySubsequences: false) + guard octets.count == 16 else { throw RemoteConnectionURLParseError.invalidFingerprint } + var bytes: [UInt8] = [] + bytes.reserveCapacity(16) + for octet in octets { + guard octet.count == 2, let byte = UInt8(octet, radix: 16) else { + throw RemoteConnectionURLParseError.invalidFingerprint + } + bytes.append(byte) + } + return SSHURIHostKeyFingerprint(algorithm: algorithm, md5: Data(bytes)) + } + + private static func decode(_ value: String?) throws -> String? { + guard let value else { return nil } + guard let decoded = value.removingPercentEncoding, + !decoded.contains(where: { $0 == "\0" || $0.isNewline || $0.isASCII && $0.asciiValue! < 0x20 }) + else { throw RemoteConnectionURLParseError.invalidURL } + return decoded + } + + private static func nonempty(_ value: String?) throws -> String? { + guard let value else { return nil } + return value.isEmpty ? nil : value + } +} diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift index 2a1fdee8..2a43e1d7 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift @@ -124,7 +124,8 @@ public final class RemoteSessionConnectionController { public func authorizeHostKey( _ challenge: SSHHostKeyChallenge, - using store: any RemoteSessionHostStore + using store: any RemoteSessionHostStore, + uriFingerprintMatched: Bool = false ) async -> SSHHostKeyDecision { if challenge.expectedHostKeys.contains(challenge.observation.identity) { return .accept @@ -143,7 +144,8 @@ public final class RemoteSessionConnectionController { observation: challenge.observation, kind: challenge.expectedHostKeys.isEmpty ? .firstUse - : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count) + : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count), + uriFingerprintMatched: uriFingerprintMatched ), hostID: challenge.hostID, fingerprint: challenge.observation.fingerprint @@ -163,6 +165,23 @@ public final class RemoteSessionConnectionController { } } + public func authorizeHostKey( + _ challenge: SSHHostKeyChallenge, + using store: any RemoteSessionHostStore, + uriFingerprint: SSHURIHostKeyFingerprint?, + destinationHostID: Host.ID + ) async -> SSHHostKeyDecision { + guard challenge.hostID == destinationHostID, let uriFingerprint else { + return await authorizeHostKey(challenge, using: store) + } + guard uriFingerprint.matches(challenge.observation) else { return .reject } + if !challenge.expectedHostKeys.isEmpty { + return challenge.expectedHostKeys.contains(challenge.observation.identity) ? .accept : .reject + } + return await authorizeHostKey( + challenge, using: store, uriFingerprintMatched: true) + } + public func waitForHostKey( _ prompt: SSHHostKeyPrompt, hostID: Host.ID, fingerprint: String ) { diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index 01f85124..e41677fa 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -24,10 +24,12 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda public var requiresPassphrase = false public var mode: Mode = .localIdentity public var target: Target = .host + public var allowsSaving = true 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, target: Target = .host + keyboardInteractive: SSHKeyboardInteractiveChallenge? = nil, target: Target = .host, + allowsSaving: Bool = true ) { self.host = host self.selectedIdentityID = selectedIdentityID @@ -38,6 +40,7 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda self.mode = mode self.keyboardInteractive = keyboardInteractive self.target = target + self.allowsSaving = allowsSaving } public var isProxy: Bool { diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index 95593e92..b676e7f1 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -8,15 +8,22 @@ public nonisolated struct SSHHostKeyObservation: Hashable, Sendable { public var port: Int public var algorithm: SSHKeyAlgorithm public var fingerprint: String + /// The legacy MD5 digest used only to validate provisional SSH/SFTP URI fingerprints. + /// Persistent trust and user presentation continue to use `fingerprint` (SHA-256). + public var legacyMD5Fingerprint: Data? public var identity: SSHHostKeyIdentity { SSHHostKeyIdentity(algorithm: algorithm, fingerprint: fingerprint) } - public init(hostname: String, port: Int, algorithm: SSHKeyAlgorithm, fingerprint: String) { + public init( + hostname: String, port: Int, algorithm: SSHKeyAlgorithm, fingerprint: String, + legacyMD5Fingerprint: Data? = nil + ) { self.hostname = hostname self.port = port self.algorithm = algorithm self.fingerprint = fingerprint + self.legacyMD5Fingerprint = legacyMD5Fingerprint } } @@ -219,9 +226,14 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { public var observation: SSHHostKeyObservation public var kind: Kind - public init(observation: SSHHostKeyObservation, kind: Kind) { + public var uriFingerprintMatched: Bool + public init( + observation: SSHHostKeyObservation, kind: Kind, + uriFingerprintMatched: Bool = false + ) { self.observation = observation self.kind = kind + self.uriFingerprintMatched = uriFingerprintMatched } } diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index 990720c3..65f0930a 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -237,7 +237,7 @@ public struct SSHAuthenticationForm: View { cancelButton Spacer() continueButton - saveAndContinueButton + if prompt.allowsSaving { saveAndContinueButton } } VStack(spacing: 10) { HStack { @@ -248,10 +248,12 @@ public struct SSHAuthenticationForm: View { #if os(iOS) HStack { Spacer() - saveAndContinueButton + if prompt.allowsSaving { saveAndContinueButton } } #else - saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) + if prompt.allowsSaving { + saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) + } #endif } } @@ -494,6 +496,11 @@ public struct SSHHostKeyConfirmationForm: View { VStack(alignment: .leading, spacing: 4) { Text(observation.algorithm.displayName).font(.caption).foregroundStyle(.secondary) Text(observation.fingerprint).font(.body.monospaced()).textSelection(.enabled) + if prompt.uriFingerprintMatched { + Label("The legacy fingerprint in the link matches this host key.", systemImage: "link.badge.checkmark") + .font(.caption) + .foregroundStyle(.secondary) + } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index b32dc7e9..46625976 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -1731,6 +1731,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { throw connectionError(session, prefix: "The server did not provide a host-key fingerprint") } let digest = Data(bytes: hash, count: 32).base64EncodedString().replacingOccurrences(of: "=", with: "") + let legacyMD5Fingerprint = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_MD5)).map { + Data(bytes: $0, count: 16) + } var length = 0 var type = Int32(LIBSSH2_HOSTKEY_TYPE_UNKNOWN) _ = libssh2_session_hostkey(session, &length, &type) @@ -1746,7 +1749,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } return SSHHostKeyObservation( hostname: host, port: port, algorithm: algorithm, - fingerprint: "SHA256:\(digest)" + fingerprint: "SHA256:\(digest)", legacyMD5Fingerprint: legacyMD5Fingerprint ) } @@ -1765,21 +1768,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { hostID: Host.ID, observed: SSHHostKeyObservation, expected: Set, authorizer: SSHHostKeyAuthorizer? ) async throws { - if expected.contains(observed.identity) { return } - guard let authorizer else { - if expected.isEmpty { throw TermindSSHError.untrustedHost } - throw TermindSSHError.hostKeyChanged( - expected: expected.map(\.fingerprint).sorted(), - received: observed.fingerprint + if let authorizer { + let decision = try await authorizer( + SSHHostKeyChallenge( + hostID: hostID, observation: observed, + expectedHostKeys: expected + ) ) + guard decision == .accept else { throw CancellationError() } + return } - let decision = try await authorizer( - SSHHostKeyChallenge( - hostID: hostID, observation: observed, - expectedHostKeys: expected - ) + if expected.contains(observed.identity) { return } + if expected.isEmpty { throw TermindSSHError.untrustedHost } + throw TermindSSHError.hostKeyChanged( + expected: expected.map(\.fingerprint).sorted(), + received: observed.fingerprint ) - guard decision == .accept else { throw CancellationError() } } public static func performHandshake( diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 7f0a421a..fd8020b6 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -158,6 +158,12 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r create session route snapshots, never replaced with saved endpoints. Routes cap at eight Hosts and reject cycles. Connect saves nothing. Explicit Save copies changed records/prerequisites in one Vault transaction, rolling back new passwords on failure; originals remain unchanged. Host-key decisions stay in the live handshake. +- External `ssh:`, `sftp:` and `telnet:` URLs create one process-only Host and Workspace on both platforms. They reuse + Known Hosts only: never saved Host/Identity/proxy/jump metadata, history or restoration. SSH/SFTP URL passwords and + SFTP text mode are rejected; RFC 4248 Telnet credentials are confirmed and consumed once. A legacy URI MD5 host-key + fingerprint is an additional handshake constraint, never sufficient trust and never persisted; unknown hosts still + show the SHA-256 trust form. SFTP v3 stats the URL path, opens directories, previews ordinary files and requires an + additional user action for executable content on macOS. ### Port Forwarding diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index f9bf760e..43163469 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -306,6 +306,42 @@ final class MobileAppModel { return workspace.id } + func openExternalConnection(_ request: RemoteConnectionURL) -> MobileWorkspace.ID { + let host: Host + let kind: ConnectionHistoryEntry.Kind + switch request.kind { + case .ssh: + host = Host(name: request.host, host: request.host, port: request.port) + kind = .ssh + case .sftp: + host = Host(name: request.host, host: request.host, port: request.port) + kind = .sftp + case .telnet: + host = Host( + name: request.host, host: request.host, + sshEnabled: false, telnetEnabled: true, telnetPort: request.port) + kind = .telnet + } + let route = SSHConnectionRoute( + hosts: [host], usernames: request.username.map { [host.id: $0] } ?? [:]) + let workspace = MobileWorkspace( + descriptor: MobileWorkspaceDescriptor( + hostID: host.id, title: host.name, kind: kind, duplicateIndex: 0), + hosts: library, transfers: transfers, sftpEdits: sftpEdits, + terminalSettings: terminalSettings, commandHistory: commandHistory, + sftpHistory: sftpHistory, connectionServices: workspaceConnectionServices, + transientHost: host, + connectionRoute: request.kind == .telnet ? nil : route, + usernameHint: request.username, + uriHostKeyFingerprint: request.hostKeyFingerprint, + advisoryTelnetPassword: request.advisoryPassword.map(SSHSecret.init), + externalSFTPResourcePath: request.kind == .sftp ? request.path : nil, + externalSFTPMustBeDirectory: request.sftpType == .directory, + isExternalTemporaryConnection: true) + addWorkspaces([workspace], persists: false) + return workspace.id + } + /// Opens one independent SSH workspace per target. The command is intentionally /// carried only by the live workspace and never enters its restoration descriptor. func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) -> [MobileWorkspace.ID] { @@ -330,7 +366,8 @@ final class MobileAppModel { } func close(_ id: UUID) { - guard savedWorkspaceDescriptors.contains(where: { $0.id == id }) else { return } + let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id }) + guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return } if let workspace = workspaces.first(where: { $0.id == id }) { workspace.didChangeState = nil workspace.didRequestClose = nil @@ -338,13 +375,13 @@ final class MobileAppModel { } workspaces.removeAll { $0.id == id } savedWorkspaceDescriptors.removeAll { $0.id == id } - saveWorkspaces() + if wasSaved { saveWorkspaces() } workspaceActivitySessions.removeAll { $0.id == id } liveActivities.update(workspaceActivitySessions) } func closeAll() { - guard !savedWorkspaceDescriptors.isEmpty else { return } + guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return } let closing = workspaces workspaces.removeAll() savedWorkspaceDescriptors.removeAll() @@ -616,13 +653,15 @@ final class MobileAppModel { return index } - private func addWorkspaces(_ added: [MobileWorkspace]) { + private func addWorkspaces(_ added: [MobileWorkspace], persists: Bool = true) { guard !added.isEmpty else { return } for workspace in added { observeActivityState(of: workspace) } workspaces.append(contentsOf: added) - let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) - savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) }) - saveWorkspaces() + if persists { + let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) + savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) }) + saveWorkspaces() + } workspaceActivitySessions.append(contentsOf: added.map(\.activitySession)) liveActivities.start(workspaceActivitySessions) for workspace in added { workspace.connect() } diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 899fdeed..9c03fd5d 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -70,6 +70,18 @@ struct MobileRootView: View { .onDisappear { app.removeScene(scene.id) } .onOpenURL(perform: scene.open) .focusedSceneValue(\.mobileScene, scene) + .alert( + "Open Telnet Connection?", + isPresented: Binding( + get: { scene.pendingExternalConnection != nil }, + set: { if !$0 { scene.pendingExternalConnection = nil } } + ) + ) { + Button("Cancel", role: .cancel) { scene.pendingExternalConnection = nil } + Button("Continue") { scene.confirmExternalConnection() } + } message: { + Text("This link includes an advisory Telnet password. It will be used once and will not be saved.") + } .alert( scene.isWorkspaceUnavailable ? String(localized: "Workspace Unavailable") : String(localized: "Operation Failed"), diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 8106ef65..100f7540 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -1,6 +1,7 @@ import Foundation import Observation import SwiftUI +import TermindApplication import TermindDomain import struct TermindDomain.Host @@ -22,6 +23,7 @@ final class MobileSceneModel { var workspaceID: MobileWorkspace.ID? var isConnectionPickerPresented = false var isWorkspaceUnavailable = false + var pendingExternalConnection: RemoteConnectionURL? var editingHost: Host? var renamingWorkspace: MobileWorkspace? var assistantLibraryRoute: MobileAssistantLibraryRoute? @@ -42,7 +44,7 @@ final class MobileSceneModel { } var hasWorkspaces: Bool { !app.workspaces.isEmpty } - var canCloseAllWorkspaces: Bool { !app.savedWorkspaceDescriptors.isEmpty } + var canCloseAllWorkspaces: Bool { !app.savedWorkspaceDescriptors.isEmpty || !app.workspaces.isEmpty } var workspaceCount: Int { app.workspaces.count } var canCloseWorkspace: Bool { activeWorkspace != nil } var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh } @@ -250,6 +252,19 @@ final class MobileSceneModel { } func open(_ url: URL) { + if let scheme = url.scheme?.lowercased(), ["ssh", "sftp", "telnet"].contains(scheme) { + do { + let request = try RemoteConnectionURLParser.parse(url) + if request.kind == .telnet, request.advisoryPassword != nil { + pendingExternalConnection = request + } else { + selectWorkspace(app.openExternalConnection(request)) + } + } catch { + app.error = error.localizedDescription + } + return + } if WorkspaceActivityAttributes.accepts(url) { isWorkspaceUnavailable = false pendingWorkspaceID = nil @@ -261,4 +276,10 @@ final class MobileSceneModel { selectWorkspace(id) app.workspaces.first(where: { $0.id == id })?.terminal.requestFocus() } + + func confirmExternalConnection() { + guard let request = pendingExternalConnection else { return } + pendingExternalConnection = nil + selectWorkspace(app.openExternalConnection(request)) + } } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index e33c93ee..ab743026 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -113,6 +113,15 @@ final class MobileWorkspace: Identifiable { private var pendingConnectionStartupCommand: MobilePendingInitialCommand? private var commandExcludedFromHistory: String? private var pendingInitialSFTPPath: String? + private var externalSFTPResourcePath: String? + private var externalSFTPMustBeDirectory = false + private var transientHost: Host? + private var connectionRoute: SSHConnectionRoute? + private var usernameHint: String? + private var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? + private var advisoryTelnetPassword: SSHSecret? + private(set) var pendingExternalPreviewURL: URL? + let isExternalTemporaryConnection: Bool var pendingInitialCommand: String? { pendingCommand?.command } private(set) var workingDirectory: String? var commandGeneratorState: CommandGeneratorState { commandGeneration.state } @@ -218,7 +227,15 @@ final class MobileWorkspace: Identifiable { reconnectController: SessionReconnectController = SessionReconnectController(), initialCommand: String? = nil, recordsInitialCommandInHistory: Bool = true, - initialSFTPPath: String? = nil + initialSFTPPath: String? = nil, + transientHost: Host? = nil, + connectionRoute: SSHConnectionRoute? = nil, + usernameHint: String? = nil, + uriHostKeyFingerprint: SSHURIHostKeyFingerprint? = nil, + advisoryTelnetPassword: SSHSecret? = nil, + externalSFTPResourcePath: String? = nil, + externalSFTPMustBeDirectory: Bool = false, + isExternalTemporaryConnection: Bool = false ) { self.terminalSettings = terminalSettings self.commandHistory = commandHistory @@ -228,11 +245,19 @@ final class MobileWorkspace: Identifiable { self.sftpEdits = sftpEdits self.descriptor = descriptor self.hosts = hosts + self.transientHost = transientHost + self.connectionRoute = connectionRoute + self.usernameHint = usernameHint + self.uriHostKeyFingerprint = uriHostKeyFingerprint + self.advisoryTelnetPassword = advisoryTelnetPassword + self.externalSFTPResourcePath = externalSFTPResourcePath + self.externalSFTPMustBeDirectory = externalSFTPMustBeDirectory + self.isExternalTemporaryConnection = isExternalTemporaryConnection activitySession = WorkspaceActivityAttributes.Session( id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue, connected: false, iconAsset: LinuxDistributionIconCatalog.entry( - for: hosts.host(descriptor.hostID)?.detectedDistribution)?.asset) + for: (hosts.host(descriptor.hostID) ?? transientHost)?.detectedDistribution)?.asset) pendingCommand = initialCommand.map { MobilePendingInitialCommand(command: $0, recordsInHistory: recordsInitialCommandInHistory) } @@ -284,7 +309,7 @@ final class MobileWorkspace: Identifiable { } private func makeAuthenticationPrompt() -> SSHAuthenticationPrompt? { - guard authenticationRequired, let host = hosts.host(authenticationHostID ?? descriptor.hostID) else { + guard authenticationRequired, let host = connectionHost(authenticationHostID ?? descriptor.hostID) else { return nil } if let proxy = host.proxy, authenticationIsProxy { @@ -306,7 +331,8 @@ final class MobileWorkspace: Identifiable { selectedIdentityID: selectedIdentity?.id, username: staged?.username ?? proxy.username, failureMessage: connectionFailure, mode: authenticationMode, - target: .proxy(ownerHostID: host.id)) + target: .proxy(ownerHostID: host.id), + allowsSaving: !isExternalTemporaryConnection) } let staged = submissions[host.id] let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host) @@ -317,7 +343,8 @@ final class MobileWorkspace: Identifiable { requiresPassphrase: keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.requiresPasswordReentry != true && staged?.passphrase == nil, - mode: authenticationMode) + mode: authenticationMode, + allowsSaving: !isExternalTemporaryConnection) } private var authenticationIsProxy = false @@ -337,7 +364,7 @@ final class MobileWorkspace: Identifiable { func submitAuthentication( identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool ) { - guard var host = hosts.host(authenticationHostID ?? descriptor.hostID) else { return } + guard var host = connectionHost(authenticationHostID ?? descriptor.hostID) else { return } let identity = identityID.flatMap { hosts.identity($0) } let isProxy = authenticationPrompt?.isProxy == true let name = ((isProxy ? nil : identity?.username) ?? username) @@ -605,7 +632,7 @@ final class MobileWorkspace: Identifiable { busy = true connectionFailure = nil status = String(localized: reconnecting ? "Reconnecting" : "Connecting") - guard let host = hosts.host(descriptor.hostID) else { + guard let host = connectionHost(descriptor.hostID) else { let message = String(localized: "The host is no longer available.") connection.stopRecovery() connectionFailure = message @@ -629,11 +656,11 @@ final class MobileWorkspace: Identifiable { command: body, recordsInHistory: true ) } - let route = hosts.route(for: host) + let route = connectionRoute?.hosts ?? hosts.route(for: host) journey.begin( host: host, route: route, purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel, - username: hosts.identity(for: host)?.username, + username: usernameHint ?? hosts.identity(for: host)?.username, mode: reconnecting ? .reconnection : .connection) if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: route) { authenticationRequired = true @@ -672,7 +699,10 @@ final class MobileWorkspace: Identifiable { busy = false return } - let attemptSubmissions = submissions.takeAuthenticationSecrets() + var attemptSubmissions = submissions.takeAuthenticationSecrets() + if let usernameHint, attemptSubmissions[host.id] == nil { + attemptSubmissions[host.id] = SSHAuthenticationSubmission(username: usernameHint) + } let attemptProxySubmissions = proxySubmissions.takeProxyAuthenticationSecrets() let progressHandler = connection.progressHandler(attemptID: token) { [weak self] in self?.generation == $0 @@ -696,7 +726,9 @@ final class MobileWorkspace: Identifiable { authorizeHostKey: { [weak self, weak hosts] challenge in guard let self, let hosts else { throw CancellationError() } return await self.connection.authorizeHostKey( - challenge, using: hosts + challenge, using: hosts, + uriFingerprint: self.uriHostKeyFingerprint, + destinationHostID: self.descriptor.hostID ) }, keyboardInteractive: { [weak self] challenge in @@ -705,6 +737,7 @@ final class MobileWorkspace: Identifiable { } ) + var externalFile: RemoteFile? if descriptor.kind == .ssh { let backend = backend let handlers = startSSHEventConsumer(for: token) @@ -749,6 +782,28 @@ final class MobileWorkspace: Identifiable { let initialPath: String if reconnecting { initialPath = sftpChannel.path + } else if let requested = externalSFTPResourcePath { + let resolved: String + if requested == "/~" || requested.hasPrefix("/~/") { + let home = RemotePath.normalized(try await client.homeDirectory()) + resolved = requested == "/~" ? home : RemotePath.join(home, String(requested.dropFirst(3))) + } else { + resolved = RemotePath.normalized(requested) + } + guard let resource = try await client.attributes(at: resolved) else { + throw SFTPError.operation(String(localized: "The remote file is no longer available.")) + } + if resource.isDirectory { + initialPath = resource.path + } else { + guard !externalSFTPMustBeDirectory else { + throw SFTPError.operation( + String(localized: "The SFTP link requires a directory, but its path is a file.")) + } + initialPath = RemotePath.parent(of: resource.path) + externalFile = resource + } + externalSFTPResourcePath = nil } else if let pendingInitialSFTPPath { initialPath = pendingInitialSFTPPath } else if host.sftpChannelInitialPath.isEmpty { @@ -791,6 +846,10 @@ final class MobileWorkspace: Identifiable { isInitialCommandSubmissionReady = true } submitPendingInitialCommandIfReady() + } else if let externalFile { + _ = preview(externalFile) { [weak self] url in + self?.pendingExternalPreviewURL = url + } } } catch { guard generation == token else { return } @@ -811,7 +870,7 @@ final class MobileWorkspace: Identifiable { sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity authenticationHostID = sshError.routeHostID ?? descriptor.hostID authenticationIsProxy = sshError.isProxyAuthentication - if let endpoint = hosts.host(authenticationHostID ?? descriptor.hostID) { + if let endpoint = connectionHost(authenticationHostID ?? descriptor.hostID) { username = authenticationIsProxy ? (proxySubmissions[endpoint.id]?.username ?? endpoint.proxy?.username ?? "") @@ -874,7 +933,7 @@ final class MobileWorkspace: Identifiable { } journey.beginTelnet( host: host, - username: hosts.identity(for: host, protocol: .telnet)?.username, + username: usernameHint ?? hosts.identity(for: host, protocol: .telnet)?.username, mode: reconnecting ? .reconnection : .connection) let terminalBackend = backend let session = TelnetConnection( @@ -918,7 +977,12 @@ final class MobileWorkspace: Identifiable { attempt = Task { [weak self, weak hosts] in guard let self, let hosts else { return } do { - let configuration = try hosts.telnetConnectionConfiguration(for: host) + var configuration = try hosts.telnetConnectionConfiguration(for: host) + if let usernameHint { configuration.username = usernameHint } + if let password = self.advisoryTelnetPassword { + configuration.password = password + self.advisoryTelnetPassword = nil + } try await session.connect( using: configuration, columns: UInt16(clamping: viewport.columns), @@ -1104,16 +1168,17 @@ final class MobileWorkspace: Identifiable { if announcesLoss, descriptor.kind == .ssh { appendTerminalNotice(String(localized: "Connection lost; reconnecting…")) } - if let host = hosts.host(descriptor.hostID) { + if let host = connectionHost(descriptor.hostID) { if descriptor.kind == .telnet { journey.beginTelnet( - host: host, username: hosts.identity(for: host, protocol: .telnet)?.username, + host: host, + username: usernameHint ?? hosts.identity(for: host, protocol: .telnet)?.username, mode: .reconnection) } else { journey.begin( - host: host, route: hosts.route(for: host), + host: host, route: connectionRoute?.hosts ?? hosts.route(for: host), purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel, - username: hosts.identity(for: host)?.username, mode: .reconnection) + username: usernameHint ?? hosts.identity(for: host)?.username, mode: .reconnection) } } Task { @@ -1129,7 +1194,7 @@ final class MobileWorkspace: Identifiable { func trustHostKey(remember: Bool = true) { guard let observation = connection.pendingHostKey?.observation else { return } - if remember { + if remember, !isExternalTemporaryConnection { connection.trustHostKey(observation, using: hosts) } else { guard connection.useTemporaryHostKey(observation) else { return } @@ -1142,6 +1207,8 @@ final class MobileWorkspace: Identifiable { resetCommandGeneratorShellState() connectionResumeIntent = .none connection.disconnect() + advisoryTelnetPassword?.clear() + advisoryTelnetPassword = nil connectionFailure = nil operationError = nil authenticationRequired = false @@ -1177,6 +1244,15 @@ final class MobileWorkspace: Identifiable { status = String(localized: "Disconnected") } + func consumePendingExternalPreviewURL() -> URL? { + defer { pendingExternalPreviewURL = nil } + return pendingExternalPreviewURL + } + + private func connectionHost(_ id: Host.ID) -> Host? { + connectionRoute?.host(id) ?? hosts.host(id) ?? (id == descriptor.hostID ? transientHost : nil) + } + private func startSSHEventConsumer( for connectionGeneration: UUID ) -> ( @@ -1597,7 +1673,9 @@ final class MobileWorkspace: Identifiable { do { let url = try await sftpEdits.open( file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp) - sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) + if !isExternalTemporaryConnection { + sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) + } completion(url) } catch { operationError = error.localizedDescription } } @@ -1617,7 +1695,9 @@ final class MobileWorkspace: Identifiable { connection: .context(transferContext), scopedRoot: directory, overwrite: false ) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in guard state == .completed else { return } - sftpHistory.record(kind: .file, path: path, for: hostID) + if !self.isExternalTemporaryConnection { + sftpHistory.record(kind: .file, path: path, for: hostID) + } completion(localURL) } return true diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index b62fcc13..d67bed75 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -89,6 +89,10 @@ struct MobileWorkspaceView: View { cancelDropLoading() } .onChange(of: workspace.uploadDestination) { _, _ in cancelDropLoading() } + .onChange(of: workspace.pendingExternalPreviewURL, initial: true) { _, url in + guard url != nil, let preview = workspace.consumePendingExternalPreviewURL() else { return } + presentPreview(preview) + } .onChange(of: scenePhase) { _, phase in if phase != .active { openWithPresenter.isEnabled = false } if phase == .active { TerminalFontCatalog.shared.refresh() } diff --git a/TFI/Info.plist b/TFI/Info.plist index d141f949..ca007fcd 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -5,6 +5,8 @@ CFBundleURLTypes + CFBundleTypeRole + Viewer CFBundleURLName com.akinokaede.termind.workspace CFBundleURLSchemes @@ -12,6 +14,18 @@ termind + + CFBundleTypeRole + Viewer + CFBundleURLName + com.akinokaede.termind.remote-connections + CFBundleURLSchemes + + ssh + sftp + telnet + + NSAppTransportSecurity diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index acb0f4de..2840973e 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -79,6 +79,23 @@ struct MobileAppTests { @Suite @MainActor struct MobileWorkspaceTests { + @Test func externalConnectionWorkspaceIsEphemeralAndDoesNotTouchVault() throws { + let app = try MobileAppModel(inMemory: true) + let request = try RemoteConnectionURLParser.parse( + #require(URL(string: "ssh://deploy@example.invalid:2222"))) + let id = app.openExternalConnection(request) + let workspace = try #require(app.workspaces.first { $0.id == id }) + + #expect(workspace.isExternalTemporaryConnection) + #expect(workspace.descriptor.title == "example.invalid") + #expect(app.savedWorkspaceDescriptors.isEmpty) + #expect(app.library.hosts.isEmpty) + + app.close(id) + #expect(app.workspaces.isEmpty) + #expect(app.savedWorkspaceDescriptors.isEmpty) + } + @Test func liveActivitiesUseStableDistinctWorkspaceDestinations() { let first = MobileWorkspaceDescriptor(hostID: UUID(), title: "Server", kind: .ssh) var renamed = first diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 3e6a6bc4..1bec93ef 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -1,5 +1,6 @@ import AgentKit import AgentKitScrubber +import AppKit import Foundation import TermindApplication import TermindDomain @@ -757,6 +758,78 @@ final class AppModel { try QuickConnectResolver.resolve(input, in: hosts, locale: locale) } + func openExternalConnection(_ url: URL, in window: WindowModel) { + do { + let request = try RemoteConnectionURLParser.parse(url) + if request.kind == .telnet, request.advisoryPassword != nil { + window.pendingExternalConnection = request + } else { + openExternalConnection(request, in: window) + } + } catch { + window.externalConnectionError = error.localizedDescription + } + } + + func confirmExternalConnection(in window: WindowModel) { + guard let request = window.pendingExternalConnection else { return } + window.pendingExternalConnection = nil + openExternalConnection(request, in: window) + } + + private func openExternalConnection(_ request: RemoteConnectionURL, in window: WindowModel) { + switch request.kind { + case .telnet: + let host = Host( + name: request.host, host: request.host, + sshEnabled: false, telnetEnabled: true, telnetPort: request.port) + window.openExternalTelnetSession( + host: host, username: request.username, + password: request.advisoryPassword.map(SSHSecret.init)) + case .ssh, .sftp: + let host = Host(name: request.host, host: request.host, port: request.port) + let route = SSHConnectionRoute( + hosts: [host], usernames: request.username.map { [host.id: $0] } ?? [:]) + if request.kind == .ssh { + let workspace = window.openSSHSession( + host: host, usernameHint: request.username, connectionRoute: route) + workspace.focusedPane.sshSession?.uriHostKeyFingerprint = request.hostKeyFingerprint + workspace.focusedPane.sshSession?.isExternalTemporaryConnection = true + return + } + let workspace = window.openSFTPChannel(host: host) + guard let channel = workspace.focusedPane.sftpChannel else { return } + channel.configureExternalConnection( + host: host, username: request.username, route: route, + fingerprint: request.hostKeyFingerprint, + resourcePath: request.path, + mustBeDirectory: request.sftpType == .directory + ) { [weak self, weak channel] file in + guard let self, let channel else { return } + channel.revealExternalFile(file) + guard Self.canAutomaticallyOpenExternalSFTPFile(file) else { + channel.operationError = String( + localized: "This remote file may contain executable content. Review it before opening.") + return + } + channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) { localURL in + if NSWorkspace.shared.open(localURL) { + channel.recordOpenedFile(at: file.path) + } + } + } + } + } + + private static func canAutomaticallyOpenExternalSFTPFile(_ file: RemoteFile) -> Bool { + guard file.kind == .file, file.permissions & 0o111 == 0 else { return false } + let unsafeExtensions: Set = [ + "app", "bash", "bin", "command", "dmg", "exe", "ksh", "mpkg", "pkg", "pl", "py", "rb", + "sh", "tool", "zsh", + ] + return !unsafeExtensions.contains((file.name as NSString).pathExtension.lowercased()) + } + /// Mark as recently used, and flip the status dot. /// /// Its own method because three code paths now open hosts — one, a batch of diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 929ad57d..b9adeb64 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -202,6 +202,7 @@ struct RootView: View { app.onboarding.windowBecameActive(window) considerOnboarding() } + .modifier(ExternalConnectionURLPresenter(app: app, window: window)) .onChange(of: app.proAccess.status) { app.reconcileProAccess() } .onChange(of: onboardingContext) { considerOnboarding() } .onChange(of: app.onboarding.revision) { considerOnboarding() } @@ -389,6 +390,39 @@ struct RootView: View { } } +private struct ExternalConnectionURLPresenter: ViewModifier { + let app: AppModel + let window: WindowModel + + func body(content: Content) -> some View { + content + .onOpenURL { app.openExternalConnection($0, in: window) } + .alert( + "Open Telnet Connection?", + isPresented: Binding( + get: { window.pendingExternalConnection != nil }, + set: { if !$0 { window.pendingExternalConnection = nil } } + ) + ) { + Button("Cancel", role: .cancel) { window.pendingExternalConnection = nil } + Button("Continue") { app.confirmExternalConnection(in: window) } + } message: { + Text("This link includes an advisory Telnet password. It will be used once and will not be saved.") + } + .alert( + "Connection Link Failed", + isPresented: Binding( + get: { window.externalConnectionError != nil }, + set: { if !$0 { window.externalConnectionError = nil } } + ) + ) { + Button("OK") { window.externalConnectionError = nil } + } message: { + Text(window.externalConnectionError ?? "") + } + } +} + /// Owns window-scoped AppKit integration: key-window onboarding and the remote /// title report. Keeping both on this persistent bridge avoids routing title /// changes through whichever Workspace or pane SwiftUI currently renders. diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 54591cdf..092d0e15 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -173,6 +173,14 @@ final class SFTPChannelModel: Identifiable { private var didResolveHomeForConnection = false private let automaticRefreshInterval: Duration var connectionCoordinator: RemoteConnectionCoordinator? + private var transientHost: Host? + private var usernameHint: String? + private var connectionRoute: SSHConnectionRoute? + private var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? + private var isExternalTemporaryConnection = false + private var externalResourcePath: String? + private var externalResourceMustBeDirectory = false + var openExternalFile: ((RemoteFile) -> Void)? init( hostID: Host.ID, @@ -209,6 +217,21 @@ final class SFTPChannelModel: Identifiable { var reconnectState: ReconnectState? { connection.reconnectState } var allowsRemoteMutations: Bool { status == .connected } + + func configureExternalConnection( + host: Host, username: String?, route: SSHConnectionRoute, + fingerprint: SSHURIHostKeyFingerprint?, resourcePath: String?, + mustBeDirectory: Bool, openFile: @escaping (RemoteFile) -> Void + ) { + transientHost = host + usernameHint = username + connectionRoute = route + uriHostKeyFingerprint = fingerprint + isExternalTemporaryConnection = true + externalResourcePath = resourcePath + externalResourceMustBeDirectory = mustBeDirectory + openExternalFile = openFile + } var displayedPath: String? { guard !homeResolution.isPending else { return nil } return displayPath(path) @@ -246,6 +269,10 @@ final class SFTPChannelModel: Identifiable { return true } + func revealExternalFile(_ file: RemoteFile) { + selection = [file.id] + } + func menuTargets(for file: RemoteFile) -> [RemoteFile] { guard selection.contains(file.id), selection.count > 1 else { return [file] } return selectedFiles @@ -298,7 +325,7 @@ final class SFTPChannelModel: Identifiable { bindStatus(to: store) connectionTask?.cancel() connection.clearHostKeyPrompt() - guard let host = store.host(hostID) else { + guard let host = connectionHost(hostID, using: store) else { connection.stopRecovery() let message = String(localized: "The host is no longer available.") status = .failed(message) @@ -308,7 +335,7 @@ final class SFTPChannelModel: Identifiable { } let attemptID = UUID() connectionAttemptID = attemptID - let route = store.route(for: host) + let route = connectionRoute?.hosts ?? store.route(for: host) let stagedAuthentication = suppliedAuthentications[host.id] connectionJourney.begin( host: host, route: route, purpose: .sftpChannel, @@ -339,7 +366,10 @@ final class SFTPChannelModel: Identifiable { requestAuthentication(host: host, store: store) return } - let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets() + var attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets() + if let usernameHint, attemptAuthentications[host.id] == nil { + attemptAuthentications[host.id] = SSHAuthenticationSubmission(username: usernameHint) + } let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets() let suppliedAuthentication = attemptAuthentications[host.id] status = reconnecting ? .reconnecting : .connecting @@ -387,12 +417,17 @@ final class SFTPChannelModel: Identifiable { expectedHostKeys: expectedHostKeys, proxy: try store.transportProxy( for: host, submission: attemptProxyAuthentications[host.id]), - jumps: try store.sshJumpConfigurations( + jumps: try connectionRoute.map { + try store.sshJumpConfigurations( + for: $0, submissions: attemptAuthentications, + proxySubmissions: attemptProxyAuthentications, + temporaryHostKeys: temporaryHostKeys, + trustPolicy: .allowChallenge) + } ?? store.sshJumpConfigurations( for: host, submissions: attemptAuthentications, proxySubmissions: attemptProxyAuthentications, temporaryHostKeys: temporaryHostKeys, - trustPolicy: .allowChallenge - ), + trustPolicy: .allowChallenge), charset: host.charset, authenticationReference: SSHAuthenticationReference( identityID: suppliedAuthentication.identityID, @@ -420,7 +455,9 @@ final class SFTPChannelModel: Identifiable { authorizeHostKey: { [weak self, weak store] challenge in guard let self, let store else { throw CancellationError() } return await self.connection.authorizeHostKey( - challenge, using: store + challenge, using: store, + uriFingerprint: self.uriHostKeyFingerprint, + destinationHostID: self.hostID ) }, keyboardInteractive: { [weak self] challenge in @@ -470,7 +507,7 @@ final class SFTPChannelModel: Identifiable { self.connectionJourney.hide() } self.hint = "" - await self.reload() + await self.openExternalResourceIfNeeded(using: client) self.startAutomaticRefresh() } catch is CancellationError { guard self.connectionAttemptID == attemptID else { return } @@ -485,7 +522,7 @@ final class SFTPChannelModel: Identifiable { } catch let error as TermindSSHError where error.shouldRequestAuthentication { guard self.connectionAttemptID == attemptID else { return } let authenticationHost: Host - if let id = error.routeHostID, let jump = store.host(id) { + if let id = error.routeHostID, let jump = self.connectionHost(id, using: store) { authenticationHost = jump } else { authenticationHost = host @@ -614,6 +651,51 @@ final class SFTPChannelModel: Identifiable { navigationRouter?.sftpChannel(self, didChangePath: path) } + private func openExternalResourceIfNeeded(using client: any SFTPChannelProtocol) async { + guard let requested = externalResourcePath else { + await reload() + return + } + externalResourcePath = nil + let resolved: String + if requested == "/~" || requested.hasPrefix("/~/") { + guard case .resolved(let home) = homeResolution else { + operationError = String(localized: "The remote home directory is unavailable.") + return + } + resolved = requested == "/~" ? home : RemotePath.join(home, String(requested.dropFirst(3))) + } else { + resolved = RemotePath.normalized(requested) + } + do { + guard let resource = try await client.attributes(at: resolved) else { + operationError = String(localized: "The remote file is no longer available.") + return + } + if resource.isDirectory { + path = resource.path + pathText = resource.path + navigationRouter?.sftpChannel(self, didChangePath: path) + await reload() + return + } + guard !externalResourceMustBeDirectory else { + operationError = String(localized: "The SFTP link requires a directory, but its path is a file.") + path = RemotePath.parent(of: resource.path) + pathText = path + await reload() + return + } + path = RemotePath.parent(of: resource.path) + pathText = path + navigationRouter?.sftpChannel(self, didChangePath: path) + await reload() + openExternalFile?(resource) + } catch { + operationError = error.localizedDescription + } + } + func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) { connection.trustHostKey(observation, using: store) } @@ -627,7 +709,7 @@ final class SFTPChannelModel: Identifiable { keyID: SSHKeyRef.ID?, save: Bool, using store: any RemoteSessionHostStore ) { guard let prompt = pendingAuthentication, - let host = store.host(prompt.host.id) + let host = connectionHost(prompt.host.id, using: store) else { return } let selectedIdentity = identityID.flatMap { store.identity($0) } @@ -957,7 +1039,9 @@ final class SFTPChannelModel: Identifiable { } func recordOpenedFile(at path: String) { - historyStore?.record(kind: .file, path: path, for: hostID) + if !isExternalTemporaryConnection { + historyStore?.record(kind: .file, path: path, for: hostID) + } } func reopenHistoryFile( @@ -1099,7 +1183,7 @@ final class SFTPChannelModel: Identifiable { selectedKeyID: selectedKeyID, failureMessage: failure, requiresPassphrase: needsPassphrase, - mode: mode + mode: mode, allowsSaving: !isExternalTemporaryConnection ) status = .disconnected switch mode { @@ -1137,7 +1221,8 @@ final class SFTPChannelModel: Identifiable { host: proxy.hostname, port: proxy.port), selectedIdentityID: selectedIdentity?.id, username: staged?.username ?? proxy.username, failureMessage: failure, - mode: mode, target: .proxy(ownerHostID: host.id)) + mode: mode, target: .proxy(ownerHostID: host.id), + allowsSaving: !isExternalTemporaryConnection) status = .disconnected connectionJourney.waitForProxyAuthentication(at: host.id, failure: failure) } @@ -1170,7 +1255,7 @@ final class SFTPChannelModel: Identifiable { additionalSnapshot = nil status = .reconnecting hint = String(localized: "Reconnecting") - let host = store.host(hostID) + let host = connectionHost(hostID, using: store) connection.presentReconnectJourney( host: host, using: store, purpose: .sftpChannel, username: host.flatMap { store.identity(for: $0)?.username } @@ -1184,6 +1269,10 @@ final class SFTPChannelModel: Identifiable { } } + private func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? { + connectionRoute?.host(id) ?? store.host(id) ?? (id == hostID ? transientHost : nil) + } + private func deleteRecursively( _ file: RemoteFile, client: any SFTPChannelProtocol, cancellation: TransferCancellationToken? = nil diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 027fe1d5..8b10da9a 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -150,6 +150,8 @@ final class SSHSessionModel: Identifiable { private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot? private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24) var connectionRoute: SSHConnectionRoute? + var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? + var isExternalTemporaryConnection = false private var transientHost: Host? private var usernameHint: String? private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:] @@ -608,7 +610,9 @@ final class SSHSessionModel: Identifiable { authorizeHostKey: { [weak self, weak store] challenge in guard let self, let store else { throw CancellationError() } return await self.connection.authorizeHostKey( - challenge, using: store + challenge, using: store, + uriFingerprint: self.uriHostKeyFingerprint, + destinationHostID: self.hostID ) } ) @@ -981,7 +985,8 @@ final class SSHSessionModel: Identifiable { username: username, selectedKeyID: selectedKeyID, failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil, - requiresPassphrase: requiresPassphrase, mode: mode + requiresPassphrase: requiresPassphrase, mode: mode, + allowsSaving: !isExternalTemporaryConnection ) status = .disconnected switch mode { @@ -1026,7 +1031,8 @@ final class SSHSessionModel: Identifiable { selectedIdentityID: selectedIdentity?.id, username: staged?.username ?? proxy.username, failureMessage: failureMessage, mode: mode, - target: .proxy(ownerHostID: host.id)) + target: .proxy(ownerHostID: host.id), + allowsSaving: !isExternalTemporaryConnection) status = .disconnected connectionJourney.waitForProxyAuthentication(at: host.id, failure: failureMessage) } @@ -1072,6 +1078,8 @@ final class SSHSessionModel: Identifiable { let host = transientHost ?? Host(id: hostID, name: title, host: title) let copy = SSHSessionModel(host: host, usernameHint: usernameHint) copy.connectionRoute = connectionRoute + copy.uriHostKeyFingerprint = uriHostKeyFingerprint + copy.isExternalTemporaryConnection = isExternalTemporaryConnection copy.connectionCoordinator = connectionCoordinator copy.commandHistoryStore = commandHistoryStore return copy diff --git a/TFM/Features/Terminal/TelnetSessionModel.swift b/TFM/Features/Terminal/TelnetSessionModel.swift index 78c9cbbd..ba72bd9b 100644 --- a/TFM/Features/Terminal/TelnetSessionModel.swift +++ b/TFM/Features/Terminal/TelnetSessionModel.swift @@ -32,6 +32,9 @@ final class TelnetSessionModel: Identifiable { var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface } private weak var statusStore: (any RemoteSessionHostStore)? private let transientHost: Host + private let usernameHint: String? + private var advisoryPassword: SSHSecret? + private let isExternalTemporaryConnection: Bool private var connection: TelnetConnection? private var connectionTask: Task? private var startsOnMount = true @@ -57,10 +60,17 @@ final class TelnetSessionModel: Identifiable { protocols: [.init(name: .telnet, address: address, connectionState: state)]) } - init(host: Host) { + init( + host: Host, usernameHint: String? = nil, + advisoryPassword: SSHSecret? = nil, + isExternalTemporaryConnection: Bool = false + ) { hostID = host.id title = host.name transientHost = host + self.usernameHint = usernameHint + self.advisoryPassword = advisoryPassword + self.isExternalTemporaryConnection = isExternalTemporaryConnection let relay = TelnetSessionRelay() terminalSurface = PersistentTerminalViewOwner( write: { data, _ in @@ -77,11 +87,14 @@ final class TelnetSessionModel: Identifiable { ) relay.owner = self connectionJourney.beginTelnet( - host: host, username: nil, mode: .connection) + host: host, username: usernameHint, mode: .connection) } func copyForReconnect() -> TelnetSessionModel? { - TelnetSessionModel(host: statusStore?.host(hostID) ?? transientHost) + TelnetSessionModel( + host: statusStore?.host(hostID) ?? transientHost, + usernameHint: usernameHint, + isExternalTemporaryConnection: isExternalTemporaryConnection) } func prepareForReopen() { @@ -106,7 +119,7 @@ final class TelnetSessionModel: Identifiable { fail(String(localized: "Telnet is disabled for this Host.")) return } - let username = store.identity(for: host, protocol: .telnet)?.username + let username = usernameHint ?? store.identity(for: host, protocol: .telnet)?.username connectionJourney.beginTelnet( host: host, username: username, mode: connectionController.hasConnectedOnce ? .reconnection : .connection) @@ -147,7 +160,12 @@ final class TelnetSessionModel: Identifiable { let viewport = latestViewport connectionTask = Task { [weak self] in do { - let configuration = try store.telnetConnectionConfiguration(for: host) + var configuration = try store.telnetConnectionConfiguration(for: host) + if let usernameHint = self?.usernameHint { configuration.username = usernameHint } + if let password = self?.advisoryPassword { + configuration.password = password + self?.advisoryPassword = nil + } try await connection.connect( using: configuration, columns: UInt16(clamping: viewport.columns), @@ -173,6 +191,8 @@ final class TelnetSessionModel: Identifiable { connection = nil status = .disconnected connectionController.disconnect() + advisoryPassword?.clear() + advisoryPassword = nil } func insert(_ text: String, submit: Bool = false) { diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index 0b967149..a3bb26df 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -413,8 +413,15 @@ extension Pane { ))) } - static func telnetSession(host: Host) -> Pane { - let session = TelnetSessionModel(host: host) + static func telnetSession( + host: Host, usernameHint: String? = nil, + advisoryPassword: SSHSecret? = nil, + isExternalTemporaryConnection: Bool = false + ) -> Pane { + let session = TelnetSessionModel( + host: host, usernameHint: usernameHint, + advisoryPassword: advisoryPassword, + isExternalTemporaryConnection: isExternalTemporaryConnection) session.status = ConnectionStatus.connecting return Pane(.telnetSession(session)) } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 35fc7022..2727b5d5 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -119,6 +119,8 @@ final class WindowModel { var assistantCommands: AssistantConversationCommands? var assistantOperationError: String? var workspaceTemplateNotice: String? + var pendingExternalConnection: RemoteConnectionURL? + var externalConnectionError: String? var hostStore: HostLibraryModel? { didSet { refreshSessionContext() @@ -880,6 +882,18 @@ final class WindowModel { return open(Workspace(pane: pane)) } + @discardableResult + func openExternalTelnetSession( + host: Host, username: String?, password: SSHSecret? + ) -> Workspace { + let pane = Pane.telnetSession( + host: host, usernameHint: username, + advisoryPassword: password, + isExternalTemporaryConnection: true) + numberPane(pane) + return open(Workspace(pane: pane)) + } + func disconnectSessions( for hostID: Host.ID, protocol connectionProtocol: HostTerminalProtocol, message: String ) { diff --git a/TFM/Info.plist b/TFM/Info.plist index 1972bfab..61be6f66 100644 --- a/TFM/Info.plist +++ b/TFM/Info.plist @@ -25,12 +25,29 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.akinokaede.termind.remote-connections + CFBundleURLSchemes + + ssh + sftp + telnet + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSApplicationCategoryType public.app-category.developer-tools + LSFileQuarantineEnabled + NSAccentColorName AccentColor NSMicrophoneUsageDescription diff --git a/TFMTests/ExternalConnectionURLTests.swift b/TFMTests/ExternalConnectionURLTests.swift new file mode 100644 index 00000000..e9fdddae --- /dev/null +++ b/TFMTests/ExternalConnectionURLTests.swift @@ -0,0 +1,41 @@ +import Foundation +import TermindApplication +import Testing + +@testable import Termind + +@Suite @MainActor +struct ExternalConnectionURLTests { + @Test func externalSSHOpensATemporaryWorkspaceWithoutSaving() throws { + let app = AppModel(hosts: HostLibraryModel()) + let window = WindowModel() + defer { app.shutdownForTermination() } + + app.openExternalConnection( + #require(URL(string: "ssh://deploy@example.invalid:2222")), + in: window) + + let session = try #require(window.selectedWorkspace?.focusedPane.sshSession) + #expect(session.isExternalTemporaryConnection) + #expect(session.connectionRoute?.hosts.first?.host == "example.invalid") + #expect(session.connectionRoute?.hosts.first?.port == 2222) + #expect(app.hostLibrary.hosts.isEmpty) + } + + @Test func telnetPasswordRequiresConfirmationBeforeOpening() throws { + let app = AppModel(hosts: HostLibraryModel()) + let window = WindowModel() + defer { app.shutdownForTermination() } + + app.openExternalConnection( + #require(URL(string: "telnet://operator:secret@example.invalid")), + in: window) + + #expect(window.pendingExternalConnection?.advisoryPassword == "secret") + #expect(window.workspaces.isEmpty) + app.confirmExternalConnection(in: window) + #expect(window.pendingExternalConnection == nil) + #expect(window.selectedWorkspace?.focusedPane.telnetSession != nil) + #expect(app.hostLibrary.hosts.isEmpty) + } +}