Files
Termind/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift
T

691 lines
31 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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)
}
}