From 1ab7ed3da3dcebf68b7a6b7ea4095e920cf50bf1 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 3 Oct 2026 09:46:09 +0800 Subject: [PATCH] feat: isolate tailscale connections and harden history Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment. Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision. Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved. --- .../RemoteShellHistoryLoaderTests.swift | 26 + FrameworkTests/SessionHistoryTests.swift | 87 +++ .../TailscaleIntegrationTests.swift | 109 +++- .../TailscaleMultiConnectionTests.swift | 149 +++++ .../TailscaleProfileCleanupTests.swift | 514 ++++++++++++++++++ FrameworkTests/TailscaleSyncSafetyTests.swift | 223 ++++++++ .../Connections/RemoteSessionHostStore.swift | 3 +- .../Connections/TailscaleConnections.swift | 34 +- .../FeatureModels/HostLibraryModel.swift | 19 +- .../FeatureModels/TailscaleModel.swift | 220 +++++--- .../TermindDomain/Hosts/TailscaleHost.swift | 32 +- .../Metrics/MetricsSyncTypes.swift | 4 +- .../TermindDomain/Shell/ShellHistory.swift | 2 +- .../TermindDomain/Vault/CloudSyncSafety.swift | 1 + .../Vault/SyncRecordFormats.swift | 5 +- .../Cloud/MetricsSyncCoordinator.swift | 2 +- .../Cloud/VaultSyncCoordinator.swift | 56 +- .../CloudSyncSafetyStore.swift | 8 +- .../TermindPersistence/HostPersistence.swift | 9 + .../SwiftDataVaultJournal.swift | 2 +- .../SwiftDataVaultStore.swift | 50 +- .../TailscalePersistence.swift | 36 ++ .../TermindPersistence.swift | 2 +- .../VaultSyncPersistence.swift | 6 + .../TermindPersistence/VaultWireV2.swift | 51 +- .../TermindPersistence/VaultWireV3.swift | 80 +++ .../Tailscale/LibTailscaleNodeSession.swift | 47 +- TFI/App/MobileAppModel.swift | 6 +- TFI/Features/Workspaces/MobileWorkspace.swift | 8 +- TFITests/MobileAppTests.swift | 21 + ...MobileCommandGeneratorAndUploadTests.swift | 40 +- TFM/App/AppModel.swift | 6 +- TFM/App/TermindApp.swift | 6 + TFMTests/TailscaleCompositionTests.swift | 27 + Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- libtailscale-spm | 2 +- 37 files changed, 1766 insertions(+), 133 deletions(-) create mode 100644 FrameworkTests/TailscaleMultiConnectionTests.swift create mode 100644 FrameworkTests/TailscaleProfileCleanupTests.swift create mode 100644 FrameworkTests/TailscaleSyncSafetyTests.swift create mode 100644 Frameworks/TermindPersistence/VaultWireV3.swift create mode 100644 TFMTests/TailscaleCompositionTests.swift 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