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:
1 parent
11d3f471e1
commit
996ea85196
22 files changed
+579
-515
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
|
||||
Reference in new issue
Block a user