diff --git a/FrameworkTests/RemoteShellHistoryLoaderTests.swift b/FrameworkTests/RemoteShellHistoryLoaderTests.swift index 2e22951d..fb46c403 100644 --- a/FrameworkTests/RemoteShellHistoryLoaderTests.swift +++ b/FrameworkTests/RemoteShellHistoryLoaderTests.swift @@ -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) diff --git a/FrameworkTests/SessionHistoryTests.swift b/FrameworkTests/SessionHistoryTests.swift index 8f4a291a..22ad0847 100644 --- a/FrameworkTests/SessionHistoryTests.swift +++ b/FrameworkTests/SessionHistoryTests.swift @@ -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()).map(\.value) == [event]) + let devices = try context.fetch(FetchDescriptor()) + #expect( + devices.count == 1 && devices.first?.id == device.id && devices.first?.displayName == device.displayName + ) + let rows = try context.fetch(FetchDescriptor()) + #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()).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) diff --git a/FrameworkTests/TailscaleIntegrationTests.swift b/FrameworkTests/TailscaleIntegrationTests.swift index 05e8c92b..da4d2bfc 100644 --- a/FrameworkTests/TailscaleIntegrationTests.swift +++ b/FrameworkTests/TailscaleIntegrationTests.swift @@ -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? + 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 { [] } diff --git a/FrameworkTests/TailscaleMultiConnectionTests.swift b/FrameworkTests/TailscaleMultiConnectionTests.swift new file mode 100644 index 00000000..d4c4e5a4 --- /dev/null +++ b/FrameworkTests/TailscaleMultiConnectionTests.swift @@ -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([ + "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 { [] } +} diff --git a/FrameworkTests/TailscaleProfileCleanupTests.swift b/FrameworkTests/TailscaleProfileCleanupTests.swift new file mode 100644 index 00000000..4d588da2 --- /dev/null +++ b/FrameworkTests/TailscaleProfileCleanupTests.swift @@ -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 = [] + var inventoryReads = 0 + var failErase = false + var holdFactory = false + var failFactory = false + var factoryContinuation: CheckedContinuation? + + 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 { + 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? + 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 { [] } +} + +@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 { 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) throws -> VaultMutationSet { + if failSave { throw CocoaError(.fileWriteUnknown) } + self.snapshot = snapshot + return .empty + } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + .init(snapshot: snapshot, acceptedKeys: [], needsUpload: []) + } +} diff --git a/FrameworkTests/TailscaleSyncSafetyTests.swift b/FrameworkTests/TailscaleSyncSafetyTests.swift new file mode 100644 index 00000000..944d7f96 --- /dev/null +++ b/FrameworkTests/TailscaleSyncSafetyTests.swift @@ -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)) + } +} diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index c7868025..ddadaacb 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -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 diff --git a/Frameworks/TermindApplication/Connections/TailscaleConnections.swift b/Frameworks/TermindApplication/Connections/TailscaleConnections.swift index b484f063..0124c893 100644 --- a/Frameworks/TermindApplication/Connections/TailscaleConnections.swift +++ b/Frameworks/TermindApplication/Connections/TailscaleConnections.swift @@ -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 + 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 + public var erase: @Sendable (UUID) async throws -> Void + public init( + profiles: @escaping @Sendable () async throws -> Set, + 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 = [] 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 { + cleanupProfiles.subtracting(states.values.map(\.profileID)) + } + public func completeCleanup(profileID: UUID) throws { cleanupProfiles.remove(profileID) } } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 48f283a1..0c5e3bd4 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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) diff --git a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift index 32d7e022..2c74590d 100644 --- a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift @@ -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] = [:] @ObservationIgnored private var starting: Set = [] + @ObservationIgnored private var profilesInUse: Set = [] + @ObservationIgnored private var retiringNodes: [UUID: any TailscaleNodeSession] = [:] + @ObservationIgnored private var cleanupTasks: [UUID: Task] = [:] @ObservationIgnored private var loginTask: Task? @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) { diff --git a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift index 82f75b8e..d8dd0f9c 100644 --- a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift +++ b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift @@ -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( diff --git a/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift index ffac72eb..c203bb43 100644 --- a/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift +++ b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift @@ -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) } diff --git a/Frameworks/TermindDomain/Shell/ShellHistory.swift b/Frameworks/TermindDomain/Shell/ShellHistory.swift index 4dadba6e..c56677aa 100644 --- a/Frameworks/TermindDomain/Shell/ShellHistory.swift +++ b/Frameworks/TermindDomain/Shell/ShellHistory.swift @@ -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() } diff --git a/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift b/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift index e5680ff1..07689852 100644 --- a/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift +++ b/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift @@ -29,6 +29,7 @@ public nonisolated enum CloudSyncQuarantineReason: String, Codable, Sendable { case unsupportedFormat case invalidRecord case unreadablePayload + case duplicateTailscaleNetwork } public nonisolated enum CloudSyncCompatibilityError: LocalizedError { diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index db1969f7..bb4bde15 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -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) + ]) } diff --git a/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift index 079d4b88..29ea8bc2 100644 --- a/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift @@ -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() }, diff --git a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift index 91aa3506..a94b5396 100644 --- a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift @@ -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) } diff --git a/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift b/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift index ddd2c352..6fc3495b 100644 --- a/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift +++ b/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift @@ -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( - 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 } diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 4bf9258a..e8b5b22a 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index 59e9b0c4..bc32bff0 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -136,7 +136,7 @@ extension VaultRecordPayload { } extension VaultEnvelope { - fileprivate var withoutDeviceLocalMetadata: Self { + var withoutDeviceLocalMetadata: Self { var copy = self copy.payload = payload?.withoutDeviceLocalMetadata return copy diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index 429ebb91..63309bcd 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -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 + ) 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() + 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() + 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 = [], 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( diff --git a/Frameworks/TermindPersistence/TailscalePersistence.swift b/Frameworks/TermindPersistence/TailscalePersistence.swift index 9eccb899..e3b28665 100644 --- a/Frameworks/TermindPersistence/TailscalePersistence.swift +++ b/Frameworks/TermindPersistence/TailscalePersistence.swift @@ -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()).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()) 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 { + let active = Set(try context.fetch(FetchDescriptor()).map(\.profileID)) + return Set(try context.fetch(FetchDescriptor()).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( + 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( + predicate: #Predicate { $0.profileID == profileID }) + for row in try context.fetch(query) { context.delete(row) } + } } diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index c4ff0b8e..660aab38 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index d43f1e4c..af2166ab 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -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) }() diff --git a/Frameworks/TermindPersistence/VaultWireV2.swift b/Frameworks/TermindPersistence/VaultWireV2.swift index 6d82acc5..7de5b375 100644 --- a/Frameworks/TermindPersistence/VaultWireV2.swift +++ b/Frameworks/TermindPersistence/VaultWireV2.swift @@ -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) diff --git a/Frameworks/TermindPersistence/VaultWireV3.swift b/Frameworks/TermindPersistence/VaultWireV3.swift new file mode 100644 index 00000000..30fe5905 --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWireV3.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift b/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift index 3d800380..7fe9222d 100644 --- a/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift +++ b/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift @@ -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 { + 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 { diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index f4ea593c..a4b3fd4e 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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 diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 320de80a..b9942469 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index b8dc8a10..023f2471 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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")) diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 36f9fc1a..8923b568 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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] = [] + private var downloadWaiters: [CheckedContinuation] = [] private let attributes: [String: RemoteFile] private let failedDownloadPaths: Set private let children: [String: [RemoteFile]] @@ -1291,11 +1315,12 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { attributes: [String: RemoteFile] = [:], failedDownloadPaths: Set = [], 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") } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 47b70b74..a541e227 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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 diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index cf2bc2bf..fb80bf8d 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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, diff --git a/TFMTests/TailscaleCompositionTests.swift b/TFMTests/TailscaleCompositionTests.swift new file mode 100644 index 00000000..ba0db1cc --- /dev/null +++ b/TFMTests/TailscaleCompositionTests.swift @@ -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 + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 30e6a65c..70a0ce95 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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" */ = { diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1729271e..6cf264f9 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { diff --git a/libtailscale-spm b/libtailscale-spm index c4cdb3fb..b2fb6744 160000 --- a/libtailscale-spm +++ b/libtailscale-spm @@ -1 +1 @@ -Subproject commit c4cdb3fb91231ab6997bb00ec69bb74898324ac2 +Subproject commit b2fb67440039ec805ed26a250942421456a6f89e