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:
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
Submodule libtailscale-spm updated: c4cdb3fb91...b2fb674400.
Reference in new issue
Block a user