Files
Termind/FrameworkTests/TailscaleIntegrationTests.swift
T

698 lines
37 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 TailscaleIntegrationTests {
@Test func lateMetricsSyncCannotRestoreAnExplicitlyDeletedOrdinaryHost() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let hostID = UUID()
let tombstone = VaultEnvelope(
key: VaultRecordKey(kind: .host, id: hostID),
version: .baseline(writerID: UUID()), isDeleted: true)
try VaultSyncJournal(context: container.mainContext).acceptRemote([tombstone])
try container.mainContext.save()
let metrics = MetricsSyncPersistence(context: container.mainContext)
let payload = MetricsRecordPayload.selection(
SyncedMetricsSelection(selection: MetricsSelection(id: hostID), sortIndex: 0))
let result = try metrics.applyRemote([
MetricsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
])
#expect(result.snapshot.selections.isEmpty)
#expect(result.needsUpload.contains(payload.key))
#expect(try metrics.journal.envelope(for: payload.key)?.isDeleted == true)
}
@Test func losingProBlocksRetainedDialersWithoutClosingExistingNodes() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID()))
let node = TestTailscaleNode(network: try #require(group.tailscale?.network))
let access = TailscaleTestAccess()
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
profileStorage: .init(profiles: { [] }, erase: { _ in }),
hostname: { "Existing device name" }, hasAccess: { access.allowed })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(model.runningGroups.contains(group.id))
let retained = try model.connection(for: group.id)
access.allowed = false
model.reconcile()
await #expect(throws: TailscaleConfigurationError.self) {
try await retained.open(host: "server.example.ts.net", port: 22, timeout: 1)
}
#expect(await node.closed == false)
await model.shutdown()
#expect(await node.closed)
}
@Test func previousStoreMigratesGroupsWithoutChangingOrdinaryHosts() throws {
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("Termind.store")
let ordinaryGroup = HostGroup(name: "Existing")
let ordinaryHost = Host(name: "Existing", host: "example.invalid", groupID: ordinaryGroup.id)
try writePreviousStore(url, group: ordinaryGroup, host: ordinaryHost)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
#expect(library.host(ordinaryHost.id)?.host == ordinaryHost.host)
#expect(library.group(ordinaryGroup.id)?.name == ordinaryGroup.name)
#expect(library.group(ordinaryGroup.id)?.tailscale == nil)
}
}
@Test func tailnetStoreReopensWithoutLosingEnrollmentOrPreferences() throws {
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("Termind.store")
var group = try group("example.ts.net")
let hostID = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "n123")
var preferences = TailscaleHostPreferences()
preferences.username = "deploy"
preferences.tags = ["production"]
preferences.colorTag = .purple
group.tailscale?.hostPreferences[hostID] = preferences
let profileID = UUID()
let peer = peer()
try writeTailnetStore(url, group: group, profileID: profileID, peer: peer)
for pass in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let localStore = SwiftDataTailscaleLocalStore(context: container.mainContext)
var state = try #require(localStore.load().first)
#expect(state.groupID == group.id && state.profileID == profileID)
#expect(state.peers == [peer])
#expect(state.hostname == (pass == 0 ? nil : "saved-device"))
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
#expect(library.group(group.id)?.tailscale?.hostPreferences[hostID] == preferences)
library.replaceTailscalePeers([peer], groupID: group.id)
#expect(library.hosts.first?.id == hostID)
let retainedHost = try #require(library.host(hostID))
#expect(library.savedTailscaleUsername(for: retainedHost) == "deploy")
state.hostname = "saved-device"
try localStore.save(state)
}
}
private func writeTailnetStore(_ url: URL, group: HostGroup, profileID: UUID, peer: TailscalePeer) throws {
let container = try TermindPersistence.makeContainer(url: url)
_ = try HostPersistence(context: container.mainContext).save(.init(groups: [group]))
try SwiftDataTailscaleLocalStore(context: container.mainContext).save(
TailscaleLocalState(groupID: group.id, profileID: profileID, peers: [peer]))
}
private func writePreviousStore(_ url: URL, group: HostGroup, host: Host) throws {
let schema = Schema(versionedSchema: TermindSchemaV46.self)
let container = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
container.mainContext.insert(PersistentHostGroup(group))
container.mainContext.insert(PersistentHost(host))
try container.mainContext.save()
}
private func group(_ suffix: String, parentID: UUID? = nil) throws -> HostGroup {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix)
var group = HostGroup(name: suffix, parentID: parentID)
group.tailscale = TailscaleGroupConfiguration(network: network)
group.tailscale?.username = "operator"
return group
}
@Test func controlURLsRemainCanonicalConnectionConfiguration() throws {
let first = try TailscaleNetwork(controlURL: "https://LOGIN.TAILSCALE.COM:443/", dnsSuffix: "Example.TS.NET.")
let second = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
#expect(first == second)
#expect(throws: TailscaleConfigurationError.self) {
try TailscaleNetwork(controlURL: "https://user:secret@example.com", dnsSuffix: "example.com")
}
}
@Test func subgroupOverridesNearestParentAndMovingChangesNewConnections() throws {
let parent = try group("parent.ts.net")
var child = HostGroup(name: "Child", parentID: parent.id)
let leaf = HostGroup(name: "Leaf", parentID: child.id)
let host = Host(name: "Manual", host: "server", groupID: leaf.id)
let library = HostLibraryModel(hosts: [host], groups: [parent, child, leaf])
#expect(library.tailscaleGroup(for: host)?.id == parent.id)
child.tailscale = try group("child.ts.net").tailscale
library.upsert(child)
#expect(library.tailscaleGroup(for: host)?.id == child.id)
child.tailscale = nil
child.parentID = nil
library.upsert(child)
#expect(library.tailscaleGroup(for: host) == nil)
child.parentID = parent.id
library.upsert(child)
#expect(throws: TailscaleConfigurationError.self) { try library.tailscaleNetwork(for: host) }
}
@Test func additionalOrdinaryConnectionsCannotDropTheirRequiredTailnetRoute() throws {
let group = try group("example.ts.net")
let host = Host(name: "Manual", host: "server", groupID: group.id)
let library = HostLibraryModel(hosts: [host], groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
var configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: 22, username: "operator", credentials: [], expectedHostKeys: [],
authenticationReference: SSHAuthenticationReference(username: "operator", permitsNone: true))
configuration.network = try library.tailscaleNetwork(for: host)
let snapshot = library.additionalConnectionSnapshot(from: configuration)
#expect(try library.additionalConnectionRequest(from: snapshot).configuration.network != nil)
library.move(hostIDs: [host.id], into: nil)
#expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) }
#expect(try library.tailscaleNetwork(for: host) == nil)
}
@Test func networkStampTracksOnlyThisConnectionsRoutingAndAuthentication() throws {
var group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let original = library.networkConfigurationStamp(for: host)
for _ in 0..<100 { #expect(library.networkConfigurationStamp(for: host) == original) }
var unrelated = TailscaleHostPreferences()
unrelated.username = "another-user"
unrelated.tags = ["production"]
group.tailscale?.hostPreferences[UUID()] = unrelated
library.upsert(group)
#expect(library.networkConfigurationStamp(for: host) == original)
group.tailscale?.username = "changed-user"
library.upsert(group)
let changedUser = library.networkConfigurationStamp(for: host)
#expect(changedUser != original)
group.tailscale?.exitNodeID = "exit-node"
library.upsert(group)
#expect(library.networkConfigurationStamp(for: host) != changedUser)
}
@Test(arguments: [true, false])
func loginRechecksRemoteGroupChangesAfterApplyingExitNode(deleted: Bool) async throws {
var group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
let node = TestTailscaleNode(network: try #require(group.tailscale?.network), holdExitNode: true)
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
profileStorage: .init(profiles: { [] }, erase: { _ in }),
hostname: { "device" }, hasAccess: { true })
model.beginLoginAndSave(
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL,
authKey: nil, groupID: group.id)
await node.waitForExitNode()
group.name = "Remote name"
group.tailscale?.username = "remote-user"
library.replacePersistedSnapshot(VaultSnapshot(groups: deleted ? [] : [group]))
await node.releaseExitNode()
for _ in 0..<1000 {
if deleted ? await node.closed : model.loginCompletedGroupID != nil { break }
await Task.yield()
}
if deleted {
#expect(library.group(group.id) == nil)
#expect(try store.load().isEmpty)
#expect(model.loginCompletedGroupID == nil)
#expect(await node.closed)
#expect(await node.logoutCount == 1)
} else {
#expect(library.group(group.id)?.name == "Remote name")
#expect(library.group(group.id)?.tailscale?.username == "remote-user")
#expect(model.loginCompletedGroupID == group.id)
#expect(try store.load().count == 1)
}
await model.shutdown()
}
@Test func sameTailnetCanBeBoundToAnExplicitSubgroup() throws {
let parent = try group("example.ts.net")
let library = HostLibraryModel(groups: [parent])
var duplicate = HostGroup(name: "Duplicate", parentID: parent.id)
duplicate.tailscale = parent.tailscale
library.upsert(duplicate)
#expect(library.group(duplicate.id)?.tailscale?.network == parent.tailscale?.network)
#expect(library.storageError == nil)
}
@Test func discoveredHostsStayLocalAndPreferencesRoundTripThroughGroup() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = HostPersistence(context: container.mainContext)
let library = HostLibraryModel(persistence: persistence)
let group = try group("example.ts.net")
library.upsert(group)
library.replaceTailscalePeers([peer()], groupID: group.id)
let managed = try #require(library.hosts.first)
#expect(managed.host == "server.example.ts.net")
#expect(library.saveTailscaleUsername("root", hostID: managed.id))
library.projectHostDeletion(managed.id)
#expect(library.host(managed.id) != nil)
var forged = managed
forged.host = "attacker.invalid"
#expect(!library.saveHost(forged))
let reloaded = HostLibraryModel(persistence: persistence)
#expect(reloaded.hosts.isEmpty)
reloaded.replaceTailscalePeers([peer(name: "Renamed", online: false)], groupID: group.id)
#expect(reloaded.hosts.first?.id == managed.id)
#expect(reloaded.tailscaleUsername(for: managed) == "root")
#expect(!reloaded.isEligible(managed, for: .keyExport))
#expect(!reloaded.isEligible(managed, for: .jumpHost))
}
@Test func managedPreparationAndExtraConnectionsNeverResolveOrdinaryCredentials() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let preparation = try library.prepareSSHConnection(for: host, submissions: [:], proxySubmissions: [:])
#expect(preparation.nodes.first?.identity == nil)
#expect(preparation.nodes.first?.key == nil)
let configuration = try await preparation.resolve()
#expect(configuration.credentials.isEmpty)
#expect(configuration.expectedHostKeys.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(configuration.network != nil)
let snapshot = library.additionalConnectionSnapshot(from: configuration)
let extra = try library.additionalConnectionRequest(from: snapshot)
#expect(extra.configuration.credentials.isEmpty)
#expect(extra.configuration.network != nil)
#expect(extra.configuration.tailscaleNodeID == "n123")
provider.allowed = false
#expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) }
}
@Test func tagsAndColorSyncBeforeAnotherDeviceDiscoversTheHost() throws {
let source = try TermindPersistence.makeContainer(inMemory: true)
let sourceLibrary = HostLibraryModel(persistence: HostPersistence(context: source.mainContext))
let group = try group("example.ts.net")
sourceLibrary.upsert(group)
sourceLibrary.replaceTailscalePeers([peer()], groupID: group.id)
var host = try #require(sourceLibrary.hosts.first)
host.tags = ["production", "linux"]
host.colorTag = .blue
#expect(sourceLibrary.saveHost(host))
sourceLibrary.recordRemoteSystemInfo(
RemoteSystemInfo(distributionID: "ubuntu", distributionLikeIDs: ["debian"], kernelName: "Linux"),
for: host.id)
sourceLibrary.replaceTailscalePeers([peer(name: "Refreshed")], groupID: group.id)
#expect(sourceLibrary.host(host.id)?.detectedDistribution?.id == "ubuntu")
var exitGroup = try #require(sourceLibrary.group(group.id))
exitGroup.tailscale?.exitNodeID = "exit-node-stable-id"
sourceLibrary.upsert(exitGroup)
let envelopes = try VaultSyncJournal(context: source.mainContext).pendingEnvelopes()
#expect(!envelopes.contains { $0.key.kind == .host })
let remote = try envelopes.map { try VaultSyncCodec.decode(VaultSyncCodec.encode($0)) }
let destination = try TermindPersistence.makeContainer(inMemory: true)
let destinationStore = HostPersistence(context: destination.mainContext)
_ = try destinationStore.applyRemote(remote)
let destinationLibrary = HostLibraryModel(persistence: destinationStore)
#expect(destinationLibrary.hosts.isEmpty)
destinationLibrary.replaceTailscalePeers([peer(name: "Renamed")], groupID: group.id)
let discovered = try #require(destinationLibrary.host(host.id))
#expect(discovered.tags == ["production", "linux"])
#expect(discovered.colorTag == .blue)
#expect(discovered.detectedDistribution?.id == "ubuntu")
#expect(destinationLibrary.group(group.id)?.tailscale?.exitNodeID == "exit-node-stable-id")
#expect(discovered.name == "Renamed")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Linux"), for: discovered.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "linux")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "FreeBSD"), for: discovered.id)
destinationLibrary.replaceTailscalePeers([peer()], groupID: group.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "freebsd")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(), for: discovered.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "freebsd")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "SunOS"), for: discovered.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution == nil)
}
@Test func usernamePromptsAndRouteSavingNeverCreateOrdinaryIdentities() async throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let prompt = SSHAuthenticationPromptResolver.host(host, using: library, username: "")
#expect(prompt.usernameOnly)
#expect(prompt.username == "root")
#expect(prompt.selectedIdentityID == nil && prompt.selectedKeyID == nil)
#expect(!prompt.requiresPassphrase)
let submission = SSHAuthenticationSubmission(username: "operator")
_ = try library.saveAuthentication(submission, for: host, selectedIdentity: nil, suppliedPassword: nil)
#expect(library.tailscaleUsername(for: host) == "operator")
#expect(library.identities.isEmpty)
_ = try library.saveConnectionRoute(
SSHConnectionRoute(hosts: [host]), authentication: submission, authenticationHostID: host.id)
#expect(library.identities.isEmpty && library.hosts.count == 1)
let ephemeral = SSHAuthenticationSubmission(username: "another-user", password: "ignored", keyID: UUID())
let preparation = try library.prepareSSHConnection(for: host, submissions: [host.id: ephemeral])
let configuration = try await preparation.resolve()
#expect(configuration.username == "another-user")
#expect(configuration.credentials.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(library.tailscaleUsername(for: host) == "operator")
}
@Test func assistantAndExternalUsernameRequestsUseTheTailscalePrompt() async throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let interaction = AgentSSHInteraction(
authenticate: { request in
#expect(request.mode == .tailscaleUsername)
#expect(request.journeyPrompt.usernameOnly)
#expect(request.selectedIdentityID == nil && request.selectedKeyID == nil)
return .submit(AgentSSHAuthenticationAnswer(username: "deploy", save: true))
},
confirmHostKey: { _ in
Issue.record("Managed hosts must not ask for ordinary host-key trust")
return .deny
})
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(), interaction: interaction
)
let configuration = try await session.configuration()
#expect(configuration.username == "deploy")
#expect(configuration.credentials.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(library.tailscaleUsername(for: host) == "deploy")
#expect(library.identities.isEmpty)
let resumed = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(),
interaction: AgentSSHInteraction(
authenticate: { _ in
Issue.record("Saved Tailscale username must bypass the initial prompt")
return .cancelled
}, confirmHostKey: { _ in .deny }))
#expect(try await resumed.configuration().username == "deploy")
}
@Test func olderHostPreferencesDecodeWithoutTagsOrColor() throws {
let data = Data(
#"{"username":"operator","charset":"utf8","sftpInitialPath":"/srv","shellIntegrationMode":"followGlobalSettings","environmentVariables":[]}"#
.utf8)
let preferences = try JSONDecoder().decode(TailscaleHostPreferences.self, from: data)
#expect(preferences.tags.isEmpty)
#expect(preferences.colorTag == .none)
#expect(preferences.username == "operator")
#expect(preferences.sftpInitialPath == "/srv")
}
@Test func nodesWithoutSSHDisappearWithoutLosingSyncedPreferencesOrManualHosts() throws {
let group = try group("example.ts.net")
let manual = Host(name: "Manual", host: "manual.example.ts.net", groupID: group.id)
let library = HostLibraryModel(hosts: [manual], groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
var managed = try #require(library.hosts.first { $0.isTailscaleManaged })
managed.tags = ["keep"]
#expect(library.saveHost(managed))
var disabled = peer()
disabled.sshHostKeys = []
library.replaceTailscalePeers([disabled], groupID: group.id)
#expect(library.host(managed.id) == nil)
#expect(library.host(manual.id) != nil)
library.replaceTailscalePeers([peer(online: false)], groupID: group.id)
#expect(library.host(managed.id)?.tags == ["keep"])
}
@Test func statusRetainsNonSSHPeersForDiagnosticsAndReportsLocalDeviceDetails() throws {
let data = Data(
#"{"BackendState":"Running","CurrentTailnet":{"Name":"Example","MagicDNSSuffix":"example.ts.net"},"Self":{"HostName":"Phone","DNSName":"phone.example.ts.net.","TailscaleIPs":["100.64.0.1","fd7a:115c:a1e0::1"],"OS":"iOS","KeyExpiry":"2027-01-02T03:04:05.123Z"},"Peer":{"disabled":{"ID":"n456","HostName":"desktop","DNSName":"desktop.example.ts.net.","Online":true,"InNetworkMap":true,"ExitNodeOption":true}}}"#
.utf8)
let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL)
#expect(status.peers.count == 1)
#expect(status.peers.first?.supportsSSH == false)
#expect(status.peers.first?.exitNodeOption == true)
let device = try #require(status.device)
#expect(device.networkName == "Example")
#expect(device.dnsName == "phone.example.ts.net")
#expect(device.hostname == "Phone")
#expect(device.addresses == ["100.64.0.1", "fd7a:115c:a1e0::1"])
#expect(device.operatingSystem == "iOS")
#expect(device.keyExpiry != nil)
}
@Test func missingTailscaleMetricsRetainConfigurationUntilExplicitGroupDeletion() throws {
let group = try group("example.ts.net")
var selection = MetricsSelection(id: UUID(), enabled: false)
selection.tailscaleGroupID = group.id
selection.displayName = "Offline node"
let preferences = TailscaleMetricsPreferences(values: [selection])
let library = HostLibraryModel(groups: [group])
let metrics = ServerMetricsModel(
hosts: library, preferences: preferences,
makeSampler: { TailscaleTestSampler() })
metrics.start()
defer { metrics.shutdown() }
#expect(metrics.servers.isEmpty)
#expect(metrics.selections == [selection])
#expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: []))
#expect(preferences.values == [selection])
metrics.removeTailscaleGroups([group.id])
#expect(preferences.values.isEmpty)
#expect(metrics.servers.isEmpty)
}
@Test func statusRequiresAnAuthoritativePeerMapAndKeepsOfflineNodes() throws {
let data = Data(
#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":{"key":{"ID":"n123","HostName":"server","DNSName":"server.example.ts.net.","Online":false,"InNetworkMap":true,"sshHostKeys":["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]}}}"#
.utf8)
let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL)
#expect(status.running)
#expect(status.peers.count == 1)
#expect(status.peers.first?.online == false)
let incomplete = Data(#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"}}"#.utf8)
#expect(throws: DecodingError.self) {
try LibTailscaleNodeSession.decodeStatus(incomplete, controlURL: TailscaleNetwork.defaultControlURL)
}
let empty = Data(
#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":null}"#.utf8)
#expect(try LibTailscaleNodeSession.decodeStatus(empty, controlURL: TailscaleNetwork.defaultControlURL).running)
#expect(LibTailscaleNodeSession.hostKey("ssh-ed25519 invalid") == nil)
#expect(
LibTailscaleNodeSession.hostKey(
"ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == nil)
}
@Test func savedUsernameAppearsInCardsWithoutInventingAnUnsavedLogin() throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
#expect(library.savedTailscaleUsername(for: host) == nil)
#expect(library.loginAddress(for: host) == "server.example.ts.net")
#expect(library.saveTailscaleUsername("operator", hostID: host.id))
#expect(library.loginAddress(for: host) == "operator@server.example.ts.net")
#expect(library.identities.isEmpty)
}
@Test func sshCommandUsesTailscaleUsernameAndPreviewsUnsavedEdits() throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
#expect(library.sshCommand(for: host) == "ssh operator@server.example.ts.net")
#expect(library.saveTailscaleUsername("deploy", hostID: host.id))
#expect(library.sshCommand(for: host) == "ssh deploy@server.example.ts.net")
#expect(
library.sshCommand(for: host, username: " o'brien ")
== "ssh 'o'\"'\"'brien@server.example.ts.net'")
#expect(library.sshCommand(for: host, username: "") == "ssh server.example.ts.net")
#expect(library.savedTailscaleUsername(for: host) == "deploy")
#expect(library.identities.isEmpty)
}
@Test func hostnamePersistsLocallyAndEditsReachTheRunningNode() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let container = try TermindPersistence.makeContainer(inMemory: true)
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID()))
let node = TestTailscaleNode(network: try #require(group.tailscale?.network))
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
profileStorage: .init(profiles: { [] }, erase: { _ in }),
hostname: { "default-device" }, hasAccess: { true })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(try store.load().first?.hostname == "default-device")
try await model.saveHostname(" custom-device ", groupID: group.id)
#expect(await node.hostname == "custom-device")
#expect(try store.load().first?.hostname == "custom-device")
await model.shutdown()
let restored = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
profileStorage: .init(profiles: { [] }, erase: { _ in }),
hostname: { "renamed-system-device" }, hasAccess: { false })
restored.start()
#expect(restored.savedHostname(groupID: group.id) == "custom-device")
await restored.shutdown()
}
@Test func pingDistinguishesDirectRelayUnknownAndServerFailure() throws {
func decode(_ json: String) throws -> TailscalePingResult {
try LibTailscaleNodeSession.decodePing(Data(json.utf8))
}
let direct = try decode(#"{"LatencySeconds":0.012,"Endpoint":"192.0.2.1:41641"}"#)
#expect(direct.milliseconds == 12)
#expect(direct.route == .direct("192.0.2.1:41641"))
#expect(try decode(#"{"LatencySeconds":0.1,"DERPRegionID":3,"DERPRegionCode":"sin"}"#).route == .derp("sin"))
#expect(
try decode(#"{"LatencySeconds":0.1,"PeerRelay":"100.64.0.1:1:vni:2"}"#).route
== .peerRelay("100.64.0.1:1:vni:2"))
#expect(try decode(#"{"LatencySeconds":0.1}"#).route == .unknown)
#expect(throws: TermindSSHError.self) { try decode(#"{"Err":"peer is offline"}"#) }
}
@Test func syncedExitNodeIsAppliedAndCanBeDisabled() async throws {
var group = try group("example.ts.net")
group.tailscale?.exitNodeID = "exit-one"
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID(), hostname: "device"))
let exitPeer = TailscalePeer(
stableID: "exit-one", name: "Exit without SSH", dnsName: "exit.example.ts.net",
online: true, sshHostKeys: [], exitNodeOption: true)
let node = TestTailscaleNode(
network: try #require(group.tailscale?.network), peers: [exitPeer, peer()])
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
profileStorage: .init(profiles: { [] }, erase: { _ in }),
hostname: { "device" }, hasAccess: { true })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-one")
#expect(
TailscaleGroupEditor(model: model, group: library.group(group.id)).candidates.map(\.stableID) == [
"exit-one"
])
#expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" })
let retained = try model.connection(for: group.id)
group.tailscale?.exitNodeID = "exit-two"
library.upsert(group)
await #expect(throws: TailscaleConfigurationError.self) {
try await retained.open(host: "example.com", port: 22, timeout: 1)
}
for _ in 0..<400 {
if await node.selectedExitNode == "exit-two" { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-two")
let editor = TailscaleGroupEditor(model: model, group: library.group(group.id))
editor.exitNodeID = ""
_ = try await editor.save()
for _ in 0..<400 {
if await node.selectedExitNode == nil { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == nil)
#expect(library.group(group.id)?.tailscale?.exitNodeID == nil)
await model.shutdown()
}
private func peer(name: String = "Server", online: Bool = true) -> TailscalePeer {
TailscalePeer(
stableID: "n123", name: name, dnsName: "server.example.ts.net", online: online,
sshHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"])
}
}
@MainActor private final class TestTailscaleProvider: TailscaleConnectionProviding {
var allowed = true
func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
guard allowed else { throw TailscaleConfigurationError.requiresPro }
return TestTailscaleDialer()
}
}
private nonisolated struct TestTailscaleDialer: SSHNetworkDialing {
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}
@MainActor private final class TailscaleMetricsPreferences: MetricsConfigurationStoring {
var values: [MetricsSelection]
init(values: [MetricsSelection]) { self.values = values }
func load() throws -> [MetricsSelection] { values }
func save(_ selections: [MetricsSelection]) throws { values = selections }
}
private nonisolated struct TailscaleTestSampler: MetricsSampling {
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
func sample() async throws -> MetricsSample { throw CancellationError() }
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}
@MainActor private final class TailscaleTestAccess { var allowed = true }
private actor TestTailscaleNode: TailscaleNodeSession {
let network: TailscaleNetwork
let peers: [TailscalePeer]
var closed = false
var hostname = ""
var selectedExitNode: String?
var logoutCount = 0
private var holdExitNode: Bool
private var exitNodeContinuation: CheckedContinuation<Void, Never>?
func setExitNode(_ stableID: String?) async throws {
if holdExitNode {
holdExitNode = false
await withCheckedContinuation { exitNodeContinuation = $0 }
}
selectedExitNode = stableID
}
func waitForExitNode() async {
while exitNodeContinuation == nil { await Task.yield() }
}
func releaseExitNode() {
exitNodeContinuation?.resume()
exitNodeContinuation = nil
}
func setHostname(_ hostname: String) async throws { self.hostname = hostname }
init(network: TailscaleNetwork, peers: [TailscalePeer] = [], holdExitNode: Bool = false) {
self.network = network
self.peers = peers
self.holdExitNode = holdExitNode
}
func status() async throws -> TailscaleNodeStatus {
TailscaleNodeStatus(running: true, network: network, authURL: nil, peers: peers)
}
func login() async throws {}
func logout() async throws { logoutCount += 1 }
func close() async { closed = true }
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}