feat: isolate tailscale connections and harden history

Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment.

Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision.

Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
This commit is contained in:
Kaede Akino committed 2026-10-03 09:46:09 +08:00
1 parent 70218856df
commit 1ab7ed3da3
37 files changed
+1766 -133

No files matched your search

@@ -8,6 +8,32 @@
@Suite
struct RemoteShellHistoryLoaderTests {
@Test func realZshHistoryPreservesBackslashesBeforeLogicalNewlines() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
let commands =
(0...2).map { "echo one" + String(repeating: "\\", count: $0) + "\ntwo" }
+ ["echo end\\", "pwd"]
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments =
[
"-f", "-i", "-c",
"HISTFILE=$1; HISTSIZE=100; SAVEHIST=100; setopt EXTENDED_HISTORY; shift; "
+ "for c in \"$@\"; do print -sr -- \"$c\"; done; fc -W \"$HISTFILE\"",
"history-test", file.path,
] + commands
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil }, execute: { command, _ in try Self.execute(command) })
let records = try await loader.load(source: .init(shell: .zsh, path: file.path))
#expect(records.map(\.command) == Array(commands.prefix(3)) + ["echo end\\ ", "pwd"])
}
@Test func execReadsQuotedPathsAsDataAndWorksWithoutIntegration() async throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+87
View File
@@ -172,6 +172,93 @@ struct ShellHistoryParsingTests {
@Suite @MainActor
struct RetiredSessionHistoryMigrationTests {
@Test func version48RetainsCloudHistoryJournalsAndDeviceStateAfterMigrationAndReopening() 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("CloudHistory.store")
let device = DeviceRecord(id: UUID(), displayName: "Original device")
let event = ConnectionHistoryEntry(
hostID: UUID(), kind: .sftp, connectedAt: Date(timeIntervalSince1970: 100),
sourceDeviceID: device.id, endedAt: Date(timeIntervalSince1970: 200))
let deletedEvent = ConnectionHistoryEntry(hostID: event.hostID, kind: .ssh)
let version = VaultVersion(modifiedAt: Date(timeIntervalSince1970: 300), counter: 4, writerID: device.id)
let eventPayload = HistoryRecordPayload.connectionHistory(event)
let devicePayload = HistoryRecordPayload.device(device)
let envelopes = [
HistoryEnvelope(key: eventPayload.key, version: version, payload: eventPayload),
HistoryEnvelope(key: devicePayload.key, version: version, payload: devicePayload),
HistoryEnvelope(
key: .init(kind: .connectionHistory, id: deletedEvent.id), version: version, isDeleted: true),
HistoryEnvelope(key: .init(kind: .device, id: UUID()), version: version, isDeleted: true),
]
try writeCloudHistoryStore(url, event: event, device: device, envelopes: envelopes)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let context = container.mainContext
let persistence = HistorySyncPersistence(context: context)
try persistence.prepare()
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).map(\.value) == [event])
let devices = try context.fetch(FetchDescriptor<PersistentDeviceRecord>())
#expect(
devices.count == 1 && devices.first?.id == device.id && devices.first?.displayName == device.displayName
)
let rows = try context.fetch(FetchDescriptor<PersistentHistorySyncEntry>())
#expect(Set(rows.compactMap(\.envelope)) == Set(envelopes))
for (index, envelope) in envelopes.enumerated() {
let row = try #require(rows.first { $0.recordName == envelope.key.recordName })
#expect(row.systemFieldsData == Data([UInt8(index), 42]))
#expect(row.needsUpload == (index != 1))
}
for kind in HistoryRecordKind.allCases {
let state = try HistorySyncJournal(context: context, kind: kind).state()
#expect(state.bootstrapCompleted)
#expect(state.installationID == device.id)
#expect(state.boundUserRecordName == "account-\(kind.rawValue)")
#expect(state.pendingUserRecordName == "pending-\(kind.rawValue)")
#expect(state.engineStateData == Data(kind.rawValue.utf8))
#expect(state.lastSuccessfulSync == version.modifiedAt)
}
let late = HistoryRecordPayload.connectionHistory(deletedEvent)
_ = try persistence.applyRemote([
HistoryEnvelope(
key: late.key, version: .next(after: version, writerID: UUID()), payload: late)
])
#expect(try persistence.journal.envelope(for: late.key)?.isDeleted == true)
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).map(\.value) == [event])
}
}
private func writeCloudHistoryStore(
_ url: URL, event: ConnectionHistoryEntry, device: DeviceRecord, envelopes: [HistoryEnvelope]
) throws {
let schema = Schema(versionedSchema: TermindSchemaV48.self)
let container = try ModelContainer(
for: schema,
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
let context = container.mainContext
context.insert(PersistentConnectionHistoryEntry(event))
context.insert(PersistentDeviceRecord(device))
context.insert(PersistentCommandHistoryEntry(hostID: event.hostID, command: "retired", executedAt: .now))
context.insert(
PersistentSFTPHistoryEntry(hostID: event.hostID, kind: "file", path: "/retired", visitedAt: .now))
for (index, envelope) in envelopes.enumerated() {
let row = try PersistentHistorySyncEntry(envelope: envelope, needsUpload: index != 1)
row.systemFieldsData = Data([UInt8(index), 42])
context.insert(row)
}
for kind in HistoryRecordKind.allCases {
let state = try HistorySyncJournal(context: context, kind: kind).state()
state.bootstrapCompleted = true
state.installationID = device.id
state.boundUserRecordName = "account-\(kind.rawValue)"
state.pendingUserRecordName = "pending-\(kind.rawValue)"
state.engineStateData = Data(kind.rawValue.utf8)
state.lastSuccessfulSync = envelopes[0].version.modifiedAt
}
try context.save()
}
@Test(arguments: [9, 48])
func oldStoresDropSessionHistoryAndRetainOtherDataAfterReopening(version: Int) throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+99 -10
View File
@@ -38,6 +38,7 @@ struct TailscaleIntegrationTests {
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) {
@@ -79,6 +80,7 @@ struct TailscaleIntegrationTests {
let url = directory.appendingPathComponent("Termind.store")
var group = try group("example.ts.net")
let network = try #require(group.tailscale?.network)
group.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
let hostID = network.hostID(stableNodeID: "n123")
var preferences = TailscaleHostPreferences()
preferences.username = "deploy"
@@ -97,6 +99,10 @@ struct TailscaleIntegrationTests {
#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)
}
@@ -110,8 +116,12 @@ struct TailscaleIntegrationTests {
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
container.mainContext.insert(PersistentHostGroup(group))
container.mainContext.insert(
try PersistentTailscaleGroup(id: group.id, configuration: #require(group.tailscale)))
let configuration = try PersistentTailscaleGroup(id: group.id, configuration: #require(group.tailscale))
var historical = try #require(
JSONSerialization.jsonObject(with: configuration.configurationData) as? [String: Any])
historical.removeValue(forKey: "hostIdentityVersion")
configuration.configurationData = try JSONSerialization.data(withJSONObject: historical)
container.mainContext.insert(configuration)
container.mainContext.insert(
TermindSchemaV47TailscaleModels.PersistentTailscaleLocalState(
groupID: group.id, profileID: profileID, peersData: try JSONEncoder().encode([peer])))
@@ -171,7 +181,7 @@ struct TailscaleIntegrationTests {
#expect(throws: TailscaleConfigurationError.self) { try library.tailscaleNetwork(for: host) }
}
@Test func additionalOrdinaryConnectionsFollowTheHostsCurrentGroup() throws {
@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])
@@ -186,18 +196,77 @@ struct TailscaleIntegrationTests {
library.move(hostIDs: [host.id], into: nil)
#expect(try library.additionalConnectionRequest(from: snapshot).configuration.network == nil)
#expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) }
#expect(try library.tailscaleNetwork(for: host) == nil)
}
@Test func duplicateTailnetIsRejectedIncludingAnExplicitSubgroup() throws {
@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.beginLogin(
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) == nil)
#expect(library.storageError != nil)
#expect(library.group(duplicate.id)?.tailscale?.network == parent.tailscale?.network)
#expect(library.storageError == nil)
}
@Test func discoveredHostsStayLocalAndPreferencesRoundTripThroughGroup() throws {
@@ -481,6 +550,7 @@ struct TailscaleIntegrationTests {
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) {
@@ -493,6 +563,7 @@ struct TailscaleIntegrationTests {
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")
@@ -527,6 +598,7 @@ struct TailscaleIntegrationTests {
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) {
@@ -598,17 +670,34 @@ private actor TestTailscaleNode: TailscaleNodeSession {
var closed = false
var hostname = ""
var selectedExitNode: String?
func setExitNode(_ stableID: String?) async throws { selectedExitNode = stableID }
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] = []) {
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 {}
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> { [] }
@@ -0,0 +1,149 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct TailscaleMultiConnectionTests {
private static let legacyGroup = Data(
#"{"formatVersion":2,"key":{"kind":"hostGroup","id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},"version":{"modifiedAt":0,"counter":1,"writerID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},"isDeleted":false,"payload":{"hostGroup":{"_0":{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Existing random-ID group","sortIndex":0,"isExpanded":true,"tailscale":{"network":{"controlURL":"https://controlplane.tailscale.com","dnsSuffix":"example.ts.net"},"username":"operator","hostPreferences":{"01bf448f-7943-55d0-96aa-b52f10c25f07":{"username":"deploy","charset":"utf8","sftpInitialPath":"/srv","shellIntegrationMode":"followGlobalSettings","environmentVariables":[]}}}}}}}"#
.utf8)
@Test func historicalRandomGroupKeepsItsHostIdentityAndPreferencesAcrossV3Edits() throws {
let envelope = try VaultSyncCodec.decode(Self.legacyGroup)
guard case .hostGroup(let group) = envelope.payload else { throw CocoaError(.coderReadCorrupt) }
let hostID = try #require(UUID(uuidString: "01bf448f-7943-55d0-96aa-b52f10c25f07"))
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = HostPersistence(context: container.mainContext)
_ = try persistence.applyRemote([envelope])
let journal = VaultSyncJournal(context: container.mainContext)
#expect(try journal.pendingEnvelopes().isEmpty)
#expect(try journal.envelope(for: envelope.key)?.formatVersion == 2)
let library = HostLibraryModel(persistence: persistence)
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.host(hostID))
#expect(library.savedTailscaleUsername(for: host) == "deploy")
#expect(host.sftpChannelInitialPath == "/srv")
var renamed = group
renamed.name = "Renamed"
library.upsert(renamed)
#expect(library.host(hostID)?.id == host.id)
let edited = try #require(try journal.pendingEnvelopes().first)
#expect(edited.formatVersion == 3)
let remote = try VaultSyncCodec.decode(VaultSyncCodec.encode(edited))
guard case .hostGroup(let restored) = remote.payload else { throw CocoaError(.coderReadCorrupt) }
#expect(restored.tailscale?.hostIdentityVersion == .legacy)
#expect(restored.tailscale?.hostID(groupID: restored.id, stableNodeID: "n123") == hostID)
}
@Test(arguments: [TailscaleGroupConfiguration.HostIdentityVersion.legacy, .groupScoped])
func sameNodeInTwoGroupsKeepsPreferencesAndRoutingIndependent(
firstIdentity: TailscaleGroupConfiguration.HostIdentityVersion
) throws {
let source = try TermindPersistence.makeContainer(inMemory: true)
let library = HostLibraryModel(persistence: HostPersistence(context: source.mainContext))
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var first = HostGroup(name: "First")
first.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: firstIdentity)
var second = HostGroup(name: "Second", parentID: first.id)
second.tailscale = TailscaleGroupConfiguration(network: network)
for group in [first, second] {
library.upsert(group)
library.replaceTailscalePeers([peer()], groupID: group.id)
}
let firstHost = try #require(library.hosts.first { $0.groupID == first.id })
let secondHost = try #require(library.hosts.first { $0.groupID == second.id })
#expect(firstHost.id != secondHost.id)
for (host, username) in [(firstHost, "alice"), (secondHost, "bob")] {
var edited = host
edited.tags = [username]
#expect(library.saveTailscaleHost(edited, username: username))
}
let routing = MultiConnectionRoutingProbe()
library.tailscaleConnections = routing
_ = try library.tailscaleNetwork(for: firstHost)
_ = try library.tailscaleNetwork(for: secondHost)
#expect(routing.groups == [first.id, second.id])
let firstStamp = library.networkConfigurationStamp(for: firstHost)
second.name = "Renamed"
second.parentID = nil
second.tailscale = library.group(second.id)?.tailscale
second.tailscale?.exitNodeID = "exit-two"
library.upsert(second)
#expect(library.networkConfigurationStamp(for: firstHost) == firstStamp)
#expect(library.host(secondHost.id)?.groupID == second.id)
let envelopes = try VaultSyncJournal(context: source.mainContext).pendingEnvelopes()
.map { try VaultSyncCodec.decode(VaultSyncCodec.encode($0)) }
let target = try TermindPersistence.makeContainer(inMemory: true)
let targetStore = HostPersistence(context: target.mainContext)
#expect(try targetStore.conflictingTailscaleGroups(in: envelopes, deletions: []).isEmpty)
_ = try targetStore.applyRemote(envelopes)
let otherDevice = HostLibraryModel(persistence: targetStore)
#expect(otherDevice.hosts.isEmpty)
for group in [first, second] { otherDevice.replaceTailscalePeers([peer()], groupID: group.id) }
#expect(otherDevice.host(firstHost.id)?.tags == ["alice"])
#expect(otherDevice.host(secondHost.id)?.tags == ["bob"])
#expect(otherDevice.savedTailscaleUsername(for: firstHost) == "alice")
#expect(otherDevice.savedTailscaleUsername(for: secondHost) == "bob")
otherDevice.deleteGroupProjection(first.id)
#expect(otherDevice.host(firstHost.id) == nil)
#expect(otherDevice.host(secondHost.id)?.tags == ["bob"])
}
@Test func groupScopedIdentityCannotBeWrittenAsV2AndOldReadersRejectV3() throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "New connection")
group.tailscale = TailscaleGroupConfiguration(network: network)
let payload = VaultRecordPayload.hostGroup(group)
var envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let bytes = try VaultSyncCodec.encode(envelope)
#expect(try VaultSyncCodec.decode(bytes) == envelope)
let oldReader = VersionedRecordDecoder<VaultEnvelope>([
"hostGroup": [1: VaultSyncCodec.decode, 2: VaultSyncCodec.decode]
])
#expect(throws: CloudSyncCompatibilityError.self) { try oldReader.decode(bytes) }
envelope.formatVersion = 2
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
#expect(
try VaultSyncCodec.decode(VaultSyncCodec.encode(VaultSyncCodec.decode(Self.legacyGroup))).formatVersion == 2
)
}
@Test(arguments: ["missing", "null", "unknown"])
func v3RejectsInvalidIdentityMarkers(marker: String) throws {
var value = try #require(JSONSerialization.jsonObject(with: Self.legacyGroup) as? [String: Any])
value["formatVersion"] = 3
var payload = try #require(value["payload"] as? [String: Any])
var groupCase = try #require(payload["hostGroup"] as? [String: Any])
var group = try #require(groupCase["_0"] as? [String: Any])
var configuration = try #require(group["tailscale"] as? [String: Any])
if marker == "null" { configuration["hostIdentityVersion"] = NSNull() }
if marker == "unknown" { configuration["hostIdentityVersion"] = "future" }
group["tailscale"] = configuration
groupCase["_0"] = group
payload["hostGroup"] = groupCase
value["payload"] = payload
#expect(throws: (any Error).self) { try VaultSyncCodec.decode(JSONSerialization.data(withJSONObject: value)) }
}
private func peer() -> TailscalePeer {
TailscalePeer(
stableID: "n123", name: "Server", dnsName: "server.example.ts.net", online: true, sshHostKeys: ["fixture"])
}
}
@MainActor private final class MultiConnectionRoutingProbe: TailscaleConnectionProviding {
var groups: [UUID] = []
func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
groups.append(groupID)
return MultiConnectionDialer()
}
}
private nonisolated struct MultiConnectionDialer: SSHNetworkDialing {
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}
@@ -0,0 +1,514 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindRemoteInfrastructure
@Suite @MainActor
struct TailscaleProfileCleanupTests {
@Test func deletingAnInactiveProfileWithoutProErasesOnlyItsOwnState() async throws {
let first = try group("first.ts.net")
let second = try group("second.ts.net")
let library = HostLibraryModel(groups: [first, second])
let store = InMemoryTailscaleLocalStore()
let ids = [UUID(), UUID()]
try store.save(.init(groupID: first.id, profileID: ids[0]))
try store.save(.init(groupID: second.id, profileID: ids[1]))
let probe = CleanupProbe(network: try #require(first.tailscale?.network))
let model = probe.model(library, store: store, access: false)
model.start()
library.replacePersistedSnapshot(VaultSnapshot(groups: [second]))
#expect(await settles { probe.erased == [ids[0]] })
#expect(probe.created.isEmpty)
#expect(try store.load().map(\.profileID) == [ids[1]])
#expect(try store.pendingCleanupProfiles().isEmpty)
await model.shutdown()
}
@Test func failedErasureSurvivesDatabaseReopeningAndRetriesWithoutStartingANode() async throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("Cleanup.store")
let group = try group("example.ts.net")
let profileID = UUID()
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
probe.failErase = true
try await seedAndFailCleanup(url, group: group, profileID: profileID, probe: probe)
probe.failErase = false
let container = try TermindPersistence.makeContainer(url: url)
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
#expect(try store.load().isEmpty)
#expect(try store.pendingCleanupProfiles() == [profileID])
let model = probe.model(HostLibraryModel(), store: store, access: false)
model.start()
#expect(await settles { probe.erased == [profileID] })
#expect(try store.pendingCleanupProfiles().isEmpty)
#expect(probe.created.isEmpty)
await model.shutdown()
}
private func seedAndFailCleanup(
_ url: URL, group: HostGroup, profileID: UUID, probe: CleanupProbe
) async throws {
let container = try TermindPersistence.makeContainer(url: url)
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
try store.save(.init(groupID: group.id, profileID: profileID))
let model = probe.model(HostLibraryModel(), store: store, access: false)
model.start()
#expect(await settles { model.error != nil })
#expect(try store.pendingCleanupProfiles() == [profileID])
await model.shutdown()
}
@Test(arguments: [false, true])
func deletingWhileFactoryIsSuspendedWaitsForTheLateNodeToClose(shutdown: Bool) async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
let profileID = UUID()
try store.save(.init(groupID: group.id, profileID: profileID))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
probe.holdFactory = true
let model = probe.model(library, store: store)
model.start()
#expect(await settles { probe.factoryContinuation != nil })
library.replacePersistedSnapshot(VaultSnapshot())
for _ in 0..<20 { await Task.yield() }
#expect(probe.erased.isEmpty)
#expect(try store.pendingCleanupProfiles() == [profileID])
if shutdown { await model.shutdown() }
probe.releaseFactory()
#expect(await settles { probe.erased == [profileID] })
#expect(probe.events == ["close:\(profileID)", "erase:\(profileID)"])
await model.shutdown()
}
@Test(arguments: [false, true])
func cancelledOrFailedLoginCleansItsCandidateWithoutTouchingAnExistingProfile(factoryFails: Bool) async throws {
let group = try group("example.ts.net")
let store = InMemoryTailscaleLocalStore()
let retained = UUID()
try store.save(.init(groupID: group.id, profileID: retained))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
probe.holdFactory = true
probe.failFactory = factoryFails
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
// Avoid starting the retained profile; beginLogin is also valid before start.
model.beginLogin(name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
#expect(await settles { probe.factoryContinuation != nil })
let candidate = try #require(probe.created.first)
model.cancelLogin()
probe.releaseFactory()
#expect(await settles { probe.erased == [candidate] })
#expect(try store.load().map(\.profileID) == [retained])
#expect(!probe.erased.contains(retained))
await model.shutdown()
}
@Test func reloginRetiresTheOldProfileButKeepsTheNewEnrollment() async throws {
let group = try group("example.ts.net")
let store = InMemoryTailscaleLocalStore()
let old = UUID()
try store.save(.init(groupID: group.id, profileID: old))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
model.start()
#expect(await settles { model.runningGroups.contains(group.id) })
model.beginLogin(
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
groupID: group.id)
#expect(await settles { model.loginCompletedGroupID == group.id && probe.erased.contains(old) })
let current = try #require(try store.load().first?.profileID)
#expect(current != old)
#expect(probe.erased == [old])
#expect(probe.nodes[old]?.closed == true)
#expect(probe.nodes[current]?.closed == false)
#expect(try store.pendingCleanupProfiles().isEmpty)
await model.shutdown()
}
@Test func multipleLoginsToTheSameTailnetKeepTheirProfilesAndDeletionIndependent() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel()
let store = InMemoryTailscaleLocalStore()
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(library, store: store)
model.start()
for name in ["First", "Second"] {
model.beginLogin(name: name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
#expect(await settles { model.loginCompletedGroupID != nil })
}
let states = try store.load()
#expect(states.count == 2)
#expect(Set(states.map(\.profileID)).count == 2)
#expect(Set(states.map(\.groupID)).count == 2)
let first = try #require(states.first { library.group($0.groupID)?.name == "First" })
let second = try #require(states.first { library.group($0.groupID)?.name == "Second" })
#expect(model.runningGroups == [first.groupID, second.groupID])
let retained = try model.connection(for: second.groupID)
library.replacePersistedSnapshot(.init(groups: library.groups.filter { $0.id != first.groupID }))
#expect(await settles { probe.erased == [first.profileID] })
#expect(try store.load().map(\.profileID) == [second.profileID])
#expect(probe.nodes[second.profileID]?.closed == false)
#expect(try await retained.hostKeys(stableNodeID: "node").isEmpty)
#expect(model.runningGroups == [second.groupID])
await model.shutdown()
}
@Test func reloginRejectsRetainedDialersBeforeTheOldNodeHasFinishedClosing() async throws {
let first = try group("example.ts.net")
var second = first
second.id = UUID()
let library = HostLibraryModel(groups: [first, second])
let store = InMemoryTailscaleLocalStore()
let firstProfile = UUID()
let secondProfile = UUID()
try store.save(.init(groupID: first.id, profileID: firstProfile))
try store.save(.init(groupID: second.id, profileID: secondProfile))
let probe = CleanupProbe(network: try #require(first.tailscale?.network))
let model = probe.model(library, store: store)
model.start()
#expect(await settles { model.runningGroups.count == 2 })
let retained = try model.connection(for: first.id)
let independent = try model.connection(for: second.id)
let old = try #require(probe.nodes[firstProfile])
old.holdLogout = true
model.beginLogin(
name: first.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
groupID: first.id)
#expect(await settles { model.loginCompletedGroupID == first.id && old.logoutContinuation != nil })
#expect(!old.closed)
await #expect(throws: TailscaleConfigurationError.self) {
try await retained.open(host: "server", port: 22, timeout: 1)
}
await #expect(throws: TailscaleConfigurationError.self) { try await retained.hostKeys(stableNodeID: "node") }
let replacement = try model.connection(for: first.id)
#expect(try await replacement.open(host: "server", port: 22, timeout: 1) == 42)
#expect(try await independent.open(host: "server", port: 22, timeout: 1) == 42)
old.logoutContinuation?.resume()
old.logoutContinuation = nil
#expect(await settles { probe.erased == [firstProfile] })
#expect(probe.nodes[secondProfile]?.closed == false)
var changed = try #require(library.group(second.id))
changed.tailscale = try group("changed.ts.net").tailscale
library.replacePersistedSnapshot(.init(groups: [try #require(library.group(first.id)), changed]))
#expect(throws: TailscaleConfigurationError.self) { try model.connection(for: second.id) }
await #expect(throws: TailscaleConfigurationError.self) { try await independent.hostKeys(stableNodeID: "node") }
await model.shutdown()
}
@Test func rejectedActivationKeepsThePreviousEnrollmentAndErasesTheCandidate() async throws {
let group = try group("example.ts.net")
let store = CleanupFailingStore()
let old = UUID()
try store.save(.init(groupID: group.id, profileID: old, hostname: "saved"))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
model.start()
#expect(await settles { model.runningGroups.contains(group.id) })
store.failSave = true
model.beginLogin(
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
groupID: group.id)
#expect(await settles { probe.created.count == 2 && !probe.erased.isEmpty })
#expect(try store.load().map(\.profileID) == [old])
#expect(probe.erased == [probe.created[1]])
#expect(probe.nodes[old]?.closed == false)
await model.shutdown()
}
@Test func vaultFailureNeverAttemptsToReplaceThePreviousEnrollment() async throws {
let group = try group("example.ts.net")
let persistence = CleanupVaultStore(.init(groups: [group]))
let library = HostLibraryModel(persistence: persistence)
let store = CleanupFailingStore()
let old = UUID()
try store.save(.init(groupID: group.id, profileID: old, hostname: "saved"))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(library, store: store)
model.start()
#expect(await settles { model.runningGroups.contains(group.id) })
let writes = store.saveAttempts
persistence.failSave = true
store.failSave = true
model.beginLogin(
name: "renamed", parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
groupID: group.id)
#expect(await settles { probe.created.count == 2 && !probe.erased.isEmpty })
#expect(store.saveAttempts == writes)
#expect(library.group(group.id)?.name == group.name)
#expect(try store.load().map(\.profileID) == [old])
#expect(probe.erased == [probe.created[1]])
#expect(probe.nodes[old]?.closed == false)
await model.shutdown()
}
@Test func newGroupRemainsLoginRequiredWhenLocalActivationFails() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel()
let store = CleanupFailingStore()
store.failSave = true
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(library, store: store)
model.start()
model.beginLogin(name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
#expect(await settles { !probe.erased.isEmpty })
#expect(library.groups.first?.tailscale?.network == group.tailscale?.network)
#expect(model.runningGroups.isEmpty)
#expect(model.error != nil)
#expect(try store.load().isEmpty)
#expect(try store.pendingCleanupProfiles().isEmpty)
await model.shutdown()
}
@Test func failedRetirementPreservesTheEnrollmentUntilItCanBeQueued() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let store = CleanupFailingStore()
let profileID = UUID()
try store.save(.init(groupID: group.id, profileID: profileID))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
let model = probe.model(library, store: store, access: false)
model.start()
store.failRemove = true
library.replacePersistedSnapshot(.init())
#expect(model.error != nil)
#expect(try store.load().map(\.profileID) == [profileID])
#expect(probe.erased.isEmpty)
store.failRemove = false
model.reconcile()
#expect(await settles { probe.erased == [profileID] })
#expect(try store.load().isEmpty)
await model.shutdown()
}
@Test func missingCleanupAcknowledgementRetriesAndFailedStateReadsNeverEraseProfiles() async throws {
let group = try group("example.ts.net")
let store = CleanupFailingStore()
let orphan = UUID()
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
probe.discovered = [orphan]
store.failLoad = true
let unreadable = probe.model(HostLibraryModel(), store: store, access: false)
unreadable.start()
#expect(unreadable.error != nil)
#expect(probe.erased.isEmpty)
#expect(probe.inventoryReads == 0)
await unreadable.shutdown()
store.failLoad = false
store.failComplete = true
let model = probe.model(HostLibraryModel(), store: store, access: false)
model.start()
#expect(await settles { model.error != nil })
#expect(probe.erased == [orphan])
#expect(try store.pendingCleanupProfiles() == [orphan])
store.failComplete = false
model.reconcile()
#expect(await settles { probe.erased.count == 2 })
#expect(try store.pendingCleanupProfiles().isEmpty)
await model.shutdown()
}
@Test func oldStoreMigratesAndOrphanInventoryNeverErasesAnActiveProfile() async throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("Upgrade.store")
var group = try group("example.ts.net")
group.tailscale = TailscaleGroupConfiguration(
network: try #require(group.tailscale?.network), hostIdentityVersion: .legacy)
let active = UUID()
let orphan = UUID()
try writeVersion49(url, group: group, profileID: active)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
probe.discovered = [active, orphan]
let model = probe.model(library, store: store, access: false)
model.start()
#expect(await settles { probe.erased == [orphan] })
#expect(try store.load().map(\.profileID) == [active])
#expect(library.group(group.id)?.tailscale == group.tailscale)
#expect(try store.pendingCleanupProfiles().isEmpty)
await model.shutdown()
}
}
@Test func profileInventoryIncludesOnlyCanonicalUUIDDirectories() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let profile = UUID()
let directory = root.appendingPathComponent(profile.uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: root.appendingPathComponent("unrelated"), withIntermediateDirectories: true)
try Data().write(to: root.appendingPathComponent(UUID().uuidString))
try FileManager.default.createSymbolicLink(
at: root.appendingPathComponent(UUID().uuidString), withDestinationURL: directory)
#expect(try LibTailscaleNodeSession.storedProfiles(in: root) == [profile])
#expect(try LibTailscaleNodeSession.storedProfiles(in: root.appendingPathComponent("missing")).isEmpty)
}
private func writeVersion49(_ url: URL, group: HostGroup, profileID: UUID) throws {
let schema = Schema(versionedSchema: TermindSchemaV49.self)
let container = try ModelContainer(
for: schema,
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
container.mainContext.insert(PersistentHostGroup(group))
let configuration = try PersistentTailscaleGroup(id: group.id, configuration: #require(group.tailscale))
var historical = try #require(
JSONSerialization.jsonObject(with: configuration.configurationData) as? [String: Any])
historical.removeValue(forKey: "hostIdentityVersion")
configuration.configurationData = try JSONSerialization.data(withJSONObject: historical)
container.mainContext.insert(configuration)
container.mainContext.insert(
PersistentTailscaleLocalState(groupID: group.id, profileID: profileID, hostname: "saved"))
try container.mainContext.save()
}
private func group(_ suffix: String) throws -> HostGroup {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix)
var group = HostGroup(id: network.groupID, name: suffix)
group.tailscale = TailscaleGroupConfiguration(network: network)
return group
}
private func temporaryDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func settles(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 {
if predicate() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return predicate()
}
}
@MainActor private final class CleanupProbe {
let network: TailscaleNetwork
var created: [UUID] = []
var erased: [UUID] = []
var events: [String] = []
var nodes: [UUID: CleanupNode] = [:]
var discovered: Set<UUID> = []
var inventoryReads = 0
var failErase = false
var holdFactory = false
var failFactory = false
var factoryContinuation: CheckedContinuation<Void, Never>?
init(network: TailscaleNetwork) { self.network = network }
func model(_ library: HostLibraryModel, store: any TailscaleLocalStoring, access: Bool = true) -> TailscaleModel {
TailscaleModel(
library: library, store: store, factory: { id, _, _, _ in try await self.make(id) },
profileStorage: .init(profiles: { await self.inventory() }, erase: { try await self.erase($0) }),
hostname: { "test" }, hasAccess: { access })
}
func make(_ id: UUID) async throws -> any TailscaleNodeSession {
created.append(id)
if holdFactory { await withCheckedContinuation { factoryContinuation = $0 } }
if failFactory { throw CocoaError(.fileReadUnknown) }
let node = CleanupNode(network: network) { self.events.append("close:\(id)") }
nodes[id] = node
return node
}
func releaseFactory() {
factoryContinuation?.resume()
factoryContinuation = nil
holdFactory = false
}
func inventory() -> Set<UUID> {
inventoryReads += 1
return discovered
}
func erase(_ id: UUID) throws {
#expect(nodes[id]?.closed != false)
if failErase { throw CocoaError(.fileWriteNoPermission) }
events.append("erase:\(id)")
erased.append(id)
}
}
@MainActor private final class CleanupNode: TailscaleNodeSession {
let network: TailscaleNetwork
let onClose: () -> Void
var closed = false
var holdLogout = false
var logoutContinuation: CheckedContinuation<Void, Never>?
init(network: TailscaleNetwork, onClose: @escaping () -> Void) {
self.network = network
self.onClose = onClose
}
func status() async throws -> TailscaleNodeStatus {
.init(running: true, network: network, authURL: nil, peers: [])
}
func login() async throws {}
@MainActor func logout() async throws {
if holdLogout { await withCheckedContinuation { logoutContinuation = $0 } }
}
func close() async {
await MainActor.run {
if !closed {
closed = true
onClose()
}
}
}
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { 42 }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}
@MainActor private final class CleanupFailingStore: TailscaleLocalStoring {
let base = InMemoryTailscaleLocalStore()
var failSave = false
var failLoad = false
var failComplete = false
var failRemove = false
var saveAttempts = 0
func load() throws -> [TailscaleLocalState] {
if failLoad { throw CocoaError(.fileReadUnknown) }
return try base.load()
}
func save(_ state: TailscaleLocalState) throws {
saveAttempts += 1
if failSave { throw CocoaError(.fileWriteUnknown) }
try base.save(state)
}
func remove(groupID: UUID) throws {
if failRemove { throw CocoaError(.fileWriteUnknown) }
try base.remove(groupID: groupID)
}
func scheduleCleanup(profileID: UUID) throws { try base.scheduleCleanup(profileID: profileID) }
func pendingCleanupProfiles() throws -> Set<UUID> { try base.pendingCleanupProfiles() }
func completeCleanup(profileID: UUID) throws {
if failComplete { throw CocoaError(.fileWriteUnknown) }
try base.completeCleanup(profileID: profileID)
}
}
@MainActor private final class CleanupVaultStore: VaultPersisting {
var snapshot: VaultSnapshot
var failSave = false
init(_ snapshot: VaultSnapshot) { self.snapshot = snapshot }
func load() throws -> VaultSnapshot { snapshot }
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
if failSave { throw CocoaError(.fileWriteUnknown) }
self.snapshot = snapshot
return .empty
}
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
.init(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
}
}
@@ -0,0 +1,223 @@
import CloudKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct TailscaleSyncSafetyTests {
@Test(arguments: [TailscaleGroupConfiguration.HostIdentityVersion.legacy, .groupScoped])
func v3UpgradeRefetchesAndAcceptsIndependentConnectionsToTheSameTailnet(
firstIdentity: TailscaleGroupConfiguration.HostIdentityVersion
) async throws {
let fixture = try CloudSyncSafetyFixture(category: .vault, seed: false)
defer { fixture.stop() }
try await fixture.start()
let persistence = HostPersistence(context: fixture.container.mainContext)
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var first = HostGroup(name: "First")
first.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: firstIdentity)
_ = try persistence.save(.init(groups: [first]))
var second = HostGroup(name: "Second")
second.tailscale = TailscaleGroupConfiguration(network: network)
second.tailscale?.username = "other-account"
let record = try groupRecord(second, zone: fixture.lifecycle.zoneID)
let safety = try fixture.safety()
let scope = try #require(fixture.lifecycle.scope)
#expect(try !fixture.lifecycle.store.safety.prepare(scope))
try safety.quarantine(
recordName: record.recordID.recordName,
payloadData: CloudSyncRecordCoding.payloadData(
from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)),
reason: "Old client", reasonCode: .unsupportedFormat)
#expect(fixture.batch([.saveRecord(record.recordID), .deleteRecord(record.recordID)]) == nil)
var oldFormats = SyncRecordFormats.vault.formats
oldFormats["hostGroup"] = .init(readingVersions: [1, 2], writingVersion: 2)
try safety.capability().capabilityID = SyncRecordFormats(oldFormats).capabilityID
try safety.recovery().requiresRefetch = false
try fixture.container.mainContext.save()
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
fixture.network.nextEngine.fetchBody = { try fixture.receive([record], []) }
try await fixture.start()
#expect(fixture.network.serializations.last! == nil)
let groups = try persistence.load().groups
#expect(Set(groups.map(\.id)) == [first.id, second.id])
#expect(groups.first { $0.id == second.id }?.tailscale?.username == "other-account")
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
#expect(try safety.capability().capabilityID == SyncRecordFormats.vault.capabilityID)
}
@Test func conflictingTailnetDoesNotBlockOtherRecordsAndRefetchesAfterUnbinding() async throws {
let fixture = try CloudSyncSafetyFixture(category: .vault, seed: false)
defer { fixture.stop() }
try await fixture.start()
let persistence = HostPersistence(context: fixture.container.mainContext)
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var local = HostGroup(name: "Local")
local.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
_ = try persistence.save(VaultSnapshot(groups: [local]))
var remote = HostGroup(name: "Remote")
remote.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
remote.tailscale?.username = "preserved-user"
remote.isExpanded = false
let unrelated = HostGroup(name: "Unrelated")
let record = try groupRecord(remote, zone: fixture.lifecycle.zoneID)
let ordinary = try groupRecord(unrelated, zone: fixture.lifecycle.zoneID)
for _ in 0..<2 { try fixture.receive([record, ordinary], []) }
let snapshot = try persistence.load()
#expect(Set(snapshot.groups.map(\.id)) == [local.id, unrelated.id])
let safety = try fixture.safety()
let isolated = try #require(try safety.entry(recordName: record.recordID.recordName))
#expect(isolated.reasonCode == CloudSyncQuarantineReason.duplicateTailscaleNetwork.rawValue)
#expect(try VaultSyncCodec.decode(isolated.payloadData).payload == .hostGroup(remote))
#expect(fixture.record(record.recordID) == nil)
#expect(fixture.batch([.saveRecord(record.recordID), .deleteRecord(record.recordID)]) == nil)
local.tailscale = nil
_ = try persistence.save(VaultSnapshot(groups: [local, unrelated]))
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
fixture.network.nextEngine.fetchBody = { try fixture.receive([record, ordinary], []) }
try await fixture.start()
#expect(fixture.network.serializations.last! == nil)
#expect(try persistence.load().groups.first { $0.id == remote.id }?.tailscale?.username == "preserved-user")
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
}
@Test func sameBatchDeletionReleasesTailnetOwnership() async throws {
let fixture = try CloudSyncSafetyFixture(category: .vault, seed: false)
defer { fixture.stop() }
try await fixture.start()
let persistence = HostPersistence(context: fixture.container.mainContext)
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var local = HostGroup(name: "Local")
local.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
_ = try persistence.save(VaultSnapshot(groups: [local]))
var remote = HostGroup(name: "Remote")
remote.tailscale = local.tailscale
let record = try groupRecord(remote, zone: fixture.lifecycle.zoneID)
let deletedID = CKRecord.ID(
recordName: VaultRecordKey(kind: .hostGroup, id: local.id).recordName, zoneID: fixture.lifecycle.zoneID)
try fixture.receive([record], [deletedID])
#expect(try persistence.load().groups == [remote])
#expect(try fixture.safety().blockedRecordNames().isEmpty)
}
@Test func metricsUpgradeRefetchesAndUnblocksPreviouslyUnsupportedSelections() async throws {
let fixture = try CloudSyncSafetyFixture(category: .metrics)
defer { fixture.stop() }
try await fixture.start()
let safety = try fixture.safety()
let record = try fixture.newerRecord(fixture.recordIDs[0])
let scope = try #require(fixture.lifecycle.scope)
#expect(try !fixture.lifecycle.store.safety.prepare(scope))
try safety.quarantine(
recordName: record.recordID.recordName,
payloadData: CloudSyncRecordCoding.payloadData(
from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)),
reason: "Old client", reasonCode: .unsupportedFormat)
try safety.capability().capabilityID = "selection:1"
try safety.recovery().requiresRefetch = false
#expect(try fixture.lifecycle.store.safety.prepare(scope))
try fixture.container.mainContext.save()
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
fixture.network.nextEngine.fetchBody = { try fixture.receive([record], []) }
try await fixture.start()
#expect(fixture.network.serializations.last! == nil)
#expect(try safety.capability().capabilityID == SyncRecordFormats.metrics.capabilityID)
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
let journal = MetricsSyncJournal(context: fixture.container.mainContext)
#expect(
try journal.envelope(for: MetricsRecordKey(recordName: record.recordID.recordName)!)
== MetricsSyncCoordinator.envelope(from: record))
}
@Test(arguments: [false, true])
func conflictRecoveryUsesCoordinatorRetryOrLocalGroupMutation(retry: Bool) async throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let name = "TailscaleSyncSafetyTests.\(UUID())"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
let network = LifecycleNetwork()
let coordinator = VaultSyncCoordinator(
context: container.mainContext, defaults: defaults, environmentIdentifier: "Test",
makeTransport: { _, _ in network.transport })
defer { coordinator.pause() }
coordinator.start()
await coordinator.lifecycle.operation?.value
let scope = try #require(coordinator.lifecycle.scope)
#expect(try !coordinator.lifecycle.store.safety.prepare(scope))
let persistence = HostPersistence(context: container.mainContext)
var local = HostGroup(name: "Local")
local.tailscale = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net"),
hostIdentityVersion: .legacy)
_ = try persistence.save(VaultSnapshot(groups: [local]))
var remote = HostGroup(name: "Remote")
remote.tailscale = local.tailscale
let record = try groupRecord(remote, zone: coordinator.lifecycle.zoneID)
try coordinator.receiveRemoteRecords([record])
let safety = CloudSyncSafetyStore(context: container.mainContext, scope: scope)
#expect(try coordinator.lifecycle.store.safety.prepare(scope))
var otherScope = scope
otherScope.account = "other-account"
#expect(
try !CloudSyncSafetyStore(context: container.mainContext, scope: otherScope)
.hasQuarantine(reason: .duplicateTailscaleNetwork))
local.tailscale = nil
let mutations = try persistence.save(VaultSnapshot(groups: [local]))
let previousEngine = network.nextEngine
network.nextEngine = LifecycleEngine()
network.nextEngine.fetchBody = {
#expect(!coordinator.lifecycle.isReadyToSend)
try coordinator.receiveRemoteRecords([record])
}
if retry { coordinator.retry() } else { coordinator.noteLocalChanges(mutations) }
await coordinator.lifecycle.operation?.value
for _ in 0..<100 where previousEngine.cancelCount == 0 { await Task.yield() }
#expect(previousEngine.cancelCount == 1)
#expect(network.engineCount == 2)
#expect(network.nextEngine.fetchCount == 1)
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
#expect(try !coordinator.lifecycle.store.safety.prepare(scope))
}
@Test func fullRefetchRemovesAnIsolatedGroupDeletedFromTheCloud() async throws {
let fixture = try CloudSyncSafetyFixture(category: .vault, seed: false)
defer { fixture.stop() }
try await fixture.start()
let persistence = HostPersistence(context: fixture.container.mainContext)
var local = HostGroup(name: "Local")
local.tailscale = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net"),
hostIdentityVersion: .legacy)
_ = try persistence.save(VaultSnapshot(groups: [local]))
var remote = HostGroup(name: "Remote")
remote.tailscale = local.tailscale
let record = try groupRecord(remote, zone: fixture.lifecycle.zoneID)
try fixture.receive([record], [])
#expect(try fixture.safety().isUploadBlocked(recordName: record.recordID.recordName))
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
try await fixture.start()
#expect(try !fixture.safety().isUploadBlocked(recordName: record.recordID.recordName))
#expect(try persistence.load().groups.map(\.id) == [local.id])
#expect(
try VaultSyncJournal(context: fixture.container.mainContext)
.envelope(for: VaultRecordKey(kind: .hostGroup, id: remote.id))?.isDeleted == true)
}
private func groupRecord(_ group: HostGroup, zone: CKRecordZone.ID) throws -> CKRecord {
let payload = VaultRecordPayload.hostGroup(group)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
return try VaultSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: payload.key.recordName, zoneID: zone))
}
}
@@ -459,7 +459,8 @@ extension RemoteSessionHostStore {
configuration.tailscaleNodeID = snapshot.tailscaleNodeID
if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) {
configuration.network = try tailscaleNetwork(for: first)
} else if snapshot.requiresTailscale {
}
if snapshot.requiresTailscale && configuration.network == nil {
throw TailscaleConfigurationError.unavailable
}
completed = true
@@ -30,13 +30,43 @@ public protocol TailscaleLocalStoring: AnyObject {
func load() throws -> [TailscaleLocalState]
func save(_ state: TailscaleLocalState) throws
func remove(groupID: UUID) throws
func scheduleCleanup(profileID: UUID) throws
func pendingCleanupProfiles() throws -> Set<UUID>
func completeCleanup(profileID: UUID) throws
}
public nonisolated struct TailscaleProfileStorage: Sendable {
/// Memory-only app compositions must never inspect or erase live device state.
public static let disabled = Self(profiles: { [] }, erase: { _ in throw TailscaleConfigurationError.unavailable })
public var profiles: @Sendable () async throws -> Set<UUID>
public var erase: @Sendable (UUID) async throws -> Void
public init(
profiles: @escaping @Sendable () async throws -> Set<UUID>,
erase: @escaping @Sendable (UUID) async throws -> Void
) {
self.profiles = profiles
self.erase = erase
}
}
@MainActor
public final class InMemoryTailscaleLocalStore: TailscaleLocalStoring {
private var states: [UUID: TailscaleLocalState] = [:]
private var cleanupProfiles: Set<UUID> = []
public init() {}
public func load() throws -> [TailscaleLocalState] { Array(states.values) }
public func save(_ state: TailscaleLocalState) throws { states[state.groupID] = state }
public func remove(groupID: UUID) throws { states[groupID] = nil }
public func save(_ state: TailscaleLocalState) throws {
if let old = states[state.groupID], old.profileID != state.profileID { cleanupProfiles.insert(old.profileID) }
states[state.groupID] = state
cleanupProfiles.remove(state.profileID)
}
public func remove(groupID: UUID) throws {
if let old = states.removeValue(forKey: groupID) { cleanupProfiles.insert(old.profileID) }
}
public func scheduleCleanup(profileID: UUID) throws { cleanupProfiles.insert(profileID) }
public func pendingCleanupProfiles() throws -> Set<UUID> {
cleanupProfiles.subtracting(states.values.map(\.profileID))
}
public func completeCleanup(profileID: UUID) throws { cleanupProfiles.remove(profileID) }
}
@@ -1709,7 +1709,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// a child of Production has to be numbered against Production's children.
public func upsert(_ group: HostGroup) {
if let configuration = group.tailscale,
groups.contains(where: { $0.id != group.id && $0.tailscale?.network == configuration.network })
groups.contains(where: { $0.id != group.id && $0.tailscale.map(configuration.conflicts) == true })
{
storageError = TailscaleConfigurationError.duplicateNetwork.localizedDescription
return
@@ -2415,8 +2415,19 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
public func networkConfigurationStamp(for host: Host) -> Data {
guard let group = tailscaleGroup(for: host) else { return Data() }
return (try? JSONEncoder().encode(group.tailscale)) ?? Data()
guard let group = tailscaleGroup(for: host), let configuration = group.tailscale else { return Data() }
// Only connection inputs belong here; discovery and other peers' preferences
// must not restart this host's Metrics worker.
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
return
(try? encoder.encode([
"groupID": group.id.uuidString,
"controlURL": configuration.network.controlURL,
"dnsSuffix": configuration.network.dnsSuffix,
"exitNodeID": configuration.exitNodeID ?? "",
"username": host.isTailscaleManaged ? tailscaleUsername(for: host) : "",
])) ?? Data()
}
public func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
@@ -2451,7 +2462,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
for group in groups {
guard let configuration = group.tailscale else { continue }
for peer in tailscalePeers[group.id] ?? [] where !peer.stableID.isEmpty && peer.supportsSSH {
let id = configuration.network.hostID(stableNodeID: peer.stableID)
let id = configuration.hostID(groupID: group.id, stableNodeID: peer.stableID)
guard ids.insert(id).inserted else { continue }
let preferences = configuration.hostPreferences[id] ?? TailscaleHostPreferences()
var host = Host(id: id, name: peer.name, host: peer.dnsName, groupID: group.id)
@@ -80,27 +80,38 @@ public final class TailscaleModel: TailscaleConnectionProviding {
@ObservationIgnored private let library: HostLibraryModel
@ObservationIgnored private let store: any TailscaleLocalStoring
@ObservationIgnored private let factory: TailscaleNodeFactory
@ObservationIgnored private let profileStorage: TailscaleProfileStorage
@ObservationIgnored private let hostname: () -> String
@ObservationIgnored private let hasAccess: () -> Bool
@ObservationIgnored private var appliedExitNodes: [UUID: String] = [:]
@ObservationIgnored private var states: [UUID: TailscaleLocalState] = [:]
@ObservationIgnored private var nodes: [UUID: any TailscaleNodeSession] = [:]
private nonisolated struct ActiveNode: Sendable {
let session: any TailscaleNodeSession
let profileID: UUID
let network: TailscaleNetwork
}
@ObservationIgnored private var nodes: [UUID: ActiveNode] = [:]
@ObservationIgnored private var monitors: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored private var starting: Set<UUID> = []
@ObservationIgnored private var profilesInUse: Set<UUID> = []
@ObservationIgnored private var retiringNodes: [UUID: any TailscaleNodeSession] = [:]
@ObservationIgnored private var cleanupTasks: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored private var loginTask: Task<Void, Never>?
@ObservationIgnored private var loginGeneration: UUID?
@ObservationIgnored private var pendingNode: (any TailscaleNodeSession)?
@ObservationIgnored private var loginTargetGroupID: UUID?
@ObservationIgnored private var started = false
@ObservationIgnored private var shuttingDown = false
public init(
library: HostLibraryModel, store: any TailscaleLocalStoring,
factory: @escaping TailscaleNodeFactory, hostname: @escaping () -> String,
factory: @escaping TailscaleNodeFactory, profileStorage: TailscaleProfileStorage,
hostname: @escaping () -> String,
hasAccess: @escaping () -> Bool
) {
self.library = library
self.store = store
self.factory = factory
self.profileStorage = profileStorage
self.hostname = hostname
self.hasAccess = hasAccess
library.tailscaleConnections = self
@@ -135,7 +146,7 @@ public final class TailscaleModel: TailscaleConnectionProviding {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let name = trimmed.isEmpty ? hostname() : trimmed
guard state.hostname != name else { return }
if let node = nodes[groupID] { try await node.setHostname(name) }
if let node = nodes[groupID] { try await node.session.setHostname(name) }
guard states[groupID]?.profileID == state.profileID else { throw CancellationError() }
state = states[groupID] ?? state
state.hostname = name
@@ -159,36 +170,32 @@ public final class TailscaleModel: TailscaleConnectionProviding {
library.replaceTailscalePeers(state.peers, groupID: state.groupID)
}
reconcile()
recoverOrphanedProfiles()
} catch { self.error = error.localizedDescription }
}
public func reconcile() {
guard !shuttingDown else { return }
if let id = loginTargetGroupID, library.group(id) == nil { cancelLogin() }
guard library.storageError == nil else { return }
let wanted = Set(library.groups.filter { $0.tailscale != nil }.map(\.id))
for id in Set(states.keys).union(nodes.keys).subtracting(wanted) {
monitors.removeValue(forKey: id)?.cancel()
runningGroups.remove(id)
devices[id] = nil
appliedExitNodes[id] = nil
peers[id] = nil
if let node = nodes.removeValue(forKey: id) {
Task {
try? await node.logout()
await node.close()
}
}
states[id] = nil
do { try store.remove(groupID: id) } catch { self.error = error.localizedDescription }
library.replaceTailscalePeers([], groupID: id)
retireProfile(groupID: id)
}
drainProfileCleanup()
guard hasAccess() else { return }
for id in wanted where nodes[id] == nil && !starting.contains(id) {
for id in wanted where nodes[id] == nil && !starting.contains(id) && loginTargetGroupID != id {
guard let state = states[id], let configuration = library.group(id)?.tailscale else { continue }
starting.insert(id)
profilesInUse.insert(state.profileID)
let name = state.hostname ?? hostname()
Task { [weak self] in
guard let self else { return }
defer { self.starting.remove(id) }
defer {
self.starting.remove(id)
self.profilesInUse.remove(state.profileID)
self.drainProfileCleanup()
}
do {
let node = try await self.factory(state.profileID, name, configuration.network.controlURL, nil)
guard !self.shuttingDown, self.states[id]?.profileID == state.profileID,
@@ -197,13 +204,75 @@ public final class TailscaleModel: TailscaleConnectionProviding {
await node.close()
return
}
self.nodes[id] = node
self.nodes[id] = ActiveNode(
session: node, profileID: state.profileID, network: configuration.network)
self.monitor(id, node: node)
} catch { self.error = error.localizedDescription }
}
}
}
private func recoverOrphanedProfiles() {
Task { [weak self] in
guard let self else { return }
do {
let discovered = try await profileStorage.profiles()
guard !shuttingDown, library.storageError == nil else { return }
// Re-read after enumeration: a login may have committed while suspended.
let active = Set(try store.load().map(\.profileID))
for id in discovered.subtracting(active) { try store.scheduleCleanup(profileID: id) }
drainProfileCleanup()
} catch { self.error = error.localizedDescription }
}
}
private func retireProfile(groupID: UUID) {
guard states[groupID] != nil else { return }
do {
// Remove the active reference and enqueue its profile atomically, before
// releasing our last runtime reference. A failed save remains retryable.
try store.remove(groupID: groupID)
states[groupID] = nil
if let node = nodes.removeValue(forKey: groupID) { retiringNodes[node.profileID] = node.session }
monitors.removeValue(forKey: groupID)?.cancel()
runningGroups.remove(groupID)
devices[groupID] = nil
appliedExitNodes[groupID] = nil
peers[groupID] = nil
library.replaceTailscalePeers([], groupID: groupID)
} catch { self.error = error.localizedDescription }
}
private func drainProfileCleanup() {
do {
for id in try store.pendingCleanupProfiles()
where !profilesInUse.contains(id) && !nodes.values.contains(where: { $0.profileID == id })
&& cleanupTasks[id] == nil
{
cleanupTasks[id] = Task { [weak self] in
guard let self else { return }
defer { self.cleanupTasks[id] = nil }
do {
guard !self.profilesInUse.contains(id),
!self.nodes.values.contains(where: { $0.profileID == id }),
try self.store.pendingCleanupProfiles().contains(id)
else { return }
if let node = self.retiringNodes.removeValue(forKey: id) {
try? await node.logout()
await node.close()
}
guard !self.profilesInUse.contains(id),
!self.nodes.values.contains(where: { $0.profileID == id }),
try self.store.pendingCleanupProfiles().contains(id)
else { return }
try await self.profileStorage.erase(id)
try self.store.completeCleanup(profileID: id)
} catch { self.error = error.localizedDescription }
}
}
} catch { self.error = error.localizedDescription }
}
public func beginLogin(
name: String, parentID: UUID?, controlURL: String, authKey: String?, groupID: UUID? = nil, hostname: String = ""
) {
@@ -213,7 +282,10 @@ public final class TailscaleModel: TailscaleConnectionProviding {
}
cancelLogin()
let generation = UUID()
let connectionID = groupID ?? UUID()
loginGeneration = generation
loginTargetGroupID = groupID
let originalGroup = groupID.flatMap(library.group)
isLoggingIn = true
loginCompletedGroupID = nil
error = nil
@@ -222,13 +294,18 @@ public final class TailscaleModel: TailscaleConnectionProviding {
loginTask = Task { [weak self] in
guard let self else { return }
var candidate: (any TailscaleNodeSession)?
let profileID = UUID()
self.profilesInUse.insert(profileID)
defer {
self.profilesInUse.remove(profileID)
self.drainProfileCleanup()
}
do {
let canonical = try TailscaleNetwork(controlURL: controlURL, dnsSuffix: "pending.invalid").controlURL
let profileID = UUID()
try self.store.scheduleCleanup(profileID: profileID)
let node = try await self.factory(profileID, deviceName, canonical, authKey)
candidate = node
try Task.checkCancellation()
self.pendingNode = node
if authKey == nil { try await node.login() }
let deadline = ContinuousClock.now.advanced(by: .seconds(600))
while ContinuousClock.now < deadline {
@@ -245,48 +322,54 @@ public final class TailscaleModel: TailscaleConnectionProviding {
{
throw TailscaleConfigurationError.missingNetworkIdentity
}
if self.library.groups.contains(where: { $0.tailscale?.network == network && $0.id != groupID })
{
throw TailscaleConfigurationError.duplicateNetwork
}
var group =
groupID.flatMap(self.library.group)
?? HostGroup(id: network.groupID, name: name, parentID: parentID)
?? HostGroup(id: connectionID, name: name, parentID: parentID)
if group.tailscale == nil { group.tailscale = TailscaleGroupConfiguration(network: network) }
try await node.setExitNode(group.tailscale?.exitNodeID)
try Task.checkCancellation()
guard self.loginGeneration == generation else { throw CancellationError() }
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
if let groupID {
guard let latest = self.library.group(groupID) else { throw CancellationError() }
guard latest.tailscale?.network == originalGroup?.tailscale?.network else {
throw CancellationError()
}
// Exit-node application suspends. Re-read cloud edits before committing
// enrollment, and apply a changed exit node on the next iteration.
if latest.tailscale?.exitNodeID != group.tailscale?.exitNodeID { continue }
group = latest
if group.tailscale == nil {
group.tailscale = TailscaleGroupConfiguration(network: network)
}
} else if self.library.group(group.id) != nil {
throw TailscaleConfigurationError.duplicateNetwork
}
let state = TailscaleLocalState(
groupID: group.id, profileID: profileID, peers: status.peers, hostname: deviceName)
group.name = name
group.parentID = parentID
let previousState = self.states[group.id]
try self.store.save(state)
if originalGroup == nil || group.name == originalGroup?.name { group.name = name }
if originalGroup == nil || group.parentID == originalGroup?.parentID {
group.parentID = parentID
}
// Public group configuration is valid without local enrollment.
// Persist it first so activation needs no cross-store rollback.
self.library.upsert(group)
guard self.library.storageError == nil else {
if let previousState {
try self.store.save(previousState)
} else {
try self.store.remove(groupID: group.id)
}
throw TailscaleConfigurationError.unavailable
}
try self.store.save(state)
self.monitors.removeValue(forKey: group.id)?.cancel()
if let old = self.nodes[group.id] {
Task {
try? await old.logout()
await old.close()
}
self.retiringNodes[old.profileID] = old.session
}
self.states[group.id] = state
self.nodes[group.id] = node
self.nodes[group.id] = ActiveNode(session: node, profileID: profileID, network: network)
self.appliedExitNodes[group.id] = group.tailscale?.exitNodeID ?? ""
self.library.replaceTailscalePeers(status.peers, groupID: group.id)
self.runningGroups.insert(group.id)
self.devices[group.id] = status.device
self.peers[group.id] = status.peers
self.monitor(group.id, node: node)
self.pendingNode = nil
candidate = nil
self.loginURL = nil
self.loginCompletedGroupID = group.id
@@ -307,55 +390,46 @@ public final class TailscaleModel: TailscaleConnectionProviding {
}
if self.loginGeneration == generation {
self.isLoggingIn = false
self.pendingNode = nil
self.loginTargetGroupID = nil
}
}
}
public func cancelLogin() {
loginGeneration = nil
loginTargetGroupID = nil
loginTask?.cancel()
loginTask = nil
loginURL = nil
isLoggingIn = false
if let node = pendingNode {
Task {
try? await node.logout()
await node.close()
}
}
pendingNode = nil
// The login task owns its candidate and performs logout/close once, including
// cancellation while factory, status, or preference application is suspended.
}
public func logout(groupID: UUID) async {
monitors.removeValue(forKey: groupID)?.cancel()
runningGroups.remove(groupID)
devices[groupID] = nil
peers[groupID] = nil
appliedExitNodes[groupID] = nil
let node = nodes.removeValue(forKey: groupID)
states[groupID] = nil
do { try store.remove(groupID: groupID) } catch { self.error = error.localizedDescription }
library.replaceTailscalePeers([], groupID: groupID)
if let node {
try? await node.logout()
await node.close()
}
let profileID = states[groupID]?.profileID
retireProfile(groupID: groupID)
drainProfileCleanup()
if let profileID { await cleanupTasks[profileID]?.value }
}
public func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard runningGroups.contains(groupID), let node = nodes[groupID] else {
guard runningGroups.contains(groupID), let node = nodes[groupID],
library.group(groupID)?.tailscale?.network == node.network
else {
throw TailscaleConfigurationError.loginRequired
}
guard appliedExitNodes[groupID] == exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
return AccessDialer(
node: node,
node: node.session,
check: { [weak self] in
guard let self, self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard self.runningGroups.contains(groupID) else { throw TailscaleConfigurationError.loginRequired }
guard self.runningGroups.contains(groupID), self.nodes[groupID]?.profileID == node.profileID,
self.library.group(groupID)?.tailscale?.network == node.network
else { throw TailscaleConfigurationError.loginRequired }
guard self.appliedExitNodes[groupID] == self.exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
@@ -385,12 +459,15 @@ public final class TailscaleModel: TailscaleConnectionProviding {
public func ping(hostID: Host.ID) async throws -> TailscalePingResult {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard let host = library.host(hostID), let group = library.tailscaleGroup(for: host),
runningGroups.contains(group.id), let node = nodes[group.id],
runningGroups.contains(group.id), let node = nodes[group.id], node.network == group.tailscale?.network,
let ip = peer(for: host)?.details?.addresses.first
else { throw TailscaleConfigurationError.unavailable }
let result = try await node.ping(ip: ip)
let result = try await node.session.ping(ip: ip)
try Task.checkCancellation()
guard hasAccess(), runningGroups.contains(group.id), library.tailscaleGroup(for: host)?.id == group.id else {
guard hasAccess(), runningGroups.contains(group.id), nodes[group.id]?.profileID == node.profileID,
library.tailscaleGroup(for: host)?.id == group.id,
library.group(group.id)?.tailscale?.network == node.network
else {
throw TailscaleConfigurationError.unavailable
}
return result
@@ -401,12 +478,13 @@ public final class TailscaleModel: TailscaleConnectionProviding {
cancelLogin()
for task in monitors.values { task.cancel() }
monitors = [:]
for node in nodes.values { await node.close() }
for node in nodes.values { await node.session.close() }
nodes = [:]
appliedExitNodes = [:]
runningGroups = []
devices = [:]
peers = [:]
for task in cleanupTasks.values { await task.value }
}
private func monitor(_ id: UUID, node: any TailscaleNodeSession) {
@@ -36,6 +36,7 @@ public nonisolated struct TailscaleNetwork: Codable, Hashable, Sendable {
dnsSuffix: container.decode(String.self, forKey: .dnsSuffix))
}
/// Historical single-entry group identity. New connections use independent group UUIDs.
public var groupID: UUID { Self.identifier(["group", controlURL, dnsSuffix]) }
public func hostID(stableNodeID: String) -> UUID {
@@ -44,7 +45,7 @@ public nonisolated struct TailscaleNetwork: Codable, Hashable, Sendable {
/// UUIDv5 with the RFC URL namespace and a length-delimited, versioned name.
/// The algorithm is a storage contract; DNS names and IPs never identify nodes.
private static func identifier(_ components: [String]) -> UUID {
fileprivate static func identifier(_ components: [String]) -> UUID {
let namespace: [UInt8] = [
0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1,
0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
@@ -121,15 +122,39 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable
}
public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendable {
public enum HostIdentityVersion: String, Codable, Sendable {
case legacy, groupScoped
}
public var network: TailscaleNetwork
public let hostIdentityVersion: HostIdentityVersion
public var username = ""
public var exitNodeID: String?
public var hostPreferences: [UUID: TailscaleHostPreferences] = [:]
public init(network: TailscaleNetwork) { self.network = network }
private enum CodingKeys: String, CodingKey { case network, username, hostPreferences, exitNodeID }
public init(network: TailscaleNetwork, hostIdentityVersion: HostIdentityVersion = .groupScoped) {
self.network = network
self.hostIdentityVersion = hostIdentityVersion
}
public func hostID(groupID: UUID, stableNodeID: String) -> UUID {
switch hostIdentityVersion {
case .legacy: network.hostID(stableNodeID: stableNodeID)
case .groupScoped: TailscaleNetwork.identifier(["groupHost", groupID.uuidString.lowercased(), stableNodeID])
}
}
public func conflicts(with other: Self) -> Bool {
hostIdentityVersion == .legacy && other.hostIdentityVersion == .legacy && network == other.network
}
private enum CodingKeys: String, CodingKey {
case network, hostIdentityVersion, username, hostPreferences, exitNodeID
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
network = try values.decode(TailscaleNetwork.self, forKey: .network)
hostIdentityVersion =
try values.decodeIfPresent(HostIdentityVersion.self, forKey: .hostIdentityVersion) ?? .legacy
exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID)
username = try values.decode(String.self, forKey: .username)
let encoded = try values.decode([String: TailscaleHostPreferences].self, forKey: .hostPreferences)
@@ -144,6 +169,7 @@ public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendab
public func encode(to encoder: any Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(network, forKey: .network)
try values.encode(hostIdentityVersion, forKey: .hostIdentityVersion)
try values.encode(username, forKey: .username)
try values.encodeIfPresent(exitNodeID, forKey: .exitNodeID)
try values.encode(
@@ -46,7 +46,7 @@ public nonisolated enum MetricsRecordPayload: Codable, Hashable, Sendable {
}
public nonisolated struct MetricsEnvelope: Codable, Hashable, Sendable {
public static let currentFormatVersion = 2
public static let currentFormatVersion = SyncRecordFormats.metrics.writingVersion(for: "selection")
public var formatVersion = Self.currentFormatVersion
public var key: MetricsRecordKey
public var version: VaultVersion
@@ -66,7 +66,7 @@ public nonisolated struct MetricsEnvelope: Codable, Hashable, Sendable {
}
public var isValid: Bool {
(1...Self.currentFormatVersion).contains(formatVersion)
SyncRecordFormats.metrics.supports(kind: key.kind.rawValue, version: formatVersion)
&& MetricsRecordKey(recordName: key.recordName) == key
&& (isDeleted ? payload == nil : payload?.key == key)
}
@@ -134,7 +134,7 @@ public nonisolated enum ShellHistoryParser {
} else {
pending?.command += "\n" + line
}
continuing = !trailingBackslashes(line).isMultiple(of: 2)
continuing = line.hasSuffix("\\")
if continuing {
// Zsh uses backslash + physical newline to serialize a literal newline.
if pending != nil { pending!.command.removeLast() }
@@ -29,6 +29,7 @@ public nonisolated enum CloudSyncQuarantineReason: String, Codable, Sendable {
case unsupportedFormat
case invalidRecord
case unreadablePayload
case duplicateTailscaleNetwork
}
public nonisolated enum CloudSyncCompatibilityError: LocalizedError {
@@ -45,7 +45,7 @@ public nonisolated struct SyncRecordFormats: Sendable {
private static let v1 = Format(readingVersions: [1], writingVersion: 1)
public static let vault = Self([
"host": Format(readingVersions: [1, 2], writingVersion: 2),
"hostGroup": Format(readingVersions: [1, 2], writingVersion: 2), "identity": v1,
"hostGroup": Format(readingVersions: [1, 2, 3], writingVersion: 3), "identity": v1,
"sshKey": Format(readingVersions: [1, 2], writingVersion: 2),
"knownHost": v1, "snippet": v1, "snippetPackage": v1,
"portForwarding": v1, "workspaceTemplate": v1,
@@ -53,4 +53,7 @@ public nonisolated struct SyncRecordFormats: Sendable {
"connectionHistory": v1, "device": v1,
])
public static let history = Self(["connectionHistory": v1, "device": v1])
public static let metrics = Self([
"selection": Format(readingVersions: [1, 2], writingVersion: 2)
])
}
@@ -88,7 +88,7 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
pendingChanges: {
try CloudSyncRecordStateStore(context: context, scope: $0).pendingChanges(journal.cloudRecords())
},
safety: .persistent(context: context),
safety: .persistent(context: context, capabilityID: SyncRecordFormats.metrics.capabilityID),
resetForNewAccount: { try journal.markAllLiveRecordsNeedsUploadForNewAccount() },
resetForZone: { try journal.markAllEntriesNeedsUploadAfterZoneReset() },
resetForEnvironment: { try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() },
@@ -68,16 +68,41 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
public func start() { lifecycle.start() }
public func pause() { lifecycle.pause() }
public func retry() { lifecycle.retry() }
public func retry() {
refreshConflictingTailnets()
lifecycle.retry()
}
public func confirmAccountSwitchMerge() { lifecycle.confirmAccountSwitchMerge() }
public func noteLocalChanges(_ mutations: VaultMutationSet) {
if mutations.keys.contains(where: { $0.kind == .hostGroup }) { refreshConflictingTailnets() }
lifecycle.noteLocalChanges(mutations.keys.map(\.recordName))
}
private func refreshConflictingTailnets() {
guard let scope = lifecycle.scope else { return }
do {
if try CloudSyncSafetyStore(context: context, scope: scope).hasQuarantine(
reason: .duplicateTailscaleNetwork)
{
// Retire the cursor; prepare forces a fresh fetch before uploads. Do not
// replay cached conflict bytes that the server may already have deleted.
lifecycle.pause()
}
} catch { lifecycle.failClosed(error) }
}
private func makeLifecycle() -> CloudSyncLifecycle {
let journal = journal
let context = context
var safety = CloudSyncLifecycleSafety.persistent(
context: context, capabilityID: SyncRecordFormats.vault.capabilityID)
let prepare = safety.prepare
safety.prepare = { scope in
try prepare(scope)
|| CloudSyncSafetyStore(context: context, scope: scope).hasQuarantine(
reason: .duplicateTailscaleNetwork)
}
return CloudSyncLifecycle(
store: CloudSyncLifecycleStore(
state: journal.syncState, updateState: journal.updateSyncState,
@@ -86,8 +111,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
let cloud = CloudSyncRecordStateStore(context: context, scope: $0)
return try cloud.pendingChanges(journal.cloudRecords())
},
safety: .persistent(
context: context, capabilityID: SyncRecordFormats.vault.capabilityID),
safety: safety,
resetForNewAccount: { try journal.markAllLiveRecordsNeedsUploadForNewAccount() },
resetForZone: { try journal.markAllEntriesNeedsUploadAfterZoneReset() },
resetForEnvironment: { try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() },
@@ -187,7 +211,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
excluding: deletions.filter { deletedNames.contains($0.recordName) })
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
let safety = CloudSyncSafetyStore(context: context, scope: scope)
let decoded: [(record: CKRecord, envelope: VaultEnvelope)] = try decodeCloudSyncRecords(
var decoded: [(record: CKRecord, envelope: VaultEnvelope)] = try decodeCloudSyncRecords(
records.filter { !deletedNames.contains($0.recordID.recordName) },
included: {
$0.recordID.zoneID == zoneID && (isConflict || $0.recordType == Self.recordType)
@@ -196,6 +220,24 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
decode: Self.envelope,
quarantine: { try CloudSyncRecordCoding.quarantine($0, error: $1, in: safety) },
logFailure: { lifecycle.logFailure($0, stage: "decode-quarantine") })
let conflicts = try persistence.conflictingTailscaleGroups(in: decoded.map(\.envelope), deletions: deletedKeys)
for envelope in conflicts {
guard let record = decoded.first(where: { $0.envelope == envelope })?.record else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
try cloud.observed(
record.recordID.recordName, version: envelope.version,
current: journal.cloudRecords().first { $0.mutation.recordName == record.recordID.recordName })
try safety.quarantine(
recordName: record.recordID.recordName,
payloadData: CloudSyncRecordCoding.payloadData(
from: record, invalidEnvelope: TailscaleConfigurationError.duplicateNetwork),
systemFieldsData: CloudSyncRecordCoding.systemFields(of: record),
reason: TailscaleConfigurationError.duplicateNetwork.localizedDescription,
reasonCode: .duplicateTailscaleNetwork)
}
let conflictingKeys = Set(conflicts.map(\.key))
decoded.removeAll { conflictingKeys.contains($0.envelope.key) }
let result = try persistence.applyRemote(
decoded.map { $0.1 }, deletions: deletedKeys,
beforeSave: {
@@ -203,6 +245,12 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
try journal.noteRemoteRecord(record, envelope: envelope, cloud: cloud, safety: safety)
}
try journal.acknowledgeRemoteDeletions(deletedKeys, scope: scope)
for key in deletedKeys
where try safety.entry(recordName: key.recordName)?.reasonCode
== CloudSyncQuarantineReason.duplicateTailscaleNetwork.rawValue
{
try safety.clearQuarantine(recordName: key.recordName)
}
})
try lifecycle.reconcilePendingChanges()
if !result.acceptedKeys.isEmpty { didApplyRemote?(result.snapshot, result.acceptedKeys) }
@@ -62,10 +62,14 @@ public final class CloudSyncSafetyStore {
}
public func requiresNewerClient() throws -> Bool {
try hasQuarantine(reason: .unsupportedFormat)
}
public func hasQuarantine(reason: CloudSyncQuarantineReason) throws -> Bool {
let scopeKey = scope.storageKey
let unsupported = CloudSyncQuarantineReason.unsupportedFormat.rawValue
let reasonCode = reason.rawValue
var descriptor = FetchDescriptor<PersistentCloudSyncQuarantine>(
predicate: #Predicate { $0.scopeKey == scopeKey && $0.reasonCode == unsupported })
predicate: #Predicate { $0.scopeKey == scopeKey && $0.reasonCode == reasonCode })
descriptor.fetchLimit = 1
return try !context.fetch(descriptor).isEmpty
}
@@ -933,6 +933,13 @@ public enum TermindSchemaV49: VersionedSchema {
}
}
public enum TermindSchemaV50: VersionedSchema {
public static let versionIdentifier = Schema.Version(50, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV49.models + [PersistentTailscaleProfileCleanup.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -949,6 +956,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self,
TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self,
TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self,
TermindSchemaV50.self,
]
}
@@ -1057,6 +1065,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV46.self, toVersion: TermindSchemaV47.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV47.self, toVersion: TermindSchemaV48.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV48.self, toVersion: TermindSchemaV49.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV49.self, toVersion: TermindSchemaV50.self),
]
}
}
@@ -136,7 +136,7 @@ extension VaultRecordPayload {
}
extension VaultEnvelope {
fileprivate var withoutDeviceLocalMetadata: Self {
var withoutDeviceLocalMetadata: Self {
var copy = self
copy.payload = payload?.withoutDeviceLocalMetadata
return copy
@@ -67,6 +67,52 @@ public final class HostPersistence {
try applyRemote(envelopes, beforeSave: {})
}
/// Legacy host IDs cannot distinguish two connections to the same Tailnet.
/// Preserve their local owner; group-scoped connections can coexist freely.
/// The coordinator quarantines these exact winning envelopes before applying the
/// remaining batch. Deletions and unbindings in that batch can release ownership.
public func conflictingTailscaleGroups(
in envelopes: [VaultEnvelope], deletions: Set<VaultRecordKey>
) throws -> [VaultEnvelope] {
let journal = VaultSyncJournal(context: context)
let deleted = try journal.deletionEnvelopes(deletions)
let accepted = try journal.mergeDecision(
for: envelopes.filter { !deletions.contains($0.key) } + deleted
).accepted
let original = try fetchSnapshot()
var rejected = Set<VaultRecordKey>()
while true {
var candidate = original
for envelope in accepted where !rejected.contains(envelope.key) { try candidate.apply(envelope) }
let networks = Dictionary(
grouping: candidate.groups.filter { $0.tailscale?.hostIdentityVersion == .legacy }
) {
$0.tailscale!.network
}
var conflicts = Set<VaultRecordKey>()
for (network, groups) in networks where groups.count > 1 {
let previousOwner = original.groups.first {
$0.tailscale?.hostIdentityVersion == .legacy && $0.tailscale?.network == network
}?.id
let owner =
groups.first { $0.id == previousOwner } ?? groups.min { $0.id.uuidString < $1.id.uuidString }!
for group in groups where group.id != owner.id {
conflicts.insert(VaultRecordKey(kind: .hostGroup, id: group.id))
}
}
let additional = conflicts.subtracting(rejected)
guard !additional.isEmpty else {
return try accepted.filter { rejected.contains($0.key) }.map { winner in
guard let original = envelopes.first(where: { $0.withoutDeviceLocalMetadata == winner }) else {
throw VaultSyncPersistenceError.invalidEnvelope(winner.key.recordName)
}
return original
}
}
rejected.formUnion(additional)
}
}
public func applyRemote(
_ envelopes: [VaultEnvelope], deletions: Set<VaultRecordKey> = [], beforeSave: () throws -> Void
) throws -> VaultRemoteApplyResult {
@@ -142,7 +188,9 @@ public final class HostPersistence {
guard snapshot.hosts.allSatisfy({ !$0.isTailscaleManaged }) else {
throw TailscaleConfigurationError.managedHost
}
let networks = snapshot.groups.compactMap { $0.tailscale?.network }
let networks = snapshot.groups.compactMap { group in
group.tailscale.flatMap { $0.hostIdentityVersion == .legacy ? $0.network : nil }
}
guard Set(networks).count == networks.count else { throw TailscaleConfigurationError.duplicateNetwork }
try persistTailscaleGroups(snapshot.groups)
try reconcile(
@@ -19,6 +19,13 @@ public final class PersistentTailscaleGroup {
public typealias PersistentTailscaleLocalState = TermindSchemaV48TailscaleModels.PersistentTailscaleLocalState
/// Device-local retry intent; never part of a Vault envelope.
@Model
public final class PersistentTailscaleProfileCleanup {
@Attribute(.unique) public var profileID: UUID
public init(profileID: UUID) { self.profileID = profileID }
}
extension HostPersistence {
static func groups(in context: ModelContext) throws -> [HostGroup] {
let configurations = try Dictionary(
@@ -69,6 +76,7 @@ public final class SwiftDataTailscaleLocalStore: TailscaleLocalStoring {
if let row = try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()).first(where: {
$0.groupID == state.groupID
}) {
if row.profileID != state.profileID { try enqueueCleanup(row.profileID) }
row.profileID = state.profileID
row.peersData = data
row.hostname = state.hostname
@@ -77,14 +85,42 @@ public final class SwiftDataTailscaleLocalStore: TailscaleLocalStoring {
PersistentTailscaleLocalState(
groupID: state.groupID, profileID: state.profileID, peersData: data, hostname: state.hostname))
}
try deleteCleanup(state.profileID)
}
}
public func remove(groupID: UUID) throws {
try context.commit {
for row in try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()) where row.groupID == groupID
{
try enqueueCleanup(row.profileID)
context.delete(row)
}
}
}
public func scheduleCleanup(profileID: UUID) throws {
try context.commit { try enqueueCleanup(profileID) }
}
public func pendingCleanupProfiles() throws -> Set<UUID> {
let active = Set(try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()).map(\.profileID))
return Set(try context.fetch(FetchDescriptor<PersistentTailscaleProfileCleanup>()).map(\.profileID))
.subtracting(active)
}
public func completeCleanup(profileID: UUID) throws {
try context.commit { try deleteCleanup(profileID) }
}
private func enqueueCleanup(_ profileID: UUID) throws {
let query = FetchDescriptor<PersistentTailscaleProfileCleanup>(
predicate: #Predicate { $0.profileID == profileID })
if try context.fetch(query).isEmpty { context.insert(PersistentTailscaleProfileCleanup(profileID: profileID)) }
}
private func deleteCleanup(_ profileID: UUID) throws {
let query = FetchDescriptor<PersistentTailscaleProfileCleanup>(
predicate: #Predicate { $0.profileID == profileID })
for row in try context.fetch(query) { context.delete(row) }
}
}
@@ -20,7 +20,7 @@ public enum TermindPersistence {
#endif
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV49.self)
public static let schema = Schema(versionedSchema: TermindSchemaV50.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -66,6 +66,9 @@ public nonisolated enum VaultSyncCodec {
guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) }
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
if envelope.formatVersion == 3 {
return try encoder.encode(VaultWireV3.Envelope(envelope))
}
if envelope.formatVersion == 2 {
return try encoder.encode(VaultWireV2.Envelope(envelope))
}
@@ -86,6 +89,9 @@ public nonisolated enum VaultSyncCodec {
decoders[VaultRecordKind.hostGroup.rawValue]?[2] = {
try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue
}
decoders[VaultRecordKind.hostGroup.rawValue]?[3] = {
try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue
}
return VersionedRecordDecoder(decoders)
}()
@@ -163,18 +163,57 @@ nonisolated enum VaultWireV2 {
return result
}
}
/// Shipped V2 has only control-plane-scoped host IDs. Keep its shape frozen.
struct TailscaleConfiguration: Codable {
var network: TailscaleNetwork
var username: String
var exitNodeID: String?
var hostPreferences: [String: TailscaleHostPreferences]
init(_ value: TailscaleGroupConfiguration) throws {
guard value.hostIdentityVersion == .legacy else { throw CocoaError(.coderInvalidValue) }
network = value.network
username = value.username
exitNodeID = value.exitNodeID
hostPreferences = Dictionary(
uniqueKeysWithValues: value.hostPreferences.map { ($0.key.uuidString.lowercased(), $0.value) })
}
private enum CodingKeys: String, CodingKey { case network, username, exitNodeID, hostPreferences }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
network = try values.decode(TailscaleNetwork.self, forKey: .network)
username = try values.decode(String.self, forKey: .username)
exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID)
hostPreferences = try values.decode([String: TailscaleHostPreferences].self, forKey: .hostPreferences)
guard hostPreferences.keys.allSatisfy({ UUID(uuidString: $0)?.uuidString.lowercased() == $0 }) else {
throw DecodingError.dataCorruptedError(
forKey: .hostPreferences, in: values, debugDescription: "Invalid host identifier")
}
}
var domainValue: TailscaleGroupConfiguration {
var value = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
value.username = username
value.exitNodeID = exitNodeID
value.hostPreferences = Dictionary(
uniqueKeysWithValues: hostPreferences.map { (UUID(uuidString: $0.key)!, $0.value) })
return value
}
}
struct HostGroup: Codable {
var base: VaultWireV1.HostGroup
var tailscale: TailscaleGroupConfiguration?
var tailscale: TailscaleConfiguration?
enum CodingKeys: String, CodingKey { case tailscale }
init(_ value: TermindDomain.HostGroup) {
init(_ value: TermindDomain.HostGroup) throws {
base = VaultWireV1.HostGroup(value)
tailscale = value.tailscale
tailscale = try value.tailscale.map(TailscaleConfiguration.init)
}
init(from decoder: any Decoder) throws {
base = try VaultWireV1.HostGroup(from: decoder)
tailscale = try decoder.container(keyedBy: CodingKeys.self)
.decode(TailscaleGroupConfiguration?.self, forKey: .tailscale)
.decode(TailscaleConfiguration?.self, forKey: .tailscale)
}
func encode(to encoder: any Encoder) throws {
try base.encode(to: encoder)
@@ -183,7 +222,7 @@ nonisolated enum VaultWireV2 {
}
var domainValue: TermindDomain.HostGroup {
var value = base.domainValue
value.tailscale = tailscale
value.tailscale = tailscale?.domainValue
return value
}
}
@@ -206,7 +245,7 @@ nonisolated enum VaultWireV2 {
if case .host(let host) = envelope.payload {
payload = .host(try Host(host))
} else if case .hostGroup(let group) = envelope.payload {
payload = .hostGroup(HostGroup(group))
payload = .hostGroup(try HostGroup(group))
} else if case .sshKey(let key) = envelope.payload {
guard key.storage != .external || key.algorithm == .securityKey else {
throw CocoaError(.coderInvalidValue)
@@ -0,0 +1,80 @@
import Foundation
import TermindDomain
/// Group-scoped Tailscale host identities require an explicit V3 wire contract.
nonisolated enum VaultWireV3 {
struct HostGroup: Codable {
var base: VaultWireV1.HostGroup
var tailscale: TailscaleGroupConfiguration?
enum CodingKeys: String, CodingKey { case tailscale }
enum TailscaleKeys: String, CodingKey { case hostIdentityVersion }
init(_ value: TermindDomain.HostGroup) {
base = VaultWireV1.HostGroup(value)
tailscale = value.tailscale
}
init(from decoder: any Decoder) throws {
base = try VaultWireV1.HostGroup(from: decoder)
let values = try decoder.container(keyedBy: CodingKeys.self)
tailscale = try values.decode(TailscaleGroupConfiguration?.self, forKey: .tailscale)
if tailscale != nil {
// Only local historical JSON may omit this field. Missing it in V3
// could otherwise silently change every projected host's identity.
let configuration = try values.nestedContainer(keyedBy: TailscaleKeys.self, forKey: .tailscale)
_ = try configuration.decode(
TailscaleGroupConfiguration.HostIdentityVersion.self, forKey: .hostIdentityVersion)
}
}
func encode(to encoder: any Encoder) throws {
try base.encode(to: encoder)
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(tailscale, forKey: .tailscale)
}
var domainValue: TermindDomain.HostGroup {
var value = base.domainValue
value.tailscale = tailscale
return value
}
}
enum Payload: Codable {
case hostGroup(HostGroup)
}
struct Envelope: Codable {
var formatVersion: Int
var key: VaultRecordKey
var version: VaultVersion
var isDeleted: Bool
var payload: Payload?
init(_ envelope: VaultEnvelope) throws {
guard envelope.key.kind == .hostGroup else { throw CocoaError(.coderInvalidValue) }
formatVersion = 3
key = envelope.key
version = envelope.version
isDeleted = envelope.isDeleted
if case .hostGroup(let group) = envelope.payload {
payload = .hostGroup(HostGroup(group))
} else if envelope.payload == nil {
payload = nil
} else {
throw CocoaError(.coderInvalidValue)
}
}
var domainValue: VaultEnvelope {
let value: VaultRecordPayload?
switch payload {
case .hostGroup(let group): value = .hostGroup(group.domainValue)
case nil: value = nil
}
return VaultEnvelope(
key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion)
}
}
}
@@ -18,18 +18,53 @@ public actor LibTailscaleNodeSession: TailscaleNodeSession {
public static let factory: TailscaleNodeFactory = { profileID, hostname, controlURL, authKey in
try await Task.detached {
let directory = try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
).appendingPathComponent("Termind/Tailscale/\(profileID.uuidString)", isDirectory: true)
let directory = try profileDirectory(profileID)
let node = try Node(
hostname: hostname, directory: directory, controlURL: controlURL,
stateNamespace: "app.termind.tailscale.\(profileID.uuidString)", authKey: authKey
stateNamespace: stateNamespace(profileID), authKey: authKey
)
return LibTailscaleNodeSession(node: node, controlURL: controlURL)
}.value
}
public static let profileStorage = TailscaleProfileStorage(
profiles: {
try await Task.detached { try storedProfiles(in: profileRoot()) }.value
},
erase: { id in
try await Task.detached {
try Node.eraseState(directory: profileDirectory(id), stateNamespace: stateNamespace(id))
}.value
})
private nonisolated static func stateNamespace(_ id: UUID) -> String { "app.termind.tailscale.\(id.uuidString)" }
private nonisolated static func profileRoot() throws -> URL {
try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false
).appendingPathComponent("Termind/Tailscale", isDirectory: true)
}
private nonisolated static func profileDirectory(_ id: UUID) throws -> URL {
try profileRoot().appendingPathComponent(id.uuidString, isDirectory: true)
}
nonisolated static func storedProfiles(in root: URL) throws -> Set<UUID> {
let entries: [URL]
do {
entries = try FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
} catch CocoaError.fileReadNoSuchFile { return [] }
return try Set(
entries.compactMap { url in
guard let id = UUID(uuidString: url.lastPathComponent), id.uuidString == url.lastPathComponent else {
return nil
}
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
return values.isDirectory == true && values.isSymbolicLink != true ? id : nil
})
}
public func status() async throws -> TailscaleNodeStatus {
let data = try await node.request(path: "status")
return try Self.decodeStatus(data, controlURL: controlURL)
@@ -140,7 +175,7 @@ public actor LibTailscaleNodeSession: TailscaleNodeSession {
public func logout() async throws {
_ = try? await node.request(method: "POST", path: "logout")
await node.close()
try await node.eraseState()
// The model's durable profile cleanup queue owns local erasure and retry.
}
public func close() async { await node.close() }
public func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
+5 -1
View File
@@ -305,7 +305,11 @@ final class MobileAppModel {
let tailscaleAccess = pro
tailscale = TailscaleModel(
library: library, store: SwiftDataTailscaleLocalStore(context: ModelContext(container)),
factory: LibTailscaleNodeSession.factory,
factory: { profileID, hostname, controlURL, authKey in
guard !memoryOnly else { throw TailscaleConfigurationError.unavailable }
return try await LibTailscaleNodeSession.factory(profileID, hostname, controlURL, authKey)
},
profileStorage: memoryOnly ? .disabled : LibTailscaleNodeSession.profileStorage,
hostname: { tailscaleDevices.currentDeviceName }, hasAccess: { tailscaleAccess.canUse(.tailscale) }
)
let forwardingRouter = agentForwardingRouter
@@ -1751,9 +1751,7 @@ final class MobileWorkspace: Identifiable {
let url = try await sftpEdits.open(
file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp,
homeDirectory: sftpChannel.homeDirectory)
if connectionOrigin.allowsPersistence {
sftpHistory.record(kind: .file, path: file.path)
}
sftpHistory.record(kind: .file, path: file.path)
completion(url)
} catch { operationError = error.localizedDescription }
}
@@ -1774,9 +1772,7 @@ final class MobileWorkspace: Identifiable {
homeDirectory: sftpChannel.homeDirectory
) { [sftpHistory, path = file.path] state in
guard state == .completed else { return }
if self.connectionOrigin.allowsPersistence {
sftpHistory.record(kind: .file, path: path)
}
sftpHistory.record(kind: .file, path: path)
completion(localURL)
}
return true
+21
View File
@@ -31,6 +31,27 @@ struct MobileAppTests {
#expect(features.registrations.map(\.id) == [.vault, .metrics, .workspaces, .assistant, .settings])
}
@Test func memoryCompositionCannotStartOrEraseLiveTailscaleProfiles() async throws {
var dependencies = MobileAppDependencies.testing()
let container = try TermindPersistence.makeContainer(inMemory: true)
dependencies.container = container
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
_ = try HostPersistence(context: container.mainContext).save(.init(groups: [group]))
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
let profileID = UUID()
try store.save(.init(groupID: group.id, profileID: profileID))
let app = try MobileAppModel(dependencies: dependencies)
defer { app.outbox.stop() }
for _ in 0..<200 where app.tailscale.error == nil { try await Task.sleep(for: .milliseconds(5)) }
#expect(app.tailscale.error != nil)
#expect(app.tailscale.runningGroups.isEmpty)
await app.tailscale.logout(groupID: group.id)
#expect(try store.pendingCleanupProfiles() == [profileID])
await app.tailscale.shutdown()
}
@Test func startupLoadsVersion28DataAfterMigrationAndRelaunch() throws {
let source = try #require(
Bundle(for: MobileMigrationFixtureBundle.self).url(forResource: "V28", withExtension: "store"))
@@ -804,6 +804,7 @@ struct MobileUploadBatchTests {
client: MobileUploadSFTPClient,
kind: ConnectionHistoryEntry.Kind = .ssh,
initialPath: String = "",
connectionOrigin: RemoteConnectionOrigin = .library,
open: (() async throws -> any SFTPClientProtocol)? = nil
) -> MobileWorkspace {
let identity = Identity(name: "Upload", username: "user")
@@ -820,7 +821,7 @@ struct MobileUploadBatchTests {
}, writeSSH: { _, _ in })
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, connectionServices: services)
hosts: library, transfers: transfers, connectionServices: services, connectionOrigin: connectionOrigin)
}
@Test func serialUploadSurvivesClosingTheSFTPWorkspace() async throws {
@@ -1035,7 +1036,8 @@ struct MobileUploadBatchTests {
)
}
@Test func successfulFileDownloadsRefreshHistoryWhileInvalidAndFailedFilesDoNot() async throws {
@Test(arguments: [false, true])
func successfulFileDownloadsRefreshHistoryWhileInvalidAndFailedFilesDoNot(external: Bool) async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
@@ -1080,7 +1082,8 @@ struct MobileUploadBatchTests {
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
connectionServices: services
connectionServices: services,
connectionOrigin: external ? .externalLink(hostKeyConstraints: [:]) : .library
)
let history = workspace.sftpHistory
defer { workspace.disconnect() }
@@ -1109,6 +1112,24 @@ struct MobileUploadBatchTests {
#expect(!history.entries().contains { $0.path == changedDirectory.path })
}
@Test func closingTemporaryWorkspacePreventsLatePreviewFromRestoringHistory() async throws {
let client = MobileUploadSFTPClient(blocksDownload: true)
let workspace = uploadWorkspace(
client: client, kind: .sftp, connectionOrigin: .externalLink(hostKeyConstraints: [:]))
workspace.connect()
#expect(await eventually { workspace.connected })
let file = RemoteFile(
path: "/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
var completed = false
#expect(workspace.preview(file) { _ in completed = true })
#expect(await eventuallyAsync { await client.startedDownloads == 1 })
workspace.close()
await client.finishDownloads()
#expect(await eventually { completed })
#expect(workspace.sftpHistory.entries().isEmpty)
}
@Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let upload = root.appending(path: "upload", directoryHint: .isDirectory)
@@ -1277,11 +1298,14 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var removedPaths: [String] = []
private(set) var disconnectCount = 0
private(set) var startedUploads = 0
private(set) var startedDownloads = 0
private let resolvedHomeDirectory: String?
private let blocksUpload: Bool
private let blocksDownload: Bool
private let requiresInteraction: Bool
private let failureStage: UploadFailureStage?
private var uploadWaiters: [CheckedContinuation<Void, Never>] = []
private var downloadWaiters: [CheckedContinuation<Void, Never>] = []
private let attributes: [String: RemoteFile]
private let failedDownloadPaths: Set<String>
private let children: [String: [RemoteFile]]
@@ -1291,11 +1315,12 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
attributes: [String: RemoteFile] = [:],
failedDownloadPaths: Set<String> = [],
children: [String: [RemoteFile]] = [:],
blocksUpload: Bool = false, requiresInteraction: Bool = false,
blocksUpload: Bool = false, blocksDownload: Bool = false, requiresInteraction: Bool = false,
failureStage: UploadFailureStage? = nil
) {
self.resolvedHomeDirectory = homeDirectory
self.blocksUpload = blocksUpload
self.blocksDownload = blocksDownload
self.requiresInteraction = requiresInteraction
self.failureStage = failureStage
self.attributes = attributes
@@ -1311,6 +1336,11 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
uploadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func finishDownloads() {
let pending = downloadWaiters
downloadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func disconnect() async { disconnectCount += 1 }
func homeDirectory() async throws -> String {
guard let resolvedHomeDirectory else { throw SFTPError.operation("Home unavailable") }
@@ -1350,6 +1380,8 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {
startedDownloads += 1
if blocksDownload { await withCheckedContinuation { downloadWaiters.append($0) } }
if failedDownloadPaths.contains(remotePath) {
throw SFTPError.operation("Download failed")
}
+5 -1
View File
@@ -186,6 +186,10 @@ final class AppModel {
connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(),
devices: DeviceDirectoryModel? = nil,
tailscaleStore: (any TailscaleLocalStoring)? = nil,
tailscaleFactory: @escaping TailscaleNodeFactory = { _, _, _, _ in
throw TailscaleConfigurationError.unavailable
},
tailscaleProfileStorage: TailscaleProfileStorage = .disabled,
externalToolCalls: ToolAuditModel = ToolAuditModel(),
dependencies: AppDependencies? = nil,
speechTranscription: SpeechTranscriptionCoordinator? = nil,
@@ -233,7 +237,7 @@ final class AppModel {
self.devices = devices
tailscale = TailscaleModel(
library: hosts, store: tailscaleStore ?? InMemoryTailscaleLocalStore(),
factory: LibTailscaleNodeSession.factory,
factory: tailscaleFactory, profileStorage: tailscaleProfileStorage,
hostname: { devices.currentDeviceName }, hasAccess: { proAccess.canUse(.tailscale) }
)
self.externalToolCalls = externalToolCalls
+6
View File
@@ -2,6 +2,7 @@ import AgentKitScrubber
import SwiftData
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
@@ -175,6 +176,11 @@ struct TermindApp: App {
),
devices: devices,
tailscaleStore: SwiftDataTailscaleLocalStore(context: ModelContext(container)),
tailscaleFactory: { profileID, hostname, controlURL, authKey in
guard !usesInMemoryStore else { throw TailscaleConfigurationError.unavailable }
return try await LibTailscaleNodeSession.factory(profileID, hostname, controlURL, authKey)
},
tailscaleProfileStorage: usesInMemoryStore ? .disabled : LibTailscaleNodeSession.profileStorage,
externalToolCalls: externalToolCalls,
dependencies: AppDependencies(
hostRepository: repository,
+27
View File
@@ -0,0 +1,27 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@Suite @MainActor
struct TailscaleCompositionTests {
@Test func defaultMemoryCompositionCannotStartOrEraseLiveProfiles() async throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
let profileID = UUID()
try store.save(.init(groupID: group.id, profileID: profileID))
let app = AppModel(hosts: library, tailscaleStore: store)
for _ in 0..<200 where app.tailscale.error == nil { try await Task.sleep(for: .milliseconds(5)) }
#expect(app.tailscale.error != nil)
#expect(app.tailscale.runningGroups.isEmpty)
await app.tailscale.logout(groupID: group.id)
// The disabled eraser fails closed, leaving the retry marker untouched.
#expect(try store.pendingCleanupProfiles() == [profileID])
await app.shutdownForTermination().value
}
}
+1 -1
View File
@@ -2899,7 +2899,7 @@
repositoryURL = "https://github.com/AkinoKaede/libtailscale-spm.git";
requirement = {
kind = exactVersion;
version = 1.0.0;
version = 1.0.1;
};
};
BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = {
@@ -185,8 +185,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/libtailscale-spm.git",
"state" : {
"revision" : "c4cdb3fb91231ab6997bb00ec69bb74898324ac2",
"version" : "1.0.0"
"revision" : "b2fb67440039ec805ed26a250942421456a6f89e",
"version" : "1.0.1"
}
},
{