Files
Termind/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift
T
Kaede Akino 1ab7ed3da3 feat: isolate tailscale connections and harden history
Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment.

Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision.

Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
2026-10-03 09:46:09 +08:00

545 lines
26 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
public nonisolated struct TailscaleDeviceInfo: Sendable, Equatable {
public var networkName: String
public var dnsName: String
public var hostname: String
public var addresses: [String]
public var operatingSystem: String
public var keyExpiry: Date?
public init(
networkName: String, dnsName: String, hostname: String, addresses: [String], operatingSystem: String,
keyExpiry: Date?
) {
self.networkName = networkName
self.dnsName = dnsName
self.hostname = hostname
self.addresses = addresses
self.operatingSystem = operatingSystem
self.keyExpiry = keyExpiry
}
}
public nonisolated struct TailscaleNodeStatus: Sendable {
public var running: Bool
public var network: TailscaleNetwork?
public var authURL: URL?
public var peers: [TailscalePeer]
public var device: TailscaleDeviceInfo?
public init(
running: Bool, network: TailscaleNetwork?, authURL: URL?, peers: [TailscalePeer],
device: TailscaleDeviceInfo? = nil
) {
self.running = running
self.network = network
self.authURL = authURL
self.peers = peers
self.device = device
}
}
public nonisolated protocol TailscaleNodeSession: Sendable {
func status() async throws -> TailscaleNodeStatus
func login() async throws
func logout() async throws
func close() async
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity>
func setExitNode(_ stableID: String?) async throws
func setHostname(_ hostname: String) async throws
func ping(ip: String) async throws -> TailscalePingResult
}
extension TailscaleNodeSession {
public func setExitNode(_ stableID: String?) async throws {
if stableID != nil { throw TailscaleConfigurationError.unavailable }
}
public func setHostname(_ hostname: String) async throws { throw TailscaleConfigurationError.unavailable }
public func ping(ip: String) async throws -> TailscalePingResult { throw TailscaleConfigurationError.unavailable }
}
public typealias TailscaleNodeFactory =
@Sendable (
_ profileID: UUID, _ hostname: String, _ controlURL: String, _ authKey: String?
) async throws -> any TailscaleNodeSession
@MainActor
@Observable
public final class TailscaleModel: TailscaleConnectionProviding {
public private(set) var loginURL: URL?
public private(set) var isLoggingIn = false
public private(set) var loginCompletedGroupID: UUID?
public private(set) var error: String?
public private(set) var runningGroups: Set<UUID> = []
public private(set) var devices: [UUID: TailscaleDeviceInfo] = [:]
public private(set) var peers: [UUID: [TailscalePeer]] = [:]
@ObservationIgnored private let library: HostLibraryModel
@ObservationIgnored private let store: any TailscaleLocalStoring
@ObservationIgnored private let factory: TailscaleNodeFactory
@ObservationIgnored private let profileStorage: TailscaleProfileStorage
@ObservationIgnored private let hostname: () -> String
@ObservationIgnored private let hasAccess: () -> Bool
@ObservationIgnored private var appliedExitNodes: [UUID: String] = [:]
@ObservationIgnored private var states: [UUID: TailscaleLocalState] = [:]
private nonisolated struct ActiveNode: Sendable {
let session: any TailscaleNodeSession
let profileID: UUID
let network: TailscaleNetwork
}
@ObservationIgnored private var nodes: [UUID: ActiveNode] = [:]
@ObservationIgnored private var monitors: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored private var starting: Set<UUID> = []
@ObservationIgnored private var profilesInUse: Set<UUID> = []
@ObservationIgnored private var retiringNodes: [UUID: any TailscaleNodeSession] = [:]
@ObservationIgnored private var cleanupTasks: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored private var loginTask: Task<Void, Never>?
@ObservationIgnored private var loginGeneration: UUID?
@ObservationIgnored private var loginTargetGroupID: UUID?
@ObservationIgnored private var started = false
@ObservationIgnored private var shuttingDown = false
public init(
library: HostLibraryModel, store: any TailscaleLocalStoring,
factory: @escaping TailscaleNodeFactory, profileStorage: TailscaleProfileStorage,
hostname: @escaping () -> String,
hasAccess: @escaping () -> Bool
) {
self.library = library
self.store = store
self.factory = factory
self.profileStorage = profileStorage
self.hostname = hostname
self.hasAccess = hasAccess
library.tailscaleConnections = self
library.didChangeTailscaleGroups = { [weak self] in self?.reconcile() }
}
public var defaultHostname: String { hostname() }
public func savedHostname(groupID: UUID) -> String? { states[groupID]?.hostname }
public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" }
public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] {
(peers[groupID] ?? []).filter { $0.exitNodeOption == true }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
public func saveExitNode(_ stableID: String, groupID: UUID) throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var group = library.group(groupID), group.tailscale != nil else {
throw TailscaleConfigurationError.unavailable
}
guard exitNodeID(groupID: groupID) != stableID else { return }
group.tailscale?.exitNodeID = stableID.isEmpty ? nil : stableID
library.upsert(group)
if let error = library.storageError { throw TermindSSHError.library(error) }
}
public func saveHostname(_ value: String, groupID: UUID) async throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var state = states[groupID] else { throw TailscaleConfigurationError.unavailable }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let name = trimmed.isEmpty ? hostname() : trimmed
guard state.hostname != name else { return }
if let node = nodes[groupID] { try await node.session.setHostname(name) }
guard states[groupID]?.profileID == state.profileID else { throw CancellationError() }
state = states[groupID] ?? state
state.hostname = name
try store.save(state)
states[groupID] = state
devices[groupID]?.hostname = name
}
public func start() {
guard !started else { return }
started = true
do {
states = Dictionary(uniqueKeysWithValues: try store.load().map { ($0.groupID, $0) })
for var state in states.values {
if state.hostname == nil {
state.hostname = hostname()
try store.save(state)
states[state.groupID] = state
}
peers[state.groupID] = state.peers
library.replaceTailscalePeers(state.peers, groupID: state.groupID)
}
reconcile()
recoverOrphanedProfiles()
} catch { self.error = error.localizedDescription }
}
public func reconcile() {
guard !shuttingDown else { return }
if let id = loginTargetGroupID, library.group(id) == nil { cancelLogin() }
guard library.storageError == nil else { return }
let wanted = Set(library.groups.filter { $0.tailscale != nil }.map(\.id))
for id in Set(states.keys).union(nodes.keys).subtracting(wanted) {
retireProfile(groupID: id)
}
drainProfileCleanup()
guard hasAccess() else { return }
for id in wanted where nodes[id] == nil && !starting.contains(id) && loginTargetGroupID != id {
guard let state = states[id], let configuration = library.group(id)?.tailscale else { continue }
starting.insert(id)
profilesInUse.insert(state.profileID)
let name = state.hostname ?? hostname()
Task { [weak self] in
guard let self else { return }
defer {
self.starting.remove(id)
self.profilesInUse.remove(state.profileID)
self.drainProfileCleanup()
}
do {
let node = try await self.factory(state.profileID, name, configuration.network.controlURL, nil)
guard !self.shuttingDown, self.states[id]?.profileID == state.profileID,
self.library.group(id)?.tailscale?.network == configuration.network, self.hasAccess()
else {
await node.close()
return
}
self.nodes[id] = ActiveNode(
session: node, profileID: state.profileID, network: configuration.network)
self.monitor(id, node: node)
} catch { self.error = error.localizedDescription }
}
}
}
private func recoverOrphanedProfiles() {
Task { [weak self] in
guard let self else { return }
do {
let discovered = try await profileStorage.profiles()
guard !shuttingDown, library.storageError == nil else { return }
// Re-read after enumeration: a login may have committed while suspended.
let active = Set(try store.load().map(\.profileID))
for id in discovered.subtracting(active) { try store.scheduleCleanup(profileID: id) }
drainProfileCleanup()
} catch { self.error = error.localizedDescription }
}
}
private func retireProfile(groupID: UUID) {
guard states[groupID] != nil else { return }
do {
// Remove the active reference and enqueue its profile atomically, before
// releasing our last runtime reference. A failed save remains retryable.
try store.remove(groupID: groupID)
states[groupID] = nil
if let node = nodes.removeValue(forKey: groupID) { retiringNodes[node.profileID] = node.session }
monitors.removeValue(forKey: groupID)?.cancel()
runningGroups.remove(groupID)
devices[groupID] = nil
appliedExitNodes[groupID] = nil
peers[groupID] = nil
library.replaceTailscalePeers([], groupID: groupID)
} catch { self.error = error.localizedDescription }
}
private func drainProfileCleanup() {
do {
for id in try store.pendingCleanupProfiles()
where !profilesInUse.contains(id) && !nodes.values.contains(where: { $0.profileID == id })
&& cleanupTasks[id] == nil
{
cleanupTasks[id] = Task { [weak self] in
guard let self else { return }
defer { self.cleanupTasks[id] = nil }
do {
guard !self.profilesInUse.contains(id),
!self.nodes.values.contains(where: { $0.profileID == id }),
try self.store.pendingCleanupProfiles().contains(id)
else { return }
if let node = self.retiringNodes.removeValue(forKey: id) {
try? await node.logout()
await node.close()
}
guard !self.profilesInUse.contains(id),
!self.nodes.values.contains(where: { $0.profileID == id }),
try self.store.pendingCleanupProfiles().contains(id)
else { return }
try await self.profileStorage.erase(id)
try self.store.completeCleanup(profileID: id)
} catch { self.error = error.localizedDescription }
}
}
} catch { self.error = error.localizedDescription }
}
public func beginLogin(
name: String, parentID: UUID?, controlURL: String, authKey: String?, groupID: UUID? = nil, hostname: String = ""
) {
guard hasAccess() else {
error = TailscaleConfigurationError.requiresPro.localizedDescription
return
}
cancelLogin()
let generation = UUID()
let connectionID = groupID ?? UUID()
loginGeneration = generation
loginTargetGroupID = groupID
let originalGroup = groupID.flatMap(library.group)
isLoggingIn = true
loginCompletedGroupID = nil
error = nil
let trimmedHostname = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let deviceName = trimmedHostname.isEmpty ? self.hostname() : trimmedHostname
loginTask = Task { [weak self] in
guard let self else { return }
var candidate: (any TailscaleNodeSession)?
let profileID = UUID()
self.profilesInUse.insert(profileID)
defer {
self.profilesInUse.remove(profileID)
self.drainProfileCleanup()
}
do {
let canonical = try TailscaleNetwork(controlURL: controlURL, dnsSuffix: "pending.invalid").controlURL
try self.store.scheduleCleanup(profileID: profileID)
let node = try await self.factory(profileID, deviceName, canonical, authKey)
candidate = node
try Task.checkCancellation()
if authKey == nil { try await node.login() }
let deadline = ContinuousClock.now.advanced(by: .seconds(600))
while ContinuousClock.now < deadline {
try Task.checkCancellation()
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
let status = try await node.status()
try Task.checkCancellation()
guard self.loginGeneration == generation else { throw CancellationError() }
if let groupID, self.library.group(groupID) == nil { throw CancellationError() }
self.loginURL = status.authURL
if status.running, let network = status.network {
if let groupID, let previous = self.library.group(groupID)?.tailscale?.network,
previous != network
{
throw TailscaleConfigurationError.missingNetworkIdentity
}
var group =
groupID.flatMap(self.library.group)
?? HostGroup(id: connectionID, name: name, parentID: parentID)
if group.tailscale == nil { group.tailscale = TailscaleGroupConfiguration(network: network) }
try await node.setExitNode(group.tailscale?.exitNodeID)
try Task.checkCancellation()
guard self.loginGeneration == generation else { throw CancellationError() }
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
if let groupID {
guard let latest = self.library.group(groupID) else { throw CancellationError() }
guard latest.tailscale?.network == originalGroup?.tailscale?.network else {
throw CancellationError()
}
// Exit-node application suspends. Re-read cloud edits before committing
// enrollment, and apply a changed exit node on the next iteration.
if latest.tailscale?.exitNodeID != group.tailscale?.exitNodeID { continue }
group = latest
if group.tailscale == nil {
group.tailscale = TailscaleGroupConfiguration(network: network)
}
} else if self.library.group(group.id) != nil {
throw TailscaleConfigurationError.duplicateNetwork
}
let state = TailscaleLocalState(
groupID: group.id, profileID: profileID, peers: status.peers, hostname: deviceName)
if originalGroup == nil || group.name == originalGroup?.name { group.name = name }
if originalGroup == nil || group.parentID == originalGroup?.parentID {
group.parentID = parentID
}
// Public group configuration is valid without local enrollment.
// Persist it first so activation needs no cross-store rollback.
self.library.upsert(group)
guard self.library.storageError == nil else {
throw TailscaleConfigurationError.unavailable
}
try self.store.save(state)
self.monitors.removeValue(forKey: group.id)?.cancel()
if let old = self.nodes[group.id] {
self.retiringNodes[old.profileID] = old.session
}
self.states[group.id] = state
self.nodes[group.id] = ActiveNode(session: node, profileID: profileID, network: network)
self.appliedExitNodes[group.id] = group.tailscale?.exitNodeID ?? ""
self.library.replaceTailscalePeers(status.peers, groupID: group.id)
self.runningGroups.insert(group.id)
self.devices[group.id] = status.device
self.peers[group.id] = status.peers
self.monitor(group.id, node: node)
candidate = nil
self.loginURL = nil
self.loginCompletedGroupID = group.id
break
}
try await Task.sleep(for: .seconds(1))
}
if candidate != nil { throw TailscaleConfigurationError.unavailable }
} catch {
if let candidate {
try? await candidate.logout()
await candidate.close()
}
if self.loginGeneration == generation && !(error is CancellationError) {
self.loginURL = nil
self.error = error.localizedDescription
}
}
if self.loginGeneration == generation {
self.isLoggingIn = false
self.loginTargetGroupID = nil
}
}
}
public func cancelLogin() {
loginGeneration = nil
loginTargetGroupID = nil
loginTask?.cancel()
loginTask = nil
loginURL = nil
isLoggingIn = false
// The login task owns its candidate and performs logout/close once, including
// cancellation while factory, status, or preference application is suspended.
}
public func logout(groupID: UUID) async {
let profileID = states[groupID]?.profileID
retireProfile(groupID: groupID)
drainProfileCleanup()
if let profileID { await cleanupTasks[profileID]?.value }
}
public func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard runningGroups.contains(groupID), let node = nodes[groupID],
library.group(groupID)?.tailscale?.network == node.network
else {
throw TailscaleConfigurationError.loginRequired
}
guard appliedExitNodes[groupID] == exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
return AccessDialer(
node: node.session,
check: { [weak self] in
guard let self, self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard self.runningGroups.contains(groupID), self.nodes[groupID]?.profileID == node.profileID,
self.library.group(groupID)?.tailscale?.network == node.network
else { throw TailscaleConfigurationError.loginRequired }
guard self.appliedExitNodes[groupID] == self.exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
})
}
public func peer(for host: Host) -> TailscalePeer? {
guard let group = library.tailscaleGroup(for: host) else { return nil }
let candidates = peers[group.id] ?? []
if let reference = host.tailscale {
return candidates.first { $0.stableID == reference.stableNodeID }
}
let address = host.host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased()
return candidates.first {
$0.dnsName.lowercased() == address || $0.dnsName.split(separator: ".").first?.lowercased() == address
|| $0.details?.addresses.contains(address) == true
}
}
public func canPing(_ host: Host) -> Bool {
guard hasAccess(), let group = library.tailscaleGroup(for: host), runningGroups.contains(group.id) else {
return false
}
return peer(for: host)?.details?.addresses.isEmpty == false
}
public func ping(hostID: Host.ID) async throws -> TailscalePingResult {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard let host = library.host(hostID), let group = library.tailscaleGroup(for: host),
runningGroups.contains(group.id), let node = nodes[group.id], node.network == group.tailscale?.network,
let ip = peer(for: host)?.details?.addresses.first
else { throw TailscaleConfigurationError.unavailable }
let result = try await node.session.ping(ip: ip)
try Task.checkCancellation()
guard hasAccess(), runningGroups.contains(group.id), nodes[group.id]?.profileID == node.profileID,
library.tailscaleGroup(for: host)?.id == group.id,
library.group(group.id)?.tailscale?.network == node.network
else {
throw TailscaleConfigurationError.unavailable
}
return result
}
public func shutdown() async {
shuttingDown = true
cancelLogin()
for task in monitors.values { task.cancel() }
monitors = [:]
for node in nodes.values { await node.session.close() }
nodes = [:]
appliedExitNodes = [:]
runningGroups = []
devices = [:]
peers = [:]
for task in cleanupTasks.values { await task.value }
}
private func monitor(_ id: UUID, node: any TailscaleNodeSession) {
monitors.removeValue(forKey: id)?.cancel()
monitors[id] = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
do {
let status = try await node.status()
try Task.checkCancellation()
guard let network = self.library.group(id)?.tailscale?.network else { return }
if status.running && status.network == network {
let desiredExitNode = self.exitNodeID(groupID: id)
if self.appliedExitNodes[id] != desiredExitNode {
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
try await node.setExitNode(desiredExitNode.isEmpty ? nil : desiredExitNode)
try Task.checkCancellation()
self.appliedExitNodes[id] = desiredExitNode
guard self.exitNodeID(groupID: id) == desiredExitNode else { continue }
}
self.runningGroups.insert(id)
self.devices[id] = status.device
self.peers[id] = status.peers
self.library.replaceTailscalePeers(status.peers, groupID: id)
if var state = self.states[id], state.peers != status.peers {
state.peers = status.peers
try self.store.save(state)
self.states[id] = state
}
} else {
self.runningGroups.remove(id)
self.devices[id] = nil
}
} catch {
if Task.isCancelled { return }
self.runningGroups.remove(id)
self.devices[id] = nil
self.error = error.localizedDescription
}
do { try await Task.sleep(for: .seconds(3)) } catch { return }
}
}
}
}
private nonisolated struct AccessDialer: SSHNetworkDialing {
let node: any TailscaleNodeSession
let check: @MainActor @Sendable () throws -> Void
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
try await check()
return try await node.dial(host: host, port: port, timeout: timeout)
}
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
try await check()
return try await node.hostKeys(stableNodeID: stableNodeID)
}
}