Files

261 lines
9.3 KiB
Swift

import Foundation
import Libbox
import Library
import SwiftUI
public struct TailscalePeerData: Identifiable {
public let id: String
public let stableID: String
public let hostName: String
public let dnsName: String
public let os: String
public let tailscaleIPs: [String]
public let sshHostKeys: [String]
public let online: Bool
public let exitNode: Bool
public let exitNodeOption: Bool
public let shareeNode: Bool
public let expired: Bool
public let active: Bool
public let rxBytes: Int64
public let txBytes: Int64
public let keyExpiry: Int64
public let lastSeen: Int64
public var displayName: String {
let segment = dnsName.split(separator: ".").first.map(String.init) ?? ""
return segment.isEmpty ? hostName : segment
}
}
public struct TailscaleUserGroupData: Identifiable {
public let id: Int64
public let loginName: String
public let displayName: String
public let profilePicURL: String
public let peers: [TailscalePeerData]
}
public struct TailscaleEndpointData: Identifiable {
public let id: String
public let endpointTag: String
public let backendState: String
public let authURL: String
public let networkName: String
public let magicDNSSuffix: String
public let selfPeer: TailscalePeerData?
public let exitNode: TailscalePeerData?
public let userGroups: [TailscaleUserGroupData]
public let keyAuth: Bool
public var hasExitNodeCandidates: Bool {
if exitNode != nil { return true }
let selfStableID = selfPeer?.stableID
return userGroups.contains { group in
group.peers.contains { $0.exitNodeOption && $0.stableID != selfStableID }
}
}
}
@MainActor
public final class TailscaleStatusViewModel: BaseViewModel {
@Published public var endpoints: [TailscaleEndpointData] = []
@Published public var isSubscribed = false
public weak var peerStore: TailscaleSSHPeerStore?
private var statusSubscription: LibboxTailscaleStatusSubscription?
public func subscribe() {
guard !isSubscribed else { return }
isSubscribed = true
let handler = StatusHandler(self)
Task { [weak self] in
do {
let subscription = try await Task.detached {
try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler)
}.value
self?.statusSubscription = subscription
} catch {
guard let self else { return }
self.isSubscribed = false
self.endpoints = []
}
}
}
public func cancel() {
try? statusSubscription?.close()
statusSubscription = nil
isSubscribed = false
endpoints = []
peerStore?.sshPeers = []
peerStore?.quickConnectPeerIDs = []
}
public func endpoint(tag: String) -> TailscaleEndpointData? {
endpoints.first { $0.endpointTag == tag }
}
public func setExitNode(endpointTag: String, stableID: String) async {
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.setTailscaleExitNode(endpointTag, stableID: stableID)
}.value
} catch {
alert = AlertState(action: "set exit node", error: error)
}
}
public func logout(endpointTag: String) async {
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.tailscaleLogout(endpointTag)
}.value
} catch {
alert = AlertState(action: "logout", error: error)
}
}
fileprivate func updateSSHPeersOnService(_ endpoints: [TailscaleEndpointData]) {
var allSSHPeers: [TailscaleSSHPeerEntry] = []
for endpoint in endpoints {
for group in endpoint.userGroups {
for peer in group.peers where peer.online && !peer.sshHostKeys.isEmpty && !peer.tailscaleIPs.isEmpty {
allSSHPeers.append(TailscaleSSHPeerEntry(
endpointTag: endpoint.endpointTag,
hostName: peer.hostName,
peerAddress: peer.tailscaleIPs.first!,
stableID: peer.stableID,
sshHostKeys: peer.sshHostKeys
))
}
}
}
peerStore?.sshPeers = allSSHPeers
Task {
let qcSet = await SharedPreferences.tailscaleSSHQuickConnectPeers.get()
peerStore?.quickConnectPeerIDs = qcSet
}
}
private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable {
private weak var viewModel: TailscaleStatusViewModel?
init(_ viewModel: TailscaleStatusViewModel?) {
self.viewModel = viewModel
}
func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) {
guard let status else { return }
let endpoints = Self.convertUpdate(status)
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.endpoints = endpoints
viewModel.updateSSHPeersOnService(endpoints)
}
}
func onError(_ message: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.isSubscribed = false
viewModel.statusSubscription = nil
viewModel.endpoints = []
viewModel.peerStore?.sshPeers = []
viewModel.peerStore?.quickConnectPeerIDs = []
if let message {
viewModel.alert = AlertState(errorMessage: message)
}
}
}
private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] {
var endpoints: [TailscaleEndpointData] = []
if let iterator = status.endpoints() {
while iterator.hasNext() {
if let endpoint = iterator.next() {
endpoints.append(convertEndpoint(endpoint))
}
}
}
return endpoints
}
private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData {
var userGroups: [TailscaleUserGroupData] = []
if let groupIterator = endpoint.userGroups() {
while groupIterator.hasNext() {
if let group = groupIterator.next() {
userGroups.append(convertUserGroup(group))
}
}
}
return TailscaleEndpointData(
id: endpoint.endpointTag,
endpointTag: endpoint.endpointTag,
backendState: endpoint.backendState,
authURL: endpoint.authURL,
networkName: endpoint.networkName,
magicDNSSuffix: endpoint.magicDNSSuffix,
selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil,
exitNode: endpoint.exitNode != nil ? convertPeer(endpoint.exitNode!) : nil,
userGroups: userGroups,
keyAuth: endpoint.keyAuth
)
}
private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData {
var peers: [TailscalePeerData] = []
if let peerIterator = group.peers() {
while peerIterator.hasNext() {
if let peer = peerIterator.next() {
peers.append(convertPeer(peer))
}
}
}
return TailscaleUserGroupData(
id: group.userID,
loginName: group.loginName,
displayName: group.displayName,
profilePicURL: group.profilePicURL,
peers: peers
)
}
private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData {
var ips: [String] = []
if let ipIterator = peer.tailscaleIPs() {
while ipIterator.hasNext() {
ips.append(ipIterator.next())
}
}
var sshKeys: [String] = []
if let keyIterator = peer.sshHostKeys() {
while keyIterator.hasNext() {
sshKeys.append(keyIterator.next())
}
}
return TailscalePeerData(
id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName,
stableID: peer.stableID,
hostName: peer.hostName,
dnsName: peer.dnsName,
os: peer.os,
tailscaleIPs: ips,
sshHostKeys: sshKeys,
online: peer.online,
exitNode: peer.exitNode,
exitNodeOption: peer.exitNodeOption,
shareeNode: peer.shareeNode,
expired: peer.expired,
active: peer.active,
rxBytes: peer.rxBytes,
txBytes: peer.txBytes,
keyExpiry: peer.keyExpiry,
lastSeen: peer.lastSeen
)
}
}
}