Add URL Scheme

This commit is contained in:
Kaede Akino committed 2026-09-21 23:54:01 +08:00
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))
}
}
+105
View File
@@ -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(
+6
View File
@@ -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
+46 -7
View File
@@ -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() }
+12
View File
@@ -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"),
+22 -1
View File
@@ -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))
}
}
+101 -21
View File
@@ -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() }
+14
View File
@@ -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>
+17
View File
@@ -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
+73
View File
@@ -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
+34
View File
@@ -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.
+103 -14
View File
@@ -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
+11 -3
View File
@@ -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
+25 -5
View File
@@ -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) {
+9 -2
View File
@@ -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))
}
+14
View File
@@ -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
) {
+17
View File
@@ -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>
+41
View File
@@ -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)
}
}