Add URL Scheme
This commit is contained in:
1 parent
b5761040bc
commit
f0aeb33a8a
25 files changed
+1034
-74
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SSHHostKeyIdentity>, 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.akinokaede.termind.workspace</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
@@ -12,6 +14,18 @@
|
||||
<string>termind</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.akinokaede.termind.remote-connections</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>sftp</string>
|
||||
<string>telnet</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = [
|
||||
"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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -25,12 +25,29 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.akinokaede.termind.remote-connections</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>sftp</string>
|
||||
<string>telnet</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>LSFileQuarantineEnabled</key>
|
||||
<true/>
|
||||
<key>NSAccentColorName</key>
|
||||
<string>AccentColor</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user