467 lines
21 KiB
Swift
467 lines
21 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 hostname: () -> String
|
|
@ObservationIgnored private let hasAccess: () -> Bool
|
|
@ObservationIgnored private var appliedExitNodes: [UUID: String] = [:]
|
|
@ObservationIgnored private var states: [UUID: TailscaleLocalState] = [:]
|
|
@ObservationIgnored private var nodes: [UUID: any TailscaleNodeSession] = [:]
|
|
@ObservationIgnored private var monitors: [UUID: Task<Void, Never>] = [:]
|
|
@ObservationIgnored private var starting: Set<UUID> = []
|
|
@ObservationIgnored private var loginTask: Task<Void, Never>?
|
|
@ObservationIgnored private var loginGeneration: UUID?
|
|
@ObservationIgnored private var pendingNode: (any TailscaleNodeSession)?
|
|
@ObservationIgnored private var started = false
|
|
@ObservationIgnored private var shuttingDown = false
|
|
|
|
public init(
|
|
library: HostLibraryModel, store: any TailscaleLocalStoring,
|
|
factory: @escaping TailscaleNodeFactory, hostname: @escaping () -> String,
|
|
hasAccess: @escaping () -> Bool
|
|
) {
|
|
self.library = library
|
|
self.store = store
|
|
self.factory = factory
|
|
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.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()
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
|
|
public func reconcile() {
|
|
guard !shuttingDown else { return }
|
|
let wanted = Set(library.groups.filter { $0.tailscale != nil }.map(\.id))
|
|
for id in Set(states.keys).union(nodes.keys).subtracting(wanted) {
|
|
monitors.removeValue(forKey: id)?.cancel()
|
|
runningGroups.remove(id)
|
|
devices[id] = nil
|
|
appliedExitNodes[id] = nil
|
|
peers[id] = nil
|
|
if let node = nodes.removeValue(forKey: id) {
|
|
Task {
|
|
try? await node.logout()
|
|
await node.close()
|
|
}
|
|
}
|
|
states[id] = nil
|
|
do { try store.remove(groupID: id) } catch { self.error = error.localizedDescription }
|
|
library.replaceTailscalePeers([], groupID: id)
|
|
}
|
|
guard hasAccess() else { return }
|
|
for id in wanted where nodes[id] == nil && !starting.contains(id) {
|
|
guard let state = states[id], let configuration = library.group(id)?.tailscale else { continue }
|
|
starting.insert(id)
|
|
let name = state.hostname ?? hostname()
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.starting.remove(id) }
|
|
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] = node
|
|
self.monitor(id, node: node)
|
|
} 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()
|
|
loginGeneration = generation
|
|
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)?
|
|
do {
|
|
let canonical = try TailscaleNetwork(controlURL: controlURL, dnsSuffix: "pending.invalid").controlURL
|
|
let profileID = UUID()
|
|
let node = try await self.factory(profileID, deviceName, canonical, authKey)
|
|
candidate = node
|
|
try Task.checkCancellation()
|
|
self.pendingNode = node
|
|
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
|
|
}
|
|
if self.library.groups.contains(where: { $0.tailscale?.network == network && $0.id != groupID })
|
|
{
|
|
throw TailscaleConfigurationError.duplicateNetwork
|
|
}
|
|
var group =
|
|
groupID.flatMap(self.library.group)
|
|
?? HostGroup(id: network.groupID, 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() }
|
|
let state = TailscaleLocalState(
|
|
groupID: group.id, profileID: profileID, peers: status.peers, hostname: deviceName)
|
|
group.name = name
|
|
group.parentID = parentID
|
|
let previousState = self.states[group.id]
|
|
try self.store.save(state)
|
|
self.library.upsert(group)
|
|
guard self.library.storageError == nil else {
|
|
if let previousState {
|
|
try self.store.save(previousState)
|
|
} else {
|
|
try self.store.remove(groupID: group.id)
|
|
}
|
|
throw TailscaleConfigurationError.unavailable
|
|
}
|
|
self.monitors.removeValue(forKey: group.id)?.cancel()
|
|
if let old = self.nodes[group.id] {
|
|
Task {
|
|
try? await old.logout()
|
|
await old.close()
|
|
}
|
|
}
|
|
self.states[group.id] = state
|
|
self.nodes[group.id] = node
|
|
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)
|
|
self.pendingNode = nil
|
|
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.pendingNode = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
public func cancelLogin() {
|
|
loginGeneration = nil
|
|
loginTask?.cancel()
|
|
loginTask = nil
|
|
loginURL = nil
|
|
isLoggingIn = false
|
|
if let node = pendingNode {
|
|
Task {
|
|
try? await node.logout()
|
|
await node.close()
|
|
}
|
|
}
|
|
pendingNode = nil
|
|
}
|
|
|
|
public func logout(groupID: UUID) async {
|
|
monitors.removeValue(forKey: groupID)?.cancel()
|
|
runningGroups.remove(groupID)
|
|
devices[groupID] = nil
|
|
peers[groupID] = nil
|
|
appliedExitNodes[groupID] = nil
|
|
let node = nodes.removeValue(forKey: groupID)
|
|
states[groupID] = nil
|
|
do { try store.remove(groupID: groupID) } catch { self.error = error.localizedDescription }
|
|
library.replaceTailscalePeers([], groupID: groupID)
|
|
if let node {
|
|
try? await node.logout()
|
|
await node.close()
|
|
}
|
|
}
|
|
|
|
public func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
|
|
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
|
|
guard runningGroups.contains(groupID), let node = nodes[groupID] else {
|
|
throw TailscaleConfigurationError.loginRequired
|
|
}
|
|
guard appliedExitNodes[groupID] == exitNodeID(groupID: groupID) else {
|
|
throw TailscaleConfigurationError.settingsPending
|
|
}
|
|
return AccessDialer(
|
|
node: node,
|
|
check: { [weak self] in
|
|
guard let self, self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
|
|
guard self.runningGroups.contains(groupID) 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],
|
|
let ip = peer(for: host)?.details?.addresses.first
|
|
else { throw TailscaleConfigurationError.unavailable }
|
|
let result = try await node.ping(ip: ip)
|
|
try Task.checkCancellation()
|
|
guard hasAccess(), runningGroups.contains(group.id), library.tailscaleGroup(for: host)?.id == group.id 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.close() }
|
|
nodes = [:]
|
|
appliedExitNodes = [:]
|
|
runningGroups = []
|
|
devices = [:]
|
|
peers = [:]
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|