fix(tailscale)!: scope host identities and defer saved logout

Derive groupScopedV2 host UUIDs from the group, MagicDNS suffix and stable
node ID. Remove experimental identity compatibility and Tailnet conflict
handling, and hide unavailable Metrics hosts while retaining configuration.

Keep saved enrollments active until Save. Retire unsaved login candidates
on Logout or Discard, including repeated replacement logins, while preserving
the original enrollment. Use the same behavior in New and Edit forms.

Validation: 94 focused tests, iOS Simulator build, Swift formatting and
AppStrings/Chinese localization checks passed. The default suite still has
9 failures in untouched notification, agent and key export tests.

BREAKING CHANGE: Tailscale only accepts groupScopedV2 identities. Remove
old Tailscale test data before using this build; no ID migration is provided.
This commit is contained in:
Kaede Akino committed 2026-10-03 16:58:40 +08:00
1 parent 11d3f471e1
commit 996ea85196
22 files changed
+579 -515

No files matched your search

+255 -16
View File
@@ -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<Void, Never>?
var holdStatus = false
var statusContinuation: CheckedContinuation<Void, Never>?
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<UUID> { try base.pendingCleanupProfiles() }
func completeCleanup(profileID: UUID) throws { try base.completeCleanup(profileID: profileID) }
@@ -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))
}
}
+10 -29
View File
@@ -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])
@@ -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<SSHHostKeyIdentity> { [] }
}
@@ -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
@@ -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
}
+2 -137
View File
@@ -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)
@@ -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" : {
@@ -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<UUID> =
groups.contains {
$0.id == group.id && $0.tailscale != nil && $0.tailscale?.network != group.tailscale?.network
@@ -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
}
}
@@ -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],
@@ -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 =
@@ -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)
@@ -29,7 +29,6 @@ public nonisolated enum CloudSyncQuarantineReason: String, Codable, Sendable {
case unsupportedFormat
case invalidRecord
case unreadablePayload
case duplicateTailscaleNetwork
}
public nonisolated enum CloudSyncCompatibilityError: LocalizedError {
@@ -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) }
@@ -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<VaultRecordKey>
) throws -> [VaultEnvelope] {
let journal = VaultSyncJournal(context: context)
let deleted = try journal.deletionEnvelopes(deletions)
let accepted = try journal.mergeDecision(
for: envelopes.filter { !deletions.contains($0.key) } + deleted
).accepted
let original = try fetchSnapshot()
var rejected = Set<VaultRecordKey>()
while true {
var candidate = original
for envelope in accepted where !rejected.contains(envelope.key) { try candidate.apply(envelope) }
let networks = Dictionary(
grouping: candidate.groups.filter { $0.tailscale?.hostIdentityVersion == .legacy }
) {
$0.tailscale!.network
}
var conflicts = Set<VaultRecordKey>()
for (network, groups) in networks where groups.count > 1 {
let previousOwner = original.groups.first {
$0.tailscale?.hostIdentityVersion == .legacy && $0.tailscale?.network == network
}?.id
let owner =
groups.first { $0.id == previousOwner } ?? groups.min { $0.id.uuidString < $1.id.uuidString }!
for group in groups where group.id != owner.id {
conflicts.insert(VaultRecordKey(kind: .hostGroup, id: group.id))
}
}
let additional = conflicts.subtracting(rejected)
guard !additional.isEmpty else {
return try accepted.filter { rejected.contains($0.key) }.map { winner in
guard let original = envelopes.first(where: { $0.withoutDeviceLocalMetadata == winner }) else {
throw VaultSyncPersistenceError.invalidEnvelope(winner.key.recordName)
}
return original
}
}
rejected.formUnion(additional)
}
}
public func applyRemote(
_ envelopes: [VaultEnvelope], deletions: Set<VaultRecordKey> = [], beforeSave: () throws -> Void
) throws -> VaultRemoteApplyResult {
@@ -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<PersistentHost>()), with: snapshot.hosts,
@@ -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)
@@ -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)
@@ -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."
@@ -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)
}
}
@@ -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() }
}
}
+2 -1
View File
@@ -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)])