Files
Termind/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift
T
Kaede Akino ad2bf28790 refactor: remove obsolete application paths and adapters
Remove the transfer forwarding adapter, borrowed SFTP channel scheduling,
legacy assistant send entry points, unreachable Telnet password state,
unused Tailscale APIs, and unused host deletion impact projections.
Migrate behavioral tests to current paths and cover SFTP passphrase saving.

Validation: Swift formatting and shared-copy/localization checks passed.
The macOS test run reported failures in ConnectionStatusIconLayoutTests
and AgentRuntimeTests; investigation and iOS validation remain pending.
2026-10-04 22:10:09 +08:00

688 lines
31 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: AnyObject, 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
/// An authenticated candidate owned by an editor until the user saves it.
public nonisolated struct TailscalePendingLogin: Sendable, Identifiable {
public let id: UUID
public let groupID: UUID
public let network: TailscaleNetwork
public let exitNodeID: String
public let suggestedName: String
public let hostname: String
public let peers: [TailscalePeer]
public let device: TailscaleDeviceInfo?
}
@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 pendingLogin: TailscalePendingLogin?
public private(set) var loginOperationID: 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 loginOriginalGroup: HostGroup?
@ObservationIgnored private var candidateNode: ActiveNode?
@ObservationIgnored private var committingProfileID: UUID?
private var committingLogin: Bool { committingProfileID != nil }
@ObservationIgnored private var knownNetworks: [UUID: TailscaleNetwork] = [:]
@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
knownNetworks = Dictionary(
uniqueKeysWithValues: library.groups.compactMap { group in
group.tailscale.map { (group.id, $0.network) }
})
library.tailscaleConnections = self
library.didChangeTailscaleGroups = { [weak self] in self?.reconcile() }
}
public var defaultHostname: String { hostname() }
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 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, !committingLogin {
let latest = library.group(id)
if (latest == nil) != (loginOriginalGroup == nil)
|| latest?.tailscale?.network != loginOriginalGroup?.tailscale?.network
{
cancelLogin()
}
}
guard library.storageError == nil else { return }
let networks = Dictionary(
uniqueKeysWithValues: library.groups.compactMap { group in
group.tailscale.map { (group.id, $0.network) }
})
let changed = Set(knownNetworks.keys.filter { knownNetworks[$0] != networks[$0] })
for id in changed {
runningGroups.remove(id)
devices[id] = nil
peers[id] = nil
retireProfile(groupID: id)
}
// Keep failed retirements retryable even though the public binding has changed.
for id in Set(knownNetworks.keys).union(networks.keys) where states[id] == nil || !changed.contains(id) {
knownNetworks[id] = networks[id]
}
let wanted = Set(networks.keys)
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 }
}
/// Authentication does not create or update a group. Only commitLogin publishes it.
@discardableResult
public func beginLogin(
controlURL: String, authKey: String?, groupID: UUID = UUID(), hostname: String = ""
) -> UUID {
cancelLogin()
let generation = UUID()
loginOperationID = generation
loginGeneration = generation
loginTargetGroupID = groupID
loginOriginalGroup = library.group(groupID)
loginCompletedGroupID = nil
error = nil
guard hasAccess(), !shuttingDown else {
error = TailscaleConfigurationError.requiresPro.localizedDescription
return generation
}
isLoggingIn = true
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 {
if self.candidateNode?.profileID != profileID { self.profilesInUse.remove(profileID) }
self.drainProfileCleanup()
if self.loginGeneration == generation { self.isLoggingIn = false }
}
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 self.validateLogin(generation)
let status = try await node.status()
try self.validateLogin(generation)
self.loginURL = status.authURL
if status.running, let network = status.network {
let networkName =
status.device?.networkName.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.candidateNode = ActiveNode(session: node, profileID: profileID, network: network)
self.pendingLogin = TailscalePendingLogin(
id: generation, groupID: groupID, network: network,
exitNodeID: self.library.group(groupID)?.tailscale?.network == network
? self.exitNodeID(groupID: groupID) : "",
suggestedName: networkName.isEmpty ? network.dnsSuffix : networkName,
hostname: deviceName, peers: status.peers, device: status.device)
candidate = nil
self.loginURL = nil
return
}
try await Task.sleep(for: .seconds(1))
}
throw TailscaleConfigurationError.unavailable
} catch {
if let candidate {
try? await candidate.logout()
await candidate.close()
}
if self.loginGeneration == generation {
self.loginURL = nil
if !(error is CancellationError) { self.error = error.localizedDescription }
}
}
}
return generation
}
private func validateLogin(_ id: UUID) throws {
try Task.checkCancellation()
guard !shuttingDown, loginGeneration == id, let groupID = loginTargetGroupID else {
throw CancellationError()
}
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
let latest = library.group(groupID)
guard (latest == nil) == (loginOriginalGroup == nil),
latest?.tailscale?.network == loginOriginalGroup?.tailscale?.network
else { throw TailscaleConfigurationError.unavailable }
}
public func group(_ id: UUID) -> HostGroup? { library.group(id) }
/// The public binding is saved first. If local activation fails, the candidate
/// remains owned by the editor so Save can retry without another browser login.
public func commitLogin(
_ id: UUID, group draft: HostGroup, hostname: String, exitNodeID: String
) async throws -> HostGroup {
try validateLogin(id)
guard !committingLogin, let pending = pendingLogin, pending.id == id,
pending.groupID == draft.id, let candidate = candidateNode,
!draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { throw TailscaleConfigurationError.unavailable }
committingProfileID = candidate.profileID
defer {
committingProfileID = nil
if candidateNode?.profileID != candidate.profileID { profilesInUse.remove(candidate.profileID) }
drainProfileCleanup()
}
let trimmed = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let deviceName = trimmed.isEmpty ? self.hostname() : trimmed
// Re-read all public settings after operations that suspend.
try await candidate.session.setHostname(deviceName)
try validateLogin(id)
func desiredExitNode() -> String {
if exitNodeID == pending.exitNodeID, library.group(draft.id)?.tailscale?.network == pending.network {
return self.exitNodeID(groupID: draft.id)
}
return exitNodeID
}
var status: TailscaleNodeStatus
var selectedExitNode: String
while true {
selectedExitNode = desiredExitNode()
try await candidate.session.setExitNode(selectedExitNode.isEmpty ? nil : selectedExitNode)
try validateLogin(id)
status = try await candidate.session.status()
try validateLogin(id)
guard status.running, status.network == pending.network else {
throw TailscaleConfigurationError.loginRequired
}
if selectedExitNode == desiredExitNode() { break }
}
var group = library.group(draft.id) ?? draft
if group.tailscale?.network != pending.network {
group.tailscale = TailscaleGroupConfiguration(network: pending.network)
}
if loginOriginalGroup == nil || draft.name != loginOriginalGroup?.name {
group.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
if loginOriginalGroup == nil || draft.parentID != loginOriginalGroup?.parentID {
group.parentID = draft.parentID
}
group.tailscale?.exitNodeID = selectedExitNode.isEmpty ? nil : selectedExitNode
library.upsert(group)
if let error = library.storageError { throw TermindSSHError.library(error) }
// A retry must compare against the binding we have just durably saved.
loginOriginalGroup = group
let state = TailscaleLocalState(
groupID: group.id, profileID: candidate.profileID, peers: status.peers, hostname: deviceName)
do {
try store.save(state)
} catch {
let message = String(
localized: "The group was saved, but this device could not finish signing in. Save again to retry.")
self.error = message
throw TermindSSHError.library(message)
}
monitors.removeValue(forKey: group.id)?.cancel()
if let old = nodes[group.id] { retiringNodes[old.profileID] = old.session }
states[group.id] = state
nodes[group.id] = candidate
knownNetworks[group.id] = pending.network
appliedExitNodes[group.id] = selectedExitNode
library.replaceTailscalePeers(status.peers, groupID: group.id)
runningGroups.insert(group.id)
devices[group.id] = status.device
peers[group.id] = status.peers
candidateNode = nil
pendingLogin = nil
profilesInUse.remove(candidate.profileID)
loginTargetGroupID = nil
loginGeneration = nil
loginOperationID = nil
loginCompletedGroupID = group.id
self.error = nil
monitor(group.id, node: candidate.session)
drainProfileCleanup()
return group
}
public func saveGroup(
_ draft: HostGroup, original: HostGroup?, enabled: Bool, exitNodeID: String? = nil
) throws -> HostGroup {
let latest = library.group(draft.id)
guard (latest == nil) == (original == nil), latest?.tailscale?.network == original?.tailscale?.network else {
throw TailscaleConfigurationError.unavailable
}
var group = latest ?? draft
if original == nil || draft.name != original?.name {
group.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !group.name.isEmpty else { throw TailscaleConfigurationError.unavailable }
if original == nil || draft.parentID != original?.parentID { group.parentID = draft.parentID }
if !enabled { group.tailscale = nil }
if let exitNodeID, enabled { group.tailscale?.exitNodeID = exitNodeID.isEmpty ? nil : exitNodeID }
library.upsert(group)
if let error = library.storageError { throw TermindSSHError.library(error) }
return group
}
public func cancelLogin(operationID: UUID? = nil) {
if let operationID, operationID != loginOperationID { return }
loginGeneration = nil
loginOperationID = nil
loginTargetGroupID = nil
loginOriginalGroup = nil
loginTask?.cancel()
loginTask = nil
loginURL = nil
isLoggingIn = false
pendingLogin = nil
if let candidate = candidateNode {
candidateNode = nil
// A Save can be suspended inside the native runtime. Its defer releases
// the final profile lease before logout, close and directory erasure.
if committingProfileID != candidate.profileID { profilesInUse.remove(candidate.profileID) }
retiringNodes[candidate.profileID] = candidate.session
drainProfileCleanup()
}
// The authentication task owns a candidate until it publishes pendingLogin.
}
public func logout(groupID: UUID) async {
let profileID = states[groupID]?.profileID
retireProfile(groupID: groupID)
drainProfileCleanup()
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],
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
let authentication = loginTask
cancelLogin()
await authentication?.value
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 self.nodes[id]?.session === node,
let network = self.library.group(id)?.tailscale?.network
else { return }
if status.running, status.network != network {
self.retireProfile(groupID: id)
self.drainProfileCleanup()
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()
guard self.nodes[id]?.session === node,
self.library.group(id)?.tailscale?.network == network
else { return }
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)
}
}