// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import CryptoKit import Foundation import TailscaleKit import TermindApplication import TermindDomain /// Owns one embedded userspace node; no system VPN or daemon is required. public actor LibTailscaleNodeSession: TailscaleNodeSession { public nonisolated static var version: String { Node.version } private let node: Node private let controlURL: String private init(node: Node, controlURL: String) { self.node = node self.controlURL = controlURL } public static let factory: TailscaleNodeFactory = { profileID, hostname, controlURL, authKey in try await Task.detached { let directory = try profileDirectory(profileID) let node = try Node( hostname: hostname, directory: directory, controlURL: controlURL, stateNamespace: stateNamespace(profileID), authKey: authKey ) return LibTailscaleNodeSession(node: node, controlURL: controlURL) }.value } public static let profileStorage = TailscaleProfileStorage( profiles: { try await Task.detached { try storedProfiles(in: profileRoot()) }.value }, erase: { id in try await Task.detached { try Node.eraseState(directory: profileDirectory(id), stateNamespace: stateNamespace(id)) }.value }) private nonisolated static func stateNamespace(_ id: UUID) -> String { "com.akinokaede.termind.tailscale.\(id.uuidString)" } private nonisolated static func profileRoot() throws -> URL { try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false ).appendingPathComponent("Termind/Tailscale", isDirectory: true) } private nonisolated static func profileDirectory(_ id: UUID) throws -> URL { try profileRoot().appendingPathComponent(id.uuidString, isDirectory: true) } nonisolated static func storedProfiles(in root: URL) throws -> Set { let entries: [URL] do { entries = try FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) } catch CocoaError.fileReadNoSuchFile { return [] } return try Set( entries.compactMap { url in guard let id = UUID(uuidString: url.lastPathComponent), id.uuidString == url.lastPathComponent else { return nil } let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) return values.isDirectory == true && values.isSymbolicLink != true ? id : nil }) } public func status() async throws -> TailscaleNodeStatus { let data = try await node.request(path: "status") return try Self.decodeStatus(data, controlURL: controlURL) } public static func decodeStatus(_ data: Data, controlURL: String) throws -> TailscaleNodeStatus { let status = try JSONDecoder().decode(Status.self, from: data) let network = try status.currentTailnet.map { try TailscaleNetwork(controlURL: controlURL, dnsSuffix: $0.magicDNSSuffix) } // A missing peer map is not an authoritative empty snapshot. let running = status.backendState == "Running" && network != nil let peers = (status.peers ?? [:]).values.filter { $0.inNetworkMap && $0.shareeNode != true && !$0.stableID.isEmpty && !$0.dnsName.isEmpty }.map { TailscalePeer( stableID: $0.stableID, name: $0.hostName, dnsName: $0.dnsName.trimmingCharacters(in: CharacterSet(charactersIn: ".")), online: $0.online, sshHostKeys: $0.sshHostKeys ?? [], details: TailscalePeerDetails( addresses: $0.tailscaleIPs ?? [], operatingSystem: $0.os ?? "", keyExpiry: Self.date($0.keyExpiry), lastSeen: Self.date($0.lastSeen)), exitNodeOption: $0.exitNodeOption) }.sorted { $0.stableID < $1.stableID } let device = try status.localDevice.map { local in let expiry: Date? if let raw = local.keyExpiry, !raw.hasPrefix("0001-") { expiry = try (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(raw)) ?? Date.ISO8601FormatStyle().parse(raw) } else { expiry = nil } return TailscaleDeviceInfo( networkName: status.currentTailnet?.name ?? network?.dnsSuffix ?? "", dnsName: (local.dnsName ?? "").trimmingCharacters(in: CharacterSet(charactersIn: ".")), hostname: local.hostName ?? "", addresses: local.tailscaleIPs ?? [], operatingSystem: local.os ?? "", keyExpiry: expiry) } return TailscaleNodeStatus( running: running, network: network, authURL: status.authURL.flatMap(URL.init(string:)), peers: peers, device: device ) } private static func date(_ raw: String?) -> Date? { guard let raw, !raw.hasPrefix("0001-") else { return nil } return (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(raw)) ?? (try? Date.ISO8601FormatStyle().parse(raw)) } public func setHostname(_ hostname: String) async throws { let body = try JSONSerialization.data(withJSONObject: ["Hostname": hostname, "HostnameSet": true]) _ = try await node.request(method: "PATCH", path: "prefs", body: String(decoding: body, as: UTF8.self)) } public func setExitNode(_ stableID: String?) async throws { let body = try JSONSerialization.data(withJSONObject: [ "ExitNodeID": stableID ?? "", "ExitNodeIDSet": true, "ExitNodeIP": "", "ExitNodeIPSet": true, "AutoExitNode": "", "AutoExitNodeSet": true, ]) _ = try await node.request(method: "PATCH", path: "prefs", body: String(decoding: body, as: UTF8.self)) } public func ping(ip: String) async throws -> TailscalePingResult { var query = URLComponents() query.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "type", value: "disco")] let data = try await node.request(method: "POST", path: "ping?" + (query.percentEncodedQuery ?? "")) return try Self.decodePing(data) } public static func decodePing(_ data: Data) throws -> TailscalePingResult { struct Response: Decodable { var error: String? var latencySeconds: Double? var endpoint: String? var peerRelay: String? var derpRegionID: Int? var derpRegionCode: String? enum CodingKeys: String, CodingKey { case error = "Err" case latencySeconds = "LatencySeconds" case endpoint = "Endpoint" case peerRelay = "PeerRelay" case derpRegionID = "DERPRegionID" case derpRegionCode = "DERPRegionCode" } } let value = try JSONDecoder().decode(Response.self, from: data) if let error = value.error, !error.isEmpty { throw TermindSSHError.library(error) } guard let latency = value.latencySeconds, latency.isFinite, latency >= 0 else { throw TailscaleConfigurationError.unavailable } let route: TailscalePingResult.Route if let endpoint = value.endpoint, !endpoint.isEmpty { route = .direct(endpoint) } else if let relay = value.peerRelay, !relay.isEmpty { route = .peerRelay(relay) } else if let id = value.derpRegionID, id != 0 { route = .derp(value.derpRegionCode ?? String(id)) } else { route = .unknown } return TailscalePingResult(milliseconds: latency * 1000, route: route) } public func login() async throws { _ = try await node.request(method: "POST", path: "login-interactive") } public func logout() async throws { _ = try? await node.request(method: "POST", path: "logout") await node.close() // The model's durable profile cleanup queue owns local erasure and retry. } public func close() async { await node.close() } public func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { try await node.dial(host: host, port: port, timeout: Int32(max(1, min(timeout, 600)))) } public func hostKeys(stableNodeID: String) async throws -> Set { let status = try await status() guard status.running else { throw TailscaleConfigurationError.loginRequired } guard let peer = status.peers.first(where: { $0.stableID == stableNodeID }) else { throw TailscaleConfigurationError.peerSSHUnavailable } let keys = Set(peer.sshHostKeys.compactMap(Self.hostKey)) guard !keys.isEmpty else { throw TailscaleConfigurationError.peerSSHUnavailable } return keys } public static func hostKey(_ line: String) -> SSHHostKeyIdentity? { let fields = line.split(whereSeparator: \.isWhitespace) guard fields.count >= 2, let algorithm = SSHKeyAlgorithm(rawValue: String(fields[0])), let blob = Data(base64Encoded: String(fields[1])), blob.count > 4 else { return nil } let length = blob.prefix(4).reduce(0) { ($0 << 8) | Int($1) } guard length > 0, length <= blob.count - 4, String(data: blob.subdata(in: 4..<(4 + length)), encoding: .utf8) == algorithm.rawValue else { return nil } let fingerprint = Data(SHA256.hash(data: blob)).base64EncodedString().replacingOccurrences(of: "=", with: "") return SSHHostKeyIdentity(algorithm: algorithm, fingerprint: "SHA256:\(fingerprint)") } private struct Status: Decodable { var backendState: String var authURL: String? var currentTailnet: Tailnet? var peers: [String: PeerStatus]? var localDevice: LocalDevice? enum CodingKeys: String, CodingKey { case backendState = "BackendState" case authURL = "AuthURL" case currentTailnet = "CurrentTailnet" case peers = "Peer" case localDevice = "Self" } init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) backendState = try values.decode(String.self, forKey: .backendState) authURL = try values.decodeIfPresent(String.self, forKey: .authURL) currentTailnet = try values.decodeIfPresent(Tailnet.self, forKey: .currentTailnet) // Null is the upstream representation of a successful empty list. peers = try values.decode([String: PeerStatus]?.self, forKey: .peers) localDevice = try values.decodeIfPresent(LocalDevice.self, forKey: .localDevice) } struct Tailnet: Decodable { var name: String? var magicDNSSuffix: String enum CodingKeys: String, CodingKey { case name = "Name" case magicDNSSuffix = "MagicDNSSuffix" } } struct LocalDevice: Decodable { var hostName: String? var dnsName: String? var tailscaleIPs: [String]? var os: String? var keyExpiry: String? enum CodingKeys: String, CodingKey { case hostName = "HostName" case dnsName = "DNSName" case tailscaleIPs = "TailscaleIPs" case os = "OS" case keyExpiry = "KeyExpiry" } } struct PeerStatus: Decodable { var stableID: String var hostName: String var dnsName: String var online: Bool var inNetworkMap: Bool var shareeNode: Bool? var exitNodeOption: Bool? var sshHostKeys: [String]? var tailscaleIPs: [String]? var os: String? var keyExpiry: String? var lastSeen: String? enum CodingKeys: String, CodingKey { case stableID = "ID" case hostName = "HostName" case dnsName = "DNSName" case online = "Online" case inNetworkMap = "InNetworkMap" case shareeNode = "ShareeNode" case sshHostKeys case exitNodeOption = "ExitNodeOption" case tailscaleIPs = "TailscaleIPs" case os = "OS" case keyExpiry = "KeyExpiry" case lastSeen = "LastSeen" } } } }