Files
Termind/FrameworkTests/TerminalTransportTests.swift

335 lines
18 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct TerminalTransportTests {
@Test func defaultsKeepSSHAndDisablingTheDefaultDoesNotSelectAnotherEnabledTransport() {
var host = Host(name: "Server", host: "example.invalid")
#expect(host.preferredTerminalProtocol == .ssh)
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
host.telnetEnabled = true
#expect(host.supportedTerminalProtocols == [.ssh, .mosh, .eternalTerminal, .telnet])
host.terminalTransports.defaultTransport = .mosh
#expect(host.preferredTerminalProtocol == .mosh)
host.terminalTransports.moshEnabled = false
host.terminalTransports.normalizeDefault(isTailscaleManaged: false)
#expect(host.terminalTransports.defaultTransport == .ssh)
host.sshEnabled = false
#expect(host.supportedTerminalProtocols == [.telnet])
#expect(host.preferredTerminalProtocol == .telnet)
#expect(host.terminalTransports.eternalTerminalEnabled)
}
@Test(arguments: [HostTerminalProtocol.ssh, .mosh, .eternalTerminal])
func preferredOrderingAndConnectionKindShareTheDefaultPolicy(transport: HostTerminalProtocol) {
var host = Host(name: "Server", host: "server.invalid", telnetEnabled: true)
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
host.terminalTransports.defaultTransport = transport
let canonical = [HostTerminalProtocol.ssh, .mosh, .eternalTerminal, .telnet]
#expect(host.supportedTerminalProtocols == canonical)
#expect(host.preferredFirstTerminalProtocols == [transport] + canonical.filter { $0 != transport })
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .terminal(transport))
host.terminalTransports.moshEnabled = false
host.terminalTransports.eternalTerminalEnabled = false
#expect(host.preferredFirstTerminalProtocols == [.ssh, .telnet])
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .ssh)
host.sshEnabled = false
#expect(host.preferredFirstTerminalProtocols == [.telnet])
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .telnet)
host.telnetEnabled = false
#expect(host.preferredFirstTerminalProtocols.isEmpty)
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == nil)
}
@Test func rangeValidationCannotInjectBootstrapArguments() {
for valid in ["", "1", "65535", "60001-60999", "1-65535"] {
#expect(HostTerminalTransports.isValidMoshPortRange(valid))
}
for invalid in ["0", "65536", "-1", "2-1", "60001-", "1-2-3", "1;id", " 22", "22\n", "60001"] {
#expect(!HostTerminalTransports.isValidMoshPortRange(invalid))
}
var options = HostTerminalTransports()
options.eternalTerminalEnabled = true
options.eternalTerminalPort = 65_536
#expect(!options.isValid)
options.eternalTerminalPort = 2022
#expect(options.isValid)
}
@Test func legacyHostDecodesWithSSHAndTransportPreferencesSurviveReopening() throws {
var host = Host(name: "Host", host: "server.invalid", iconOverrideID: "macos")
var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(host)) as? [String: Any])
json.removeValue(forKey: "terminalTransports")
let legacyHost = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json))
#expect(legacyHost.terminalTransports == .init())
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("store")
do {
let schema = Schema(versionedSchema: TermindSchemaV54.self)
let legacy = try ModelContainer(
for: schema,
configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none))
legacy.mainContext.insert(TermindSchemaV54HostModels.PersistentHost(host))
try legacy.mainContext.save()
}
do {
let container = try TermindPersistence.makeContainer(url: url)
let store = HostPersistence(context: container.mainContext)
#expect(try store.load().hosts == [host])
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
host.terminalTransports.defaultTransport = .eternalTerminal
host.terminalTransports.moshUDPPortRange = "60001-60999"
host.terminalTransports.eternalTerminalPort = 2222
_ = try store.save(.init(hosts: [host]))
}
let reopened = try TermindPersistence.makeContainer(url: url)
#expect(try HostPersistence(context: reopened.mainContext).load().hosts == [host])
}
@Test func wireVersionsProtectTransportChoicesFromOlderWriters() throws {
var host = Host(name: "Host", host: "server.invalid")
host.terminalTransports.eternalTerminalEnabled = true
host.terminalTransports.defaultTransport = .eternalTerminal
let payload = VaultRecordPayload.host(host)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
for version in [1, 2, 4, 5] {
var old = envelope
old.formatVersion = version
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) }
}
let template = WorkspaceTemplate(name: "ET", layout: .pane(.eternalTerminal(hostID: host.id)))
let templatePayload = VaultRecordPayload.workspaceTemplate(template)
let templateEnvelope = VaultEnvelope(
key: templatePayload.key, version: .baseline(writerID: UUID()), payload: templatePayload)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(templateEnvelope)) == templateEnvelope)
var oldTemplate = templateEnvelope
oldTemplate.formatVersion = 1
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(oldTemplate) }
}
@Test func tailscaleKeepsETPreferencesWithoutEnablingMosh() throws {
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net"))
let peer = TailscalePeer(
stableID: "peer", name: "Server", dnsName: "server.test.ts.net", online: true,
sshHostKeys: ["ssh-ed25519 test"])
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer], groupID: group.id)
var host = try #require(library.hosts.first)
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
host.terminalTransports.defaultTransport = .eternalTerminal
#expect(library.saveTailscaleHost(host))
library.replaceTailscalePeers([peer], groupID: group.id)
let projected = try #require(library.host(host.id))
#expect(projected.supportedTerminalProtocols == [.ssh, .eternalTerminal])
#expect(projected.preferredTerminalProtocol == .eternalTerminal)
let payload = VaultRecordPayload.hostGroup(try #require(library.group(group.id)))
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
}
@Test func v6GroupTransportOverridesRoundTripManyPeersAndRejectInvalidKeys() throws {
var group = HostGroup(name: "Large Tailnet")
var configuration = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net"))
for index in 0..<128 {
let id = try #require(UUID(uuidString: String(format: "aaaaaaaa-aaaa-aaaa-aaaa-%012x", index)))
var preferences = TailscaleHostPreferences()
preferences.username = "peer-\(index)"
preferences.terminalTransports.eternalTerminalEnabled = index.isMultiple(of: 2)
preferences.terminalTransports.eternalTerminalPort = 2022 + index
preferences.terminalTransports.defaultTransport = index.isMultiple(of: 2) ? .eternalTerminal : .ssh
configuration.hostPreferences[id] = preferences
}
group.tailscale = configuration
let payload = VaultRecordPayload.hostGroup(group)
let envelope = VaultEnvelope(
key: payload.key, version: .baseline(writerID: UUID()), payload: payload, formatVersion: 6)
let encoded = try VaultSyncCodec.encode(envelope)
#expect(try VaultSyncCodec.decode(encoded) == envelope)
let validKey = "aaaaaaaa-aaaa-aaaa-aaaa-000000000000"
for invalidKey in [validKey.uppercased(), "not-a-uuid", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] {
var json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
var payloadJSON = try #require(json["payload"] as? [String: Any])
var groupJSON = try #require(payloadJSON["hostGroup"] as? [String: Any])
var value = try #require(groupJSON["_0"] as? [String: Any])
var transports = try #require(value["terminalTransports"] as? [String: Any])
let preferencesJSON = try #require(transports[validKey] as? [String: Any])
transports.removeValue(forKey: validKey)
transports[invalidKey] = preferencesJSON
value["terminalTransports"] = transports
groupJSON["_0"] = value
payloadJSON["hostGroup"] = groupJSON
json["payload"] = payloadJSON
let invalid = try JSONSerialization.data(withJSONObject: json)
#expect(throws: (any Error).self) { try VaultSyncCodec.decode(invalid) }
}
}
@Test func checkpointsAreEncryptedConsumedOnceAndCannotResurrectAfterClose() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = TerminalRecoveryStore(directory: directory, testingKey: Data(repeating: 7, count: 32))
var host = Host(name: "Host", host: "server.invalid")
host.terminalTransports.eternalTerminalEnabled = true
let record = TerminalRecoveryRecord(id: UUID(), host: host, username: "user", transport: .eternalTerminal)
let secret = Data("checkpoint-secret-passkey-and-counters".utf8)
let checkpoint = TerminalTransportCheckpoint(transport: .eternalTerminal, payload: secret)
let lease = await store.register(record.id)
try await store.save(record, checkpoint: checkpoint, lease: lease)
let file = try #require(
FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil
).first)
#expect(try Data(contentsOf: file).range(of: secret) == nil)
var changed = host
changed.host = "another.invalid"
await #expect(throws: (any Error).self) { try await store.consume(record.id, host: changed) }
#expect(try await store.records().count == 1)
#expect(try await store.consume(record.id, host: host).payload == secret)
#expect(try await store.records().isEmpty)
await #expect(throws: (any Error).self) { try await store.consume(record.id, host: host) }
try await store.discard(record.id)
await #expect(throws: (any Error).self) {
try await store.save(record, checkpoint: checkpoint, lease: lease)
}
#expect(try await store.records().isEmpty)
}
@Test func routedResumeAuthenticatesTheJumpAndProxyButNeverTheTarget() async throws {
let target = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Target", host: "target.invalid", port: 22,
authentication: .init(identityID: UUID(), username: "target-user"),
expectedHostKeys: [], proxy: nil)
let jump = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Jump", host: "jump.invalid", port: 2222,
authentication: .init(username: "jump-user", hasEphemeralSecret: true), expectedHostKeys: [],
proxy: .init(kind: .socks5, hostname: "proxy.invalid", port: 1080, username: "proxy-user", identityID: nil))
let prompts = RoutePrompts()
let request = try await HostLibraryModel().resumableRouteRequest(
from: .init(target: target, jumps: [jump]), authorizeHostKey: nil,
keyboardInteractive: { challenge in
await prompts.record(challenge)
return ["one-shot"]
},
securityKeySigning: nil, hostKeyConstraints: [:])
let challenges = await prompts.challenges
#expect(challenges.map(\.hostname) == ["jump.invalid", "proxy.invalid"])
#expect(challenges.map(\.username) == ["jump-user", "proxy-user"])
#expect(challenges.allSatisfy { $0.prompts.count == 1 && !$0.prompts[0].echo })
#expect(request.configuration.host == "target.invalid")
#expect(request.configuration.credentials.isEmpty)
let hop = try #require(request.configuration.jumps.first)
guard case .password(let password) = try #require(hop.credentials.first) else {
Issue.record("The jump should receive its one-shot password")
return
}
#expect(password.matches("one-shot"))
#expect(hop.proxy?.password?.matches("one-shot") == true)
request.configuration.clearSecrets()
#expect(password.isConsumed)
#expect(hop.proxy?.password?.isConsumed == true)
}
@Test func cancelledRouteAuthenticationDoesNotCreateATargetConnection() async throws {
let node = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Jump", host: "jump.invalid", port: 22,
authentication: .init(username: "user", hasEphemeralSecret: true),
expectedHostKeys: [], proxy: nil)
await #expect(throws: CancellationError.self) {
try await HostLibraryModel().resumableRouteRequest(
from: .init(target: node, jumps: [node]), authorizeHostKey: nil,
keyboardInteractive: { _ in throw CancellationError() }, securityKeySigning: nil,
hostKeyConstraints: [:])
}
}
@Test func explicitTmuxControlAuthenticatesTheTargetAndRetainsItsTrust() async throws {
let key = SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:target")
let target = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Target", host: "target.invalid", port: 22,
authentication: .init(username: "user", hasEphemeralSecret: true),
expectedHostKeys: [key], proxy: nil)
let prompts = RoutePrompts()
let request = try await HostLibraryModel().resumableRouteRequest(
from: .init(target: target, jumps: []), authorizeHostKey: nil,
keyboardInteractive: { challenge in
await prompts.record(challenge)
return ["control-secret"]
},
securityKeySigning: nil, hostKeyConstraints: [:], authenticatingTarget: true)
#expect(await prompts.challenges.map(\.hostname) == ["target.invalid"])
#expect(request.configuration.jumps.isEmpty)
#expect(request.configuration.expectedHostKeys == [key])
guard case .password(let password) = try #require(request.configuration.credentials.first) else {
Issue.record("Explicit tmux control needs target authentication")
return
}
#expect(password.matches("control-secret"))
request.configuration.clearSecrets()
#expect(password.isConsumed)
}
@Test func restoredMoshControlRebuildsItsJumpRouteWithoutReadingSecrets() async throws {
let jump = Host(name: "Jump", host: "jump.invalid")
var target = Host(name: "Target", host: "target.invalid")
target.jumpHostID = jump.id
target.proxy = .init(kind: .socks5, hostname: "proxy.invalid", port: 1080)
let library = HostLibraryModel(hosts: [jump, target])
let snapshot = try library.resumableControlSnapshot(for: target, username: "target-user")
#expect(snapshot.jumps.map(\.id) == [jump.id])
#expect(snapshot.target.proxy?.hostname == "proxy.invalid")
#expect(snapshot.target.authentication.username == "target-user")
let prompts = RoutePrompts()
let request = try await library.resumableRouteRequest(
from: snapshot, authorizeHostKey: nil,
keyboardInteractive: { challenge in
await prompts.record(challenge)
return [challenge.prompts.first?.echo == true ? "jump-user" : "secret"]
},
securityKeySigning: nil, hostKeyConstraints: [:], authenticatingTarget: true)
defer { request.configuration.clearSecrets() }
#expect(request.configuration.jumps.first?.username == "jump-user")
#expect(request.configuration.username == "target-user")
#expect(await prompts.challenges.map(\.hostname) == ["jump.invalid", "jump.invalid", "target.invalid"])
}
@Test func partialAuthenticatedShellEventsSurviveCheckpointEncoding() throws {
var filter = RemoteCommandEventFilter(token: "checkpoint")
let prefix = Data("\u{1b}]777;TermindCommand=checkpoint;echo%20".utf8)
#expect(filter.receive(prefix).events.isEmpty)
var restored = try JSONDecoder().decode(
RemoteCommandEventFilter.self, from: JSONEncoder().encode(filter))
let update = restored.receive(Data("hello\u{7}visible".utf8))
#expect(update.events == [.commandStarted("echo hello")])
#expect(update.visibleOutput == Data("visible".utf8))
}
private actor RoutePrompts {
private(set) var challenges: [SSHKeyboardInteractiveChallenge] = []
func record(_ challenge: SSHKeyboardInteractiveChallenge) { challenges.append(challenge) }
}
}