diff --git a/FrameworkTests/TailscaleGroupEditorTests.swift b/FrameworkTests/TailscaleGroupEditorTests.swift index 24537771..502f2434 100644 --- a/FrameworkTests/TailscaleGroupEditorTests.swift +++ b/FrameworkTests/TailscaleGroupEditorTests.swift @@ -87,6 +87,24 @@ struct TailscaleGroupEditorTests { await fixture.model.shutdown() } + @Test(arguments: [false, true]) + func logoutOfAnUnsavedLoginTakesEffectImmediatelyInNewAndEdit(existing: Bool) async throws { + let fixture = try EditorFixture(existing: existing) + let savedGroups = fixture.library.groups + let editor = fixture.editor(savedGroups.first) + editor.setEnabled(true) + editor.login(authKey: nil) + try await fixture.ready(editor) + #expect(editor.pending != nil && editor.isConnected) + editor.logout() + #expect(editor.pending == nil && !editor.isConnected && !editor.isLogoutPending) + try await wait { fixture.node.closed } + #expect(fixture.node.logouts == 1) + #expect(fixture.library.groups == savedGroups) + #expect(try fixture.store.load().isEmpty) + await fixture.model.shutdown() + } + @Test func anOlderEditorCannotCancelAnotherEditorsLogin() async throws { let fixture = try EditorFixture() let first = fixture.editor() @@ -115,6 +133,10 @@ struct TailscaleGroupEditorTests { #expect(fixture.model.runningGroups.isEmpty) let saved = try await editor.save() #expect(saved.id == group.id) + let before = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "exit") + let after = try #require(saved.tailscale).hostID(groupID: saved.id, stableNodeID: "exit") + #expect((before != after) == switchNetwork) + #expect(saved.tailscale?.hostIdentityVersion == .groupScopedV2) #expect(saved.tailscale?.network == fixture.node.network) #expect(saved.tailscale?.username == (switchNetwork ? "" : "operator")) #expect(saved.tailscale?.exitNodeID == (switchNetwork ? nil : "exit")) @@ -224,23 +246,211 @@ struct TailscaleGroupEditorTests { await fixture.model.shutdown() } - @Test func logoutKeepsTheBindingButAllowsDisablingIt() async throws { + @Test func logoutRemainsAnEditorDraftUntilSaveAndCancellationKeepsTheConnection() async throws { let fixture = try EditorFixture() - let editor = fixture.editor() - editor.setEnabled(true) - editor.login(authKey: nil) - try await fixture.ready(editor) - let saved = try await editor.save() + let saved = try await fixture.connect() + let state = try #require(fixture.store.load().first) + let editor = fixture.editor(saved) + editor.logout() + #expect(editor.isLogoutPending && editor.isDirty && editor.canSave) + #expect(!editor.isConnected) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(try fixture.store.load().first?.profileID == state.profileID) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + #expect(!fixture.library.hosts.isEmpty) + editor.cancel() + #expect(!editor.isLogoutPending && editor.isConnected && !editor.isDirty) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(fixture.node.logouts == 0) + await fixture.model.shutdown() + } + + @Test func savingLogoutKeepsTheBindingAndEndsOnlyTheLocalEnrollment() async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let editor = fixture.editor(saved) + editor.logout() + editor.draft.name = "Renamed while logging out" + let updated = try await editor.save() + #expect(updated.name == editor.draft.name) + #expect(updated.tailscale == saved.tailscale) + #expect(fixture.node.logouts == 1 && fixture.node.closed) + #expect(fixture.model.runningGroups.isEmpty) + #expect(fixture.library.hosts.isEmpty) + #expect(try fixture.store.load().isEmpty) + #expect(!editor.isLogoutPending && !editor.isDirty) + await fixture.model.shutdown() + } + + @Test func disablingAfterLogoutRemainsPendingUntilSave() async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let editor = fixture.editor(saved) editor.setEnabled(false) #expect(editor.enabled) - await editor.logout() - #expect(!editor.isConnected) - #expect(fixture.library.group(saved.id)?.tailscale == saved.tailscale) - #expect(try fixture.store.load().isEmpty) + editor.logout() editor.setEnabled(false) #expect(!editor.enabled) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(fixture.node.logouts == 0) + let updated = try await editor.save() + #expect(updated.tailscale == nil) + #expect(!fixture.model.runningGroups.contains(saved.id)) + #expect(fixture.node.logouts == 1) + #expect(try fixture.store.load().isEmpty) + await fixture.model.shutdown() + } + + @Test(arguments: [false, true]) + func cancellingTheWholeEditKeepsSavedSettingsRegardlessOfEnabled(enabled: Bool) async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let profileID = try #require(fixture.store.load().first?.profileID) + let editor = fixture.editor(saved) + let hostname = editor.hostname + let controlURL = editor.controlURL + let exitNodeID = editor.exitNodeID + editor.logout() + editor.hostname = "Replacement device" + editor.controlURL = "https://another.example.com" + editor.exitNodeID = "other-exit" + editor.setEnabled(enabled) + #expect(editor.isLogoutPending) editor.cancel() + let persistedGroup = try #require(fixture.library.group(saved.id)) + let reopened = fixture.editor(persistedGroup) + #expect(reopened.enabled && reopened.isConnected && !reopened.isLogoutPending && !reopened.isDirty) + #expect(reopened.hostname == hostname && reopened.controlURL == controlURL && reopened.exitNodeID == exitNodeID) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(try fixture.store.load().first?.profileID == profileID) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + await fixture.model.shutdown() + } + + @Test(arguments: [false, true]) + func failedLogoutSaveKeepsTheIntentAndTheConnectionForRetry(localFailure: Bool) async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let editor = fixture.editor(saved) + editor.draft.name = "Saved edit" + editor.logout() + fixture.vault.failSave = !localFailure + fixture.store.failRemove = localFailure + await #expect(throws: (any Error).self) { try await editor.save() } + #expect(editor.isLogoutPending && editor.canSave) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + fixture.vault.failSave = false + fixture.store.failRemove = false + let updated = try await editor.save() + #expect(updated.name == "Saved edit") + #expect(!editor.isLogoutPending && !editor.isDirty) + #expect(fixture.model.error == nil) + #expect(fixture.node.logouts == 1) + #expect(try fixture.store.load().isEmpty) + await fixture.model.shutdown() + } + + @Test(arguments: [false, true]) + func replacementLoginKeepsTheOriginalConnectionUntilSave(commit: Bool) async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let originalProfile = try #require(fixture.store.load().first?.profileID) + let replacement = EditorNode( + network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "other.ts.net")) + fixture.nodes.next = replacement + let editor = fixture.editor(saved) + editor.logout() + editor.login(authKey: nil) + try await fixture.ready(editor) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + #expect(try fixture.store.load().first?.profileID == originalProfile) + #expect(fixture.library.group(saved.id)?.tailscale?.network == fixture.node.network) + if commit { + let updated = try await editor.save() + #expect(updated.tailscale?.network == replacement.network) + try await wait { fixture.node.closed } + #expect(fixture.node.logouts == 1) + #expect(!replacement.closed && replacement.logouts == 0) + #expect(try fixture.store.load().first?.profileID != originalProfile) + #expect(!editor.isLogoutPending) + } else { + editor.cancel() + try await wait { replacement.closed } + #expect(fixture.library.group(saved.id)?.tailscale?.network == fixture.node.network) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + #expect(try fixture.store.load().first?.profileID == originalProfile) + } + #expect(fixture.model.runningGroups.contains(saved.id)) + await fixture.model.shutdown() + } + + @Test func repeatedLogoutAndLoginThenDiscardRetainsTheOriginalAndRetiresEveryCandidate() async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let originalState = try #require(fixture.store.load().first) + let originalHosts = fixture.library.hosts.map(\.id) + let editor = fixture.editor(saved) + let first = EditorNode( + network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "first.ts.net")) + let second = EditorNode( + network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "second.ts.net")) + + editor.logout() + fixture.nodes.next = first + editor.login(authKey: nil) + try await fixture.ready(editor) + first.holdLogout = true + editor.logout() + try await wait { first.logoutContinuation != nil } + #expect(editor.isLogoutPending && !editor.isConnected) + + fixture.nodes.next = second + editor.login(authKey: nil) + try await fixture.ready(editor) + #expect(first.logouts == 1 && !first.closed) + #expect(second.logouts == 0 && !second.closed) #expect(fixture.library.group(saved.id)?.tailscale == saved.tailscale) + #expect(try fixture.store.load().first?.profileID == originalState.profileID) + + editor.cancel() + try await wait { second.closed } + first.releaseLogout() + try await wait { first.closed } + try await wait { (try? fixture.store.pendingCleanupProfiles().isEmpty) == true } + #expect(first.logouts == 1 && second.logouts == 1) + #expect(fixture.node.logouts == 0 && !fixture.node.closed) + #expect(fixture.model.pendingLogin == nil) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(try fixture.store.load().first?.profileID == originalState.profileID) + #expect(try fixture.store.pendingCleanupProfiles().isEmpty) + #expect(fixture.library.hosts.map(\.id) == originalHosts) + let persistedGroup = try #require(fixture.library.group(saved.id)) + let reopened = fixture.editor(persistedGroup) + #expect(reopened.isConnected && !reopened.isDirty && !reopened.isLogoutPending) + #expect(reopened.network == fixture.node.network) + #expect(reopened.hostname == originalState.hostname) + await fixture.model.shutdown() + } + + @Test func deferredLogoutCannotEndANewerEnrollmentFromAnotherEditor() async throws { + let fixture = try EditorFixture() + let saved = try await fixture.connect() + let older = fixture.editor(saved) + older.logout() + let newer = fixture.editor(saved) + newer.logout() + let replacement = EditorNode(network: fixture.node.network) + fixture.nodes.next = replacement + newer.login(authKey: nil) + try await fixture.ready(newer) + _ = try await newer.save() + let profile = try #require(fixture.store.load().first?.profileID) + await #expect(throws: TailscaleConfigurationError.self) { try await older.save() } + #expect(try fixture.store.load().first?.profileID == profile) + #expect(fixture.model.runningGroups.contains(saved.id)) + #expect(replacement.logouts == 0 && !replacement.closed) + older.cancel() await fixture.model.shutdown() } @@ -310,6 +520,7 @@ struct TailscaleGroupEditorTests { let vault: EditorVaultStore let store = EditorLocalStore() let node: EditorNode + let nodes: EditorNodes let model: TailscaleModel init(existing: Bool = false, otherNetwork: Bool = false) throws { @@ -324,13 +535,22 @@ struct TailscaleGroupEditorTests { group.tailscale?.hostPreferences[UUID()] = TailscaleHostPreferences() vault = EditorVaultStore(.init(groups: existing ? [group] : [])) library = HostLibraryModel(persistence: vault) - let node = node + nodes = EditorNodes(next: node) + let nodes = nodes model = TailscaleModel( - library: library, store: store, factory: { _, _, _, _ in node }, + library: library, store: store, factory: { _, _, _, _ in await nodes.next }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "default-device" }, hasAccess: { true }) } + func connect() async throws -> HostGroup { + let editor = editor() + editor.setEnabled(true) + editor.login(authKey: nil) + try await ready(editor) + return try await editor.save() + } + func editor(_ group: HostGroup? = nil) -> TailscaleGroupEditor { TailscaleGroupEditor(model: model, group: group) } func ready(_ editor: TailscaleGroupEditor) async throws { for _ in 0..<200 { @@ -344,6 +564,11 @@ struct TailscaleGroupEditorTests { } } +@MainActor private final class EditorNodes { + var next: EditorNode + init(next: EditorNode) { self.next = next } +} + @MainActor private final class EditorNode: TailscaleNodeSession { let network: TailscaleNetwork var networkName = "Test Tailnet" @@ -351,12 +576,14 @@ struct TailscaleGroupEditorTests { var exitNode: String? var closed = false var logouts = 0 + var holdLogout = false + var logoutContinuation: CheckedContinuation? var holdStatus = false var statusContinuation: CheckedContinuation? var peers: [TailscalePeer] { [ TailscalePeer( - stableID: "exit", name: "Exit", dnsName: "exit.test.ts.net", online: true, + stableID: "exit", name: "Exit", dnsName: "exit.\(network.dnsSuffix)", online: true, sshHostKeys: ["fixture"], exitNodeOption: true) ] } @@ -366,7 +593,7 @@ struct TailscaleGroupEditorTests { return .init( running: true, network: network, authURL: nil, peers: peers, device: .init( - networkName: networkName, dnsName: "device.test.ts.net", hostname: hostname, + networkName: networkName, dnsName: "device.\(network.dnsSuffix)", hostname: hostname, addresses: ["100.64.0.1"], operatingSystem: "macOS", keyExpiry: nil)) } @MainActor func releaseStatus() { @@ -375,7 +602,15 @@ struct TailscaleGroupEditorTests { statusContinuation = nil } @MainActor func login() async throws {} - @MainActor func logout() async throws { logouts += 1 } + @MainActor func logout() async throws { + logouts += 1 + if holdLogout { await withCheckedContinuation { logoutContinuation = $0 } } + } + @MainActor func releaseLogout() { + holdLogout = false + logoutContinuation?.resume() + logoutContinuation = nil + } @MainActor func close() async { closed = true } @MainActor func setHostname(_ value: String) async throws { hostname = value } @MainActor func setExitNode(_ value: String?) async throws { exitNode = value } @@ -386,12 +621,16 @@ struct TailscaleGroupEditorTests { @MainActor private final class EditorLocalStore: TailscaleLocalStoring { let base = InMemoryTailscaleLocalStore() var failSave = false + var failRemove = false func load() throws -> [TailscaleLocalState] { try base.load() } func save(_ state: TailscaleLocalState) throws { if failSave { throw CocoaError(.fileWriteUnknown) } try base.save(state) } - func remove(groupID: UUID) throws { try base.remove(groupID: groupID) } + 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 { try base.completeCleanup(profileID: profileID) } diff --git a/FrameworkTests/TailscaleHostIdentityTests.swift b/FrameworkTests/TailscaleHostIdentityTests.swift new file mode 100644 index 00000000..dab7f76d --- /dev/null +++ b/FrameworkTests/TailscaleHostIdentityTests.swift @@ -0,0 +1,75 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct TailscaleHostIdentityTests { + private let groupID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")! + + // Independently generated with Python hashlib.sha1 and uuid.NAMESPACE_URL. + @Test(arguments: [ + ("example.ts.net", "n123", "75e53f16-003f-5fa1-87f3-663b8726f413"), + ("other.ts.net", "n123", "e3c78211-3fa4-546b-a5b7-4a429a7972cc"), + ("example.ts.net", "节点:é🙂", "8f19a534-101c-5a8a-a22e-ef7c969e4ebf"), + ("a", "b:c", "9fb0d403-eb25-5326-a72f-b5522674c7a9"), + ("a:b", "c", "571f7dca-a7c4-5ff8-a3d9-7e29b11f6072"), + ]) + func uuidUsesLengthDelimitedUTF8(suffix: String, nodeID: String, expected: String) throws { + let configuration = try configuration(suffix) + #expect(configuration.hostID(groupID: groupID, stableNodeID: nodeID) == UUID(uuidString: expected)) + } + + @Test func identityUsesGroupTailnetAndNodeButNotControlURL() throws { + let original = try configuration("example.ts.net") + let id = original.hostID(groupID: groupID, stableNodeID: "n123") + let canonical = try configuration("EXAMPLE.TS.NET.", controlURL: "https://LOGIN.TAILSCALE.COM:443/") + let otherControl = try configuration("example.ts.net", controlURL: "https://headscale.example.com") + #expect(canonical.hostID(groupID: groupID, stableNodeID: "n123") == id) + #expect(otherControl.hostID(groupID: groupID, stableNodeID: "n123") == id) + #expect(original.hostID(groupID: UUID(), stableNodeID: "n123") != id) + #expect(original.hostID(groupID: groupID, stableNodeID: "n456") != id) + #expect(try configuration("other.ts.net").hostID(groupID: groupID, stableNodeID: "n123") != id) + } + + @Test func switchingTailnetsDoesNotAttachHistoryToAnotherNetwork() throws { + var group = HostGroup(id: groupID, name: "Servers") + let firstNetwork = try configuration("first.ts.net") + group.tailscale = firstNetwork + let library = HostLibraryModel(groups: [group]) + var peer = TailscalePeer( + stableID: "n123", name: "First", dnsName: "first.first.ts.net", online: true, sshHostKeys: ["fixture"]) + library.replaceTailscalePeers([peer], groupID: groupID) + let firstHost = try #require(library.hosts.first) + let history = ConnectionHistoryModel() + let event = try #require(history.record(kind: .ssh, for: firstHost.id)) + + group.name = "Renamed group" + library.upsert(group) + peer.name = "Renamed node" + peer.dnsName = "renamed.first.ts.net" + library.replaceTailscalePeers([peer], groupID: groupID) + #expect(library.host(firstHost.id)?.name == peer.name) + #expect(history.recentLocalHosts(in: library, limit: 10).first?.entry.id == event.id) + + group.tailscale = try configuration("second.ts.net") + library.upsert(group) + #expect(library.hosts.isEmpty) + library.replaceTailscalePeers([peer], groupID: groupID) + #expect(library.hosts.first?.id != firstHost.id) + #expect(history.entries().map(\.id) == [event.id]) + #expect(history.recentLocalHosts(in: library, limit: 10).isEmpty) + + group.tailscale = firstNetwork + library.upsert(group) + library.replaceTailscalePeers([peer], groupID: groupID) + #expect(library.hosts.first?.id == firstHost.id) + #expect(history.recentLocalHosts(in: library, limit: 10).first?.entry.id == event.id) + } + + private func configuration( + _ suffix: String, controlURL: String = TailscaleNetwork.defaultControlURL + ) throws -> TailscaleGroupConfiguration { + TailscaleGroupConfiguration(network: try TailscaleNetwork(controlURL: controlURL, dnsSuffix: suffix)) + } +} diff --git a/FrameworkTests/TailscaleIntegrationTests.swift b/FrameworkTests/TailscaleIntegrationTests.swift index 1e3597f6..09dd91cd 100644 --- a/FrameworkTests/TailscaleIntegrationTests.swift +++ b/FrameworkTests/TailscaleIntegrationTests.swift @@ -73,15 +73,13 @@ struct TailscaleIntegrationTests { } } - @Test func existingTailnetStoreMigratesWithoutLosingEnrollmentOrPreferences() throws { + @Test func tailnetStoreReopensWithoutLosingEnrollmentOrPreferences() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("Termind.store") var group = try group("example.ts.net") - let network = try #require(group.tailscale?.network) - group.tailscale = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy) - let hostID = network.hostID(stableNodeID: "n123") + let hostID = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "n123") var preferences = TailscaleHostPreferences() preferences.username = "deploy" preferences.tags = ["production"] @@ -109,23 +107,10 @@ struct TailscaleIntegrationTests { } private func writeTailnetStore(_ url: URL, group: HostGroup, profileID: UUID, peer: TailscalePeer) throws { - let schema = Schema(versionedSchema: TermindSchemaV47.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( - TermindSchemaV47TailscaleModels.PersistentTailscaleLocalState( - groupID: group.id, profileID: profileID, peersData: try JSONEncoder().encode([peer]))) - try container.mainContext.save() + let container = try TermindPersistence.makeContainer(url: url) + _ = try HostPersistence(context: container.mainContext).save(.init(groups: [group])) + try SwiftDataTailscaleLocalStore(context: container.mainContext).save( + TailscaleLocalState(groupID: group.id, profileID: profileID, peers: [peer])) } private func writePreviousStore(_ url: URL, group: HostGroup, host: Host) throws { @@ -142,21 +127,16 @@ struct TailscaleIntegrationTests { private func group(_ suffix: String, parentID: UUID? = nil) throws -> HostGroup { let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix) - var group = HostGroup(id: network.groupID, name: suffix, parentID: parentID) + var group = HostGroup(name: suffix, parentID: parentID) group.tailscale = TailscaleGroupConfiguration(network: network) group.tailscale?.username = "operator" return group } - @Test func canonicalControlIdentitySurvivesNodeAndGroupRenames() throws { + @Test func controlURLsRemainCanonicalConnectionConfiguration() throws { let first = try TailscaleNetwork(controlURL: "https://LOGIN.TAILSCALE.COM:443/", dnsSuffix: "Example.TS.NET.") let second = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net") #expect(first == second) - #expect(first.hostID(stableNodeID: "n123") == second.hostID(stableNodeID: "n123")) - let otherTailnet = try TailscaleNetwork(controlURL: first.controlURL, dnsSuffix: "other.ts.net") - #expect(first.hostID(stableNodeID: "n123") == otherTailnet.hostID(stableNodeID: "n123")) - let headscale = try TailscaleNetwork(controlURL: "https://headscale.example.com", dnsSuffix: first.dnsSuffix) - #expect(first.hostID(stableNodeID: "n123") != headscale.hostID(stableNodeID: "n123")) #expect(throws: TailscaleConfigurationError.self) { try TailscaleNetwork(controlURL: "https://user:secret@example.com", dnsSuffix: "example.com") } @@ -482,7 +462,8 @@ struct TailscaleIntegrationTests { makeSampler: { TailscaleTestSampler() }) metrics.start() defer { metrics.shutdown() } - #expect(metrics.servers.first?.name == "Offline node") + #expect(metrics.servers.isEmpty) + #expect(metrics.selections == [selection]) #expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: [])) #expect(preferences.values == [selection]) metrics.removeTailscaleGroups([group.id]) diff --git a/FrameworkTests/TailscaleMetricsAvailabilityTests.swift b/FrameworkTests/TailscaleMetricsAvailabilityTests.swift new file mode 100644 index 00000000..3c317113 --- /dev/null +++ b/FrameworkTests/TailscaleMetricsAvailabilityTests.swift @@ -0,0 +1,117 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct TailscaleMetricsAvailabilityTests { + @Test(arguments: [false, true]) + func missingPeerHidesCardWithoutChangingConfigurationAndReturnsInOrder(enabled: Bool) async throws { + let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net") + var group = HostGroup(name: "Tailnet") + group.tailscale = TailscaleGroupConfiguration(network: network) + group.tailscale?.username = "operator" + let hostID = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "n123") + let other = Host(name: "Other", host: "other.invalid") + let library = HostLibraryModel(hosts: [other], groups: [group]) + let provider = AvailabilityProvider() + library.tailscaleConnections = provider + var selection = MetricsSelection(id: hostID, enabled: enabled, disks: ["sda"], interfaces: []) + selection.tailscaleGroupID = group.id + selection.displayName = "Saved name" + let otherSelection = MetricsSelection(id: other.id, enabled: false) + let preferences = AvailabilityPreferences([selection, otherSelection]) + let sampler = AvailabilitySampler() + let model = ServerMetricsModel(hosts: library, preferences: preferences, makeSampler: { sampler }) + model.start() + defer { model.shutdown() } + #expect(model.servers.map(\.id) == [other.id]) + #expect(model.selections == [selection, otherSelection]) + #expect(await sampler.connections == 0) + + var peer = TailscalePeer( + stableID: "n123", name: "Server", dnsName: "server.example.ts.net", online: true, + sshHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]) + library.replaceTailscalePeers([peer], groupID: group.id) + model.reconcile() + #expect(model.servers.map(\.id) == [hostID, other.id]) + if enabled { + try await settle { model.snapshot(for: hostID)?.state == .live } + #expect(await sampler.connections == 1) + } else { + #expect(model.snapshot(for: hostID)?.state == .paused) + } + + library.replaceTailscalePeers([], groupID: group.id) + model.reconcile() + #expect(model.servers.map(\.id) == [other.id]) + #expect(model.snapshot(for: hostID) == nil) + #expect(!model.keepsRunning) + #expect(preferences.values == [selection, otherSelection]) + #expect(preferences.saves == 0) + if enabled { try await settle { await sampler.disconnections > 0 } } + #expect(model.replaceHosts(selectedIDs: [other.id], editableHostIDs: [other.id])) + #expect(model.selections == [selection, otherSelection]) + + peer.name = "Renamed server" + peer.online = false + library.replaceTailscalePeers([peer], groupID: group.id) + model.reconcile() + #expect(model.servers.map(\.id) == [hostID, other.id]) + #expect(model.servers.first?.name == peer.name) + #expect(model.servers.first?.selection == selection) + #expect(preferences.saves == 0) + if enabled { + try await settle { model.snapshot(for: hostID)?.state == .live } + #expect(await sampler.connections == 2) + } else { + #expect(model.snapshot(for: hostID)?.state == .paused) + #expect(await sampler.connections == 0) + } + + library.replaceTailscalePeers([], groupID: group.id) + model.reconcile() + model.removeTailscaleGroups([group.id]) + #expect(model.selections == [otherSelection]) + #expect(preferences.values == [otherSelection]) + } + + private func settle(_ condition: () async -> Bool) async throws { + for _ in 0..<200 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + #expect(await condition()) + } +} + +@MainActor private final class AvailabilityPreferences: MetricsConfigurationStoring { + var values: [MetricsSelection] + var saves = 0 + init(_ values: [MetricsSelection]) { self.values = values } + func load() throws -> [MetricsSelection] { values } + func save(_ values: [MetricsSelection]) throws { + self.values = values + saves += 1 + } +} + +private actor AvailabilitySampler: MetricsSampling { + var connections = 0 + var disconnections = 0 + func connect(_ configuration: SSHConnectionConfiguration) async throws { connections += 1 } + func sample() async throws -> MetricsSample { MetricsSample(uptime: 1, cpu: [1, 1, 1, 1], coreCount: 1) } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async { disconnections += 1 } +} + +@MainActor private final class AvailabilityProvider: TailscaleConnectionProviding { + func connection(for groupID: UUID) throws -> any SSHNetworkDialing { AvailabilityDialer() } +} + +private nonisolated struct AvailabilityDialer: 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/TailscaleMultiConnectionTests.swift b/FrameworkTests/TailscaleMultiConnectionTests.swift index d4c4e5a4..bb5398f5 100644 --- a/FrameworkTests/TailscaleMultiConnectionTests.swift +++ b/FrameworkTests/TailscaleMultiConnectionTests.swift @@ -11,42 +11,12 @@ struct TailscaleMultiConnectionTests { #"{"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 { + @Test func sameNodeInTwoGroupsKeepsPreferencesAndRoutingIndependent() 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) + first.tailscale = TailscaleGroupConfiguration(network: network) var second = HostGroup(name: "Second", parentID: first.id) second.tailscale = TailscaleGroupConfiguration(network: network) for group in [first, second] { @@ -79,7 +49,6 @@ struct TailscaleMultiConnectionTests { .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) @@ -107,12 +76,15 @@ struct TailscaleMultiConnectionTests { #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 - ) + #expect(throws: DecodingError.self) { try VaultSyncCodec.decode(Self.legacyGroup) } + + let ordinary = VaultRecordPayload.hostGroup(HostGroup(name: "Ordinary")) + let oldEnvelope = VaultEnvelope( + key: ordinary.key, version: .baseline(writerID: UUID()), payload: ordinary, formatVersion: 2) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(oldEnvelope)) == oldEnvelope) } - @Test(arguments: ["missing", "null", "unknown"]) + @Test(arguments: ["missing", "null", "legacy", "groupScoped", "future"]) func v3RejectsInvalidIdentityMarkers(marker: String) throws { var value = try #require(JSONSerialization.jsonObject(with: Self.legacyGroup) as? [String: Any]) value["formatVersion"] = 3 @@ -121,7 +93,11 @@ struct TailscaleMultiConnectionTests { 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" } + if !["missing", "null"].contains(marker) { configuration["hostIdentityVersion"] = marker } + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + TailscaleGroupConfiguration.self, from: JSONSerialization.data(withJSONObject: configuration)) + } group["tailscale"] = configuration groupCase["_0"] = group payload["hostGroup"] = groupCase diff --git a/FrameworkTests/TailscaleProfileCleanupTests.swift b/FrameworkTests/TailscaleProfileCleanupTests.swift index da34b232..d76577d9 100644 --- a/FrameworkTests/TailscaleProfileCleanupTests.swift +++ b/FrameworkTests/TailscaleProfileCleanupTests.swift @@ -317,16 +317,14 @@ struct TailscaleProfileCleanupTests { await model.shutdown() } - @Test func oldStoreMigratesAndOrphanInventoryNeverErasesAnActiveProfile() async throws { + @Test func reopenedStoreNeverErasesAnActiveProfileFromOrphanInventory() 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 group = try group("example.ts.net") let active = UUID() let orphan = UUID() - try writeVersion49(url, group: group, profileID: active) + try writeStore(url, group: group, profileID: active) for _ in 0..<2 { let container = try TermindPersistence.makeContainer(url: url) let store = SwiftDataTailscaleLocalStore(context: container.mainContext) @@ -358,26 +356,16 @@ struct TailscaleProfileCleanupTests { #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 writeStore(_ url: URL, group: HostGroup, profileID: UUID) throws { + let container = try TermindPersistence.makeContainer(url: url) + _ = try HostPersistence(context: container.mainContext).save(.init(groups: [group])) + try SwiftDataTailscaleLocalStore(context: container.mainContext).save( + TailscaleLocalState(groupID: group.id, profileID: profileID, hostname: "saved")) } private func group(_ suffix: String) throws -> HostGroup { let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix) - var group = HostGroup(id: network.groupID, name: suffix) + var group = HostGroup(name: suffix) group.tailscale = TailscaleGroupConfiguration(network: network) return group } diff --git a/FrameworkTests/TailscaleSyncSafetyTests.swift b/FrameworkTests/TailscaleSyncSafetyTests.swift index 18f7ee32..2cc58c2b 100644 --- a/FrameworkTests/TailscaleSyncSafetyTests.swift +++ b/FrameworkTests/TailscaleSyncSafetyTests.swift @@ -10,17 +10,14 @@ import Testing @Suite @MainActor struct TailscaleSyncSafetyTests { - @Test(arguments: [TailscaleGroupConfiguration.HostIdentityVersion.legacy, .groupScoped]) - func v3UpgradeRefetchesAndAcceptsIndependentConnectionsToTheSameTailnet( - firstIdentity: TailscaleGroupConfiguration.HostIdentityVersion - ) async throws { + @Test func v3ReplayAcceptsIndependentConnectionsToTheSameTailnet() 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) + first.tailscale = TailscaleGroupConfiguration(network: network) _ = try persistence.save(.init(groups: [first])) var second = HostGroup(name: "Second") second.tailscale = TailscaleGroupConfiguration(network: network) @@ -52,62 +49,6 @@ struct TailscaleSyncSafetyTests { #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() } @@ -138,82 +79,6 @@ struct TailscaleSyncSafetyTests { == 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) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index e50c290f..64752260 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -30768,23 +30768,6 @@ } } }, - "This Tailnet already belongs to another group." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此Tailnet已属于其他分组。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "此Tailnet已屬於其他群組。" - } - } - } - }, "This Tailscale connection is unavailable. Check the Tailnet login, SSH policy, and username." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 51eaa3f1..d8725ca3 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -1734,12 +1734,6 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// rather than at the end of the flat array — `sortIndex` is per-parent, so /// 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.map(configuration.conflicts) == true }) - { - storageError = TailscaleConfigurationError.duplicateNetwork.localizedDescription - return - } let invalidated: Set = groups.contains { $0.id == group.id && $0.tailscale != nil && $0.tailscale?.network != group.tailscale?.network diff --git a/Frameworks/TermindApplication/FeatureModels/TailscaleGroupEditor.swift b/Frameworks/TermindApplication/FeatureModels/TailscaleGroupEditor.swift index 6dcca68c..9c0c4af8 100644 --- a/Frameworks/TermindApplication/FeatureModels/TailscaleGroupEditor.swift +++ b/Frameworks/TermindApplication/FeatureModels/TailscaleGroupEditor.swift @@ -7,7 +7,8 @@ import TermindDomain public final class TailscaleGroupEditor { public let model: TailscaleModel private let initialDraft: HostGroup - private let initialHostname: String + private var initialHostname: String + private var logoutProfileID: UUID? public var draft: HostGroup public private(set) var original: HostGroup? public private(set) var enabled: Bool @@ -16,7 +17,6 @@ public final class TailscaleGroupEditor { public var controlURL: String public private(set) var operationID: UUID? public private(set) var isSaving = false - public private(set) var isLoggingOut = false public init(model: TailscaleModel, group: HostGroup?, parentID: UUID? = nil) { self.model = model @@ -37,7 +37,9 @@ public final class TailscaleGroupEditor { return model.pendingLogin } public var isLoggingIn: Bool { operationID != nil && model.loginOperationID == operationID && model.isLoggingIn } - public var isConnected: Bool { pending != nil || model.runningGroups.contains(draft.id) } + public var isLogoutPending: Bool { logoutProfileID != nil } + public var canChangeEnabled: Bool { isLogoutPending || !model.runningGroups.contains(draft.id) } + public var isConnected: Bool { pending != nil || (!isLogoutPending && model.runningGroups.contains(draft.id)) } public var device: TailscaleDeviceInfo? { pending?.device ?? model.devices[draft.id] } public var network: TailscaleNetwork? { pending?.network ?? model.group(draft.id)?.tailscale?.network } public var candidates: [TailscalePeer] { @@ -57,19 +59,19 @@ public final class TailscaleGroupEditor { } public var canSave: Bool { !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoggingIn && !isSaving - && !isLoggingOut && !requiresLogin + && !requiresLogin } public var isDirty: Bool { draft != (original ?? initialDraft) - || enabled != (original?.tailscale != nil) || operationID != nil + || enabled != (original?.tailscale != nil) || operationID != nil || isLogoutPending || hostname != (model.savedHostname(groupID: draft.id) ?? "") || exitNodeID != (original?.tailscale?.exitNodeID ?? "") || controlURL != (original?.tailscale?.network.controlURL ?? TailscaleNetwork.defaultControlURL) } public func setEnabled(_ value: Bool) { - guard !isSaving, !isLoggingOut, !model.runningGroups.contains(draft.id) else { return } - if !value { cancel() } + guard !isSaving, canChangeEnabled else { return } + if !value { cancelLogin() } enabled = value } @@ -88,14 +90,21 @@ public final class TailscaleGroupEditor { exitNodeID = pending.exitNodeID } - public func logout() async { - guard !isSaving, !isLoggingOut else { return } - isLoggingOut = true - defer { isLoggingOut = false } - if pending != nil { cancel() } else { await model.logout(groupID: draft.id) } + public func logout() { + guard !isSaving else { return } + if pending != nil { + cancelLogin() + } else { + logoutProfileID = model.profileID(groupID: draft.id) + } } public func cancel() { + cancelLogin() + logoutProfileID = nil + } + + private func cancelLogin() { if let operationID { model.cancelLogin(operationID: operationID) } operationID = nil } @@ -104,6 +113,11 @@ public final class TailscaleGroupEditor { guard canSave else { throw TailscaleConfigurationError.unavailable } isSaving = true defer { isSaving = false } + if let logoutProfileID, let currentProfileID = model.profileID(groupID: draft.id), + currentProfileID != logoutProfileID + { + throw TailscaleConfigurationError.unavailable + } let saved: HostGroup if let pending { do { @@ -123,17 +137,28 @@ public final class TailscaleGroupEditor { guard model.group(draft.id)?.tailscale?.network == original?.tailscale?.network else { throw TailscaleConfigurationError.unavailable } - if enabled, model.runningGroups.contains(draft.id) { + if enabled, !isLogoutPending, model.runningGroups.contains(draft.id) { try await model.saveHostname(hostname, groupID: draft.id) } saved = try model.saveGroup( draft, original: original, enabled: enabled, exitNodeID: enabled && isConnected && exitNodeID != (original?.tailscale?.exitNodeID ?? "") ? exitNodeID : nil) + if let logoutProfileID { + // A committed group edit must remain the baseline if local logout needs a retry. + original = saved + draft = saved + try await model.logout(groupID: saved.id, profileID: logoutProfileID) + } } + logoutProfileID = nil let latest = model.group(saved.id) ?? saved original = latest draft = latest + hostname = model.savedHostname(groupID: saved.id) ?? "" + initialHostname = hostname + controlURL = latest.tailscale?.network.controlURL ?? TailscaleNetwork.defaultControlURL + exitNodeID = latest.tailscale?.exitNodeID ?? "" return latest } } diff --git a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift index 08f90516..9f64d4bd 100644 --- a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift @@ -145,6 +145,8 @@ public final class TailscaleModel: TailscaleConnectionProviding { public func savedHostname(groupID: UUID) -> String? { states[groupID]?.hostname } + func profileID(groupID: UUID) -> UUID? { states[groupID]?.profileID } + public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" } public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] { @@ -543,6 +545,21 @@ public final class TailscaleModel: TailscaleConnectionProviding { if let profileID { await cleanupTasks[profileID]?.value } } + /// A deferred logout belongs to the enrollment selected by that editor. + func logout(groupID: UUID, profileID: UUID) async throws { + if let current = states[groupID]?.profileID { + guard current == profileID else { throw TailscaleConfigurationError.unavailable } + error = nil + await logout(groupID: groupID) + guard states[groupID]?.profileID != profileID else { + throw TermindSSHError.library(error ?? TailscaleConfigurationError.unavailable.localizedDescription) + } + } else { + // Disabling the binding can already have queued this profile's cleanup. + 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], diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 2b45a80c..fe1c788e 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -514,9 +514,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading { var nextServers: [ServerMetric] = [] for selection in normalized { - let host = hosts.host(selection.id) - if host == nil && selection.tailscaleGroupID == nil { continue } - let name = host?.name ?? selection.displayName ?? selection.id.uuidString + guard let host = hosts.host(selection.id) else { continue } + let name = host.name if let server = previous[selection.id] { let enabledChanged = server.selection.enabled != selection.enabled let devicesChanged = diff --git a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift index d8dd0f9c..9ecb112c 100644 --- a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift +++ b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift @@ -36,34 +36,33 @@ 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 { - Self.identifier(["host", controlURL, stableNodeID]) - } - /// 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. + /// The algorithm is a storage contract; peer names and IPs never identify nodes. 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, ] - let name = "termind.tailscale.v1:" + components.map { "\($0.utf8.count):\($0)" }.joined() - var bytes = Array(Insecure.SHA1.hash(data: Data(namespace) + Data(name.utf8)).prefix(16)) - bytes[6] = (bytes[6] & 0x0f) | 0x50 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - return UUID( - uuid: ( - bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], - bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] - )) + let prefix = "termind.tailscale.v1:" + // Each length needs at most 19 decimal digits plus its separator on supported 64-bit platforms. + let capacity = components.reduce(namespace.count + prefix.utf8.count) { $0 + $1.utf8.count + 20 } + var data = Data(capacity: capacity) + data.append(contentsOf: namespace) + data.append(contentsOf: prefix.utf8) + for component in components { + data.append(contentsOf: String(component.utf8.count).utf8) + data.append(0x3a) + data.append(contentsOf: component.utf8) + } + var bytes = Insecure.SHA1.hash(data: data).withUnsafeBytes { $0.load(as: uuid_t.self) } + bytes.6 = (bytes.6 & 0x0f) | 0x50 + bytes.8 = (bytes.8 & 0x3f) | 0x80 + return UUID(uuid: bytes) } } public nonisolated enum TailscaleConfigurationError: LocalizedError, Sendable { - case invalidControlURL, missingNetworkIdentity, duplicateNetwork, managedHost, unavailable, requiresPro, + case invalidControlURL, missingNetworkIdentity, managedHost, unavailable, requiresPro, hostKeyMismatch, loginRequired, peerSSHUnavailable, approvalTimedOut, settingsPending public var errorDescription: String? { switch self { @@ -76,7 +75,6 @@ public nonisolated enum TailscaleConfigurationError: LocalizedError, Sendable { case .approvalTimedOut: String(localized: "Tailscale SSH approval timed out. Connect again to request approval.") case .invalidControlURL: String(localized: "Enter a valid HTTPS Controlplane URL.") - case .duplicateNetwork: String(localized: "This Tailnet already belongs to another group.") case .requiresPro: String(localized: "Tailscale requires Termind Pro.") case .hostKeyMismatch: String(localized: "The SSH host key does not match the keys provided by Tailscale.") @@ -123,7 +121,7 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendable { public enum HostIdentityVersion: String, Codable, Sendable { - case legacy, groupScoped + case groupScopedV2 } public var network: TailscaleNetwork @@ -131,20 +129,13 @@ public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendab public var username = "" public var exitNodeID: String? public var hostPreferences: [UUID: TailscaleHostPreferences] = [:] - public init(network: TailscaleNetwork, hostIdentityVersion: HostIdentityVersion = .groupScoped) { + public init(network: TailscaleNetwork, hostIdentityVersion: HostIdentityVersion = .groupScopedV2) { 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 + TailscaleNetwork.identifier(["groupHostV2", groupID.uuidString.lowercased(), network.dnsSuffix, stableNodeID]) } private enum CodingKeys: String, CodingKey { @@ -153,8 +144,7 @@ public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendab 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 + hostIdentityVersion = try values.decode(HostIdentityVersion.self, forKey: .hostIdentityVersion) 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) diff --git a/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift b/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift index 07689852..e5680ff1 100644 --- a/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift +++ b/Frameworks/TermindDomain/Vault/CloudSyncSafety.swift @@ -29,7 +29,6 @@ public nonisolated enum CloudSyncQuarantineReason: String, Codable, Sendable { case unsupportedFormat case invalidRecord case unreadablePayload - case duplicateTailscaleNetwork } public nonisolated enum CloudSyncCompatibilityError: LocalizedError { diff --git a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift index a94b5396..1dd77b9a 100644 --- a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift @@ -68,41 +68,18 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec public func start() { lifecycle.start() } public func pause() { lifecycle.pause() } - public func retry() { - refreshConflictingTailnets() - lifecycle.retry() - } + public func retry() { 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( + let 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, @@ -211,7 +188,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) - var decoded: [(record: CKRecord, envelope: VaultEnvelope)] = try decodeCloudSyncRecords( + let decoded: [(record: CKRecord, envelope: VaultEnvelope)] = try decodeCloudSyncRecords( records.filter { !deletedNames.contains($0.recordID.recordName) }, included: { $0.recordID.zoneID == zoneID && (isConflict || $0.recordType == Self.recordType) @@ -220,24 +197,6 @@ 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: { @@ -245,12 +204,6 @@ 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/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index 63309bcd..e5de1720 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -67,52 +67,6 @@ 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 { @@ -188,10 +142,6 @@ public final class HostPersistence { guard snapshot.hosts.allSatisfy({ !$0.isTailscaleManaged }) else { throw TailscaleConfigurationError.managedHost } - 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( try context.fetch(FetchDescriptor()), with: snapshot.hosts, diff --git a/Frameworks/TermindPersistence/VaultWireV2.swift b/Frameworks/TermindPersistence/VaultWireV2.swift index 4b48625b..d71cd7d6 100644 --- a/Frameworks/TermindPersistence/VaultWireV2.swift +++ b/Frameworks/TermindPersistence/VaultWireV2.swift @@ -163,68 +163,28 @@ 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 - } - } - + /// Ordinary V2 groups remain readable; experimental Tailscale identities are unsupported. struct HostGroup: Codable { var base: VaultWireV1.HostGroup - var tailscale: TailscaleConfiguration? enum CodingKeys: String, CodingKey { case tailscale } init(_ value: TermindDomain.HostGroup) throws { + guard value.tailscale == nil else { throw CocoaError(.coderInvalidValue) } base = VaultWireV1.HostGroup(value) - 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(TailscaleConfiguration?.self, forKey: .tailscale) + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decodeNil(forKey: .tailscale) else { + throw DecodingError.dataCorruptedError( + forKey: .tailscale, in: container, debugDescription: "Unsupported Tailscale identity version") + } } func encode(to encoder: any Encoder) throws { try base.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(tailscale, forKey: .tailscale) - } - var domainValue: TermindDomain.HostGroup { - var value = base.domainValue - value.tailscale = tailscale?.domainValue - return value + try container.encodeNil(forKey: .tailscale) } + var domainValue: TermindDomain.HostGroup { base.domainValue } } enum Payload: Codable { case host(Host) diff --git a/Frameworks/TermindPersistence/VaultWireV3.swift b/Frameworks/TermindPersistence/VaultWireV3.swift index a994bed6..7b528ec5 100644 --- a/Frameworks/TermindPersistence/VaultWireV3.swift +++ b/Frameworks/TermindPersistence/VaultWireV3.swift @@ -8,7 +8,6 @@ nonisolated enum VaultWireV3 { var tailscale: TailscaleGroupConfiguration? enum CodingKeys: String, CodingKey { case tailscale } - enum TailscaleKeys: String, CodingKey { case hostIdentityVersion } init(_ value: TermindDomain.HostGroup) { base = VaultWireV1.HostGroup(value) @@ -19,13 +18,6 @@ nonisolated enum VaultWireV3 { 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 { @@ -175,45 +167,6 @@ nonisolated enum VaultWireV3 { 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 - } - } - enum Payload: Codable { case sshKey(SSHKeyRef) case hostGroup(HostGroup) diff --git a/Frameworks/TermindPresentation/Strings/Tailscale.swift b/Frameworks/TermindPresentation/Strings/Tailscale.swift index ec63dc27..a093a58d 100644 --- a/Frameworks/TermindPresentation/Strings/Tailscale.swift +++ b/Frameworks/TermindPresentation/Strings/Tailscale.swift @@ -43,7 +43,6 @@ extension AppStrings { public static var unavailable: LocalizedStringResource { "This Tailscale connection is unavailable. Check the Tailnet login, SSH policy, and username." } - public static var duplicate: LocalizedStringResource { "This Tailnet already belongs to another group." } public static var invalidURL: LocalizedStringResource { "Enter a valid HTTPS Controlplane URL." } public static var partialSave: LocalizedStringResource { "The group was saved, but this device could not finish signing in. Save again to retry." diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift index b21f856e..fa346c75 100644 --- a/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift @@ -36,9 +36,6 @@ public struct TailscaleDeviceFields: View { } LabeledContent(AppStrings.Tailscale.operatingSystem.swiftUIKey, value: device.operatingSystem) } - Button(AppStrings.Tailscale.logout.swiftUIKey, role: .destructive) { - Task { await editor.logout() } - }.disabled(editor.isLoggingOut) } } diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleGroupFields.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleGroupFields.swift index 7ded13d2..12ccf681 100644 --- a/Frameworks/TermindPresentation/Tailscale/TailscaleGroupFields.swift +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleGroupFields.swift @@ -17,7 +17,7 @@ public struct TailscaleGroupFields: View { public var body: some View { Section(AppStrings.Tailscale.title.swiftUIKey) { - if !editor.model.runningGroups.contains(editor.draft.id) { + if editor.canChangeEnabled { Toggle( AppStrings.Common.enabled.swiftUIKey, isOn: Binding( @@ -36,9 +36,12 @@ public struct TailscaleGroupFields: View { TailscaleLoginFields(editor: editor, requireAccess: requireAccess) } } + if editor.enabled && editor.isConnected { + Button(AppStrings.Tailscale.logout.swiftUIKey, role: .destructive) { editor.logout() } + } if let error = editor.model.error { Text(error).foregroundStyle(.red).font(.caption) } } - .disabled(editor.isSaving || editor.isLoggingOut) + .disabled(editor.isSaving) .onChange(of: editor.pending?.id) { _, _ in editor.receiveLogin() } } } diff --git a/TFMTests/TailscaleMetricsDeletionTests.swift b/TFMTests/TailscaleMetricsDeletionTests.swift index bc2f402a..20bd2108 100644 --- a/TFMTests/TailscaleMetricsDeletionTests.swift +++ b/TFMTests/TailscaleMetricsDeletionTests.swift @@ -31,7 +31,8 @@ struct TailscaleMetricsDeletionTests { let child = HostGroup(name: "Child", parentID: group.id) let manual = Host(name: "Manual", host: "server", groupID: child.id) let library = HostLibraryModel(hosts: [manual], groups: [group, child]) - var unseen = MetricsSelection(id: network.hostID(stableNodeID: "n123"), enabled: false) + var unseen = MetricsSelection( + id: try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "n123"), enabled: false) unseen.tailscaleGroupID = group.id unseen.displayName = "Unseen" let preferences = DeletionMetricsPreferences([unseen, MetricsSelection(id: manual.id, enabled: false)])