288 lines
13 KiB
Swift
288 lines
13 KiB
Swift
// 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<UUID> {
|
|
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<SSHHostKeyIdentity> {
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
}
|