burrow/Apple/UI/Networks/Network.swift
JettChenT d1638726ca Add proxy subscription runtime support
Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling.

Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
2026-06-01 15:23:17 +08:00

1085 lines
35 KiB
Swift

import BurrowConfiguration
import BurrowCore
import Foundation
import Security
import SwiftProtobuf
import SwiftUI
struct NetworkCardModel: Identifiable {
let id: Int32
let backgroundColor: Color
let label: AnyView
}
struct TailnetNetworkPayload: Codable, Sendable {
var provider: TailnetProvider
var authority: String?
var account: String
var identity: String
var tailnet: String?
var hostname: String?
func encoded() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(self)
}
}
struct TailnetDiscoveryResponse: Codable, Sendable {
var domain: String
var provider: TailnetProvider
var authority: String
var oidcIssuer: String?
}
struct TailnetAuthorityProbeStatus: Sendable {
var authority: String
var statusCode: Int
var summary: String
var detail: String?
}
struct TailnetLoginStatus: Sendable {
var sessionID: String
var backendState: String
var authURL: URL?
var running: Bool
var needsLogin: Bool
var tailnetName: String?
var magicDNSSuffix: String?
var selfDNSName: String?
var tailnetIPs: [String]
var health: [String]
}
struct ProxySubscriptionNodePreview: Sendable, Identifiable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
}
struct ProxySubscriptionPreview: Sendable {
var suggestedName: String
var detectedFormat: String
var compatibleCount: Int32
var rejectedCount: Int32
var nodes: [ProxySubscriptionNodePreview]
var warnings: [String]
}
struct ProxySubscriptionNetwork: Sendable, Identifiable, Hashable {
var id: Int32
var name: String
var sourceURL: String
var detectedFormat: String
var selectedOrdinal: Int32?
var selectedName: String?
var nodes: [ProxySubscriptionNetworkNode]
var warnings: [String]
var selectedNode: ProxySubscriptionNetworkNode? {
selectedName
.flatMap { name in nodes.first { $0.name == name && $0.runtimeSupported } }
?? selectedOrdinal
.flatMap { ordinal in nodes.first { $0.ordinal == ordinal && $0.runtimeSupported } }
?? nodes.first { $0.runtimeSupported }
?? nodes.first
}
var runtimeSupportedNodes: [ProxySubscriptionNetworkNode] {
nodes.filter(\.runtimeSupported)
}
init?(network: Burrow_Network) {
guard network.type == .proxySubscription,
let payload = try? JSONDecoder().decode(StoredProxySubscriptionPayload.self, from: network.payload)
else {
return nil
}
id = network.id
name = payload.name
sourceURL = payload.source.url
detectedFormat = payload.detectedFormat
selectedOrdinal = payload.selectedOrdinal.map(Int32.init)
selectedName = payload.selectedName
nodes = payload.nodes.map(ProxySubscriptionNetworkNode.init)
warnings = payload.warnings
}
}
struct ProxySubscriptionNetworkNode: Sendable, Identifiable, Hashable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
fileprivate init(stored: StoredProxySubscriptionNode) {
ordinal = Int32(stored.ordinal)
name = stored.name
proxyProtocol = stored.proxyProtocol
server = stored.server
port = Int32(stored.port)
warnings = stored.warnings
runtimeSupported = stored.config.runtimeSupported
}
}
private struct StoredProxySubscriptionPayload: Decodable {
var name: String
var source: StoredProxySubscriptionSource
var detectedFormat: String
var selectedOrdinal: Int?
var selectedName: String?
var nodes: [StoredProxySubscriptionNode]
var warnings: [String]
enum CodingKeys: String, CodingKey {
case name
case source
case detectedFormat = "detected_format"
case selectedOrdinal = "selected_ordinal"
case selectedName = "selected_name"
case nodes
case warnings
}
}
private struct StoredProxySubscriptionSource: Decodable {
var url: String
}
private struct StoredProxySubscriptionNode: Decodable {
var ordinal: Int
var name: String
var proxyProtocol: String
var server: String
var port: Int
var warnings: [String]
var config: StoredProxySubscriptionNodeConfig
enum CodingKeys: String, CodingKey {
case ordinal
case name
case proxyProtocol = "protocol"
case server
case port
case warnings
case config
}
}
private struct StoredProxySubscriptionNodeConfig: Decodable {
var type: String
var network: StoredProxySubscriptionNetworkTransport?
var cipher: String?
var plugin: StoredJSONValue?
var udpOverTCP: Bool?
var runtimeSupported: Bool {
switch type {
case "trojan":
return network?.isTrojanTCP ?? true
case "shadowsocks":
return plugin == nil
&& udpOverTCP != true
&& Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "")
default:
return false
}
}
enum CodingKeys: String, CodingKey {
case type
case network
case cipher
case plugin
case udpOverTCP = "udp_over_tcp"
}
private static let supportedShadowsocksCiphers: Set<String> = [
"aes-128-gcm",
"aead_aes_128_gcm",
"aes-256-gcm",
"aead_aes_256_gcm",
"chacha20-ietf-poly1305",
"chacha20-poly1305",
"aead_chacha20_poly1305",
]
}
private enum StoredProxySubscriptionNetworkTransport: Decodable, Hashable {
case named(String)
case keyed(String)
var isTrojanTCP: Bool {
switch self {
case .named(let value), .keyed(let value):
return value == "tcp"
}
}
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(String.self) {
self = .named(value)
return
}
let object = try container.decode([String: StoredJSONValue].self)
self = .keyed(object.keys.first ?? "")
}
}
private enum StoredJSONValue: Decodable, Hashable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: StoredJSONValue])
case array([StoredJSONValue])
case null
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([StoredJSONValue].self) {
self = .array(value)
} else {
self = .object(try container.decode([String: StoredJSONValue].self))
}
}
}
enum TailnetDiscoveryClient {
static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
var request = Burrow_TailnetDiscoverRequest()
request.email = email
let response = try await TailnetClient.unix(socketURL: socketURL).discover(request)
return TailnetDiscoveryResponse(
domain: response.domain,
provider: response.managed ? .tailscale : .headscale,
authority: response.authority,
oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.oidcIssuer
)
}
}
enum TailnetAuthorityProbeClient {
static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus {
var request = Burrow_TailnetProbeRequest()
request.authority = authority
let response = try await TailnetClient.unix(socketURL: socketURL).probe(request)
return TailnetAuthorityProbeStatus(
authority: response.authority,
statusCode: Int(response.statusCode),
summary: response.summary,
detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.detail
)
}
}
enum TailnetLoginClient {
static func start(
accountName: String,
identityName: String,
hostname: String?,
authority: String,
socketURL: URL
) async throws -> TailnetLoginStatus {
var request = Burrow_TailnetLoginStartRequest()
request.accountName = accountName
request.identityName = identityName
request.hostname = hostname ?? ""
request.authority = authority
let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request)
return decode(response)
}
static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus {
var request = Burrow_TailnetLoginStatusRequest()
request.sessionID = sessionID
let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request)
return decode(response)
}
static func cancel(sessionID: String, socketURL: URL) async throws {
var request = Burrow_TailnetLoginCancelRequest()
request.sessionID = sessionID
_ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request)
}
private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus {
TailnetLoginStatus(
sessionID: response.sessionID,
backendState: response.backendState,
authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)),
running: response.running,
needsLogin: response.needsLogin,
tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.tailnetName,
magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.magicDNSSuffix,
selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.selfDNSName,
tailnetIPs: response.tailnetIPs,
health: response.health
)
}
}
enum ProxySubscriptionImportClient {
static func preview(url: String, socketURL: URL) async throws -> ProxySubscriptionPreview {
var request = Burrow_ProxySubscriptionImportRequest()
request.url = url
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.previewImport(request)
return ProxySubscriptionPreview(
suggestedName: response.suggestedName,
detectedFormat: response.detectedFormat,
compatibleCount: response.compatibleCount,
rejectedCount: response.rejectedCount,
nodes: response.node.map { node in
ProxySubscriptionNodePreview(
ordinal: node.ordinal,
name: node.name,
proxyProtocol: node.`protocol`,
server: node.server,
port: node.port,
warnings: node.warnings,
runtimeSupported: node.runtimeSupported
)
},
warnings: response.warnings
)
}
static func apply(
id: Int32,
url: String,
name: String,
selectedOrdinal: Int32?,
socketURL: URL
) async throws -> Int32 {
var request = Burrow_ProxySubscriptionApplyRequest()
request.id = id
request.url = url
request.name = name
request.selectedOrdinal = selectedOrdinal ?? -1
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.applyImport(request)
return response.id
}
static func selectNode(
id: Int32,
selectedOrdinal: Int32,
socketURL: URL
) async throws -> Int32 {
var request = Burrow_ProxySubscriptionSelectRequest()
request.id = id
request.selectedOrdinal = selectedOrdinal
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.selectNode(request)
return response.selectedOrdinal
}
}
private struct DaemonUnavailableError: LocalizedError {
var socketPath: String
var errorDescription: String? {
"Burrow daemon is not reachable yet. Enable the tunnel or start the daemon, then try again."
}
var failureReason: String? {
"The daemon socket does not exist at \(socketPath)."
}
}
@Observable
@MainActor
final class NetworkViewModel: Sendable {
private(set) var networks: [Burrow_Network] = []
private(set) var connectionError: String?
private let socketURLResult: Result<URL, Error>
@ObservationIgnored private var task: Task<Void, Never>?
init(socketURLResult: Result<URL, Error>) {
self.socketURLResult = socketURLResult
startStreaming()
}
deinit {
task?.cancel()
}
var cards: [NetworkCardModel] {
networks.map(Self.makeCard(for:))
}
var nonProxyCards: [NetworkCardModel] {
networks
.filter { $0.type != .proxySubscription }
.map(Self.makeCard(for:))
}
var proxySubscriptions: [ProxySubscriptionNetwork] {
networks.compactMap(ProxySubscriptionNetwork.init)
}
var nextNetworkID: Int32 {
(networks.map(\.id).max() ?? 0) + 1
}
func addWireGuardNetwork(configText: String) async throws -> Int32 {
try await addNetwork(type: .wireGuard, payload: Data(configText.utf8))
}
func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 {
try await addNetwork(type: .tailnet, payload: payload.encoded())
}
func previewProxySubscription(url: String) async throws -> ProxySubscriptionPreview {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.preview(url: url, socketURL: socketURL)
}
func importProxySubscription(url: String, name: String, selectedOrdinal: Int32?) async throws -> Int32 {
let socketURL = try daemonSocketURL()
let networkID = nextNetworkID
return try await ProxySubscriptionImportClient.apply(
id: networkID,
url: url,
name: name,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func updateProxySubscriptionSelection(
network: ProxySubscriptionNetwork,
selectedOrdinal: Int32
) async throws -> Int32 {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.selectNode(
id: network.id,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func updateActiveProxySubscriptionSelection(
network: ProxySubscriptionNetwork,
selectedOrdinal: Int32
) async throws -> Int32 {
let socketURL = try Constants.packetTunnelSocketURL
return try await ProxySubscriptionImportClient.selectNode(
id: network.id,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func refreshProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.apply(
id: network.id,
url: network.sourceURL,
name: network.name,
selectedOrdinal: network.selectedNode?.ordinal,
socketURL: socketURL
)
}
func refreshActiveProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 {
let socketURL = try Constants.packetTunnelSocketURL
return try await ProxySubscriptionImportClient.apply(
id: network.id,
url: network.sourceURL,
name: network.name,
selectedOrdinal: network.selectedNode?.ordinal,
socketURL: socketURL
)
}
func deleteNetwork(id: Int32) async throws {
let socketURL = try daemonSocketURL()
var request = Burrow_NetworkDeleteRequest()
request.id = id
_ = try await NetworksClient.unix(socketURL: socketURL).networkDelete(request)
}
func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse {
let socketURL = try daemonSocketURL()
return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL)
}
func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus {
let socketURL = try daemonSocketURL()
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
}
func startTailnetLogin(
accountName: String,
identityName: String,
hostname: String?,
authority: String
) async throws -> TailnetLoginStatus {
let socketURL = try daemonSocketURL()
return try await TailnetLoginClient.start(
accountName: accountName,
identityName: identityName,
hostname: hostname,
authority: authority,
socketURL: socketURL
)
}
func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus {
let socketURL = try daemonSocketURL()
return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL)
}
func cancelTailnetLogin(sessionID: String) async throws {
let socketURL = try daemonSocketURL()
try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL)
}
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
let socketURL = try daemonSocketURL()
let networkID = nextNetworkID
let request = Burrow_Network.with {
$0.id = networkID
$0.type = type
$0.payload = payload
}
let client = NetworksClient.unix(socketURL: socketURL)
_ = try await client.networkAdd(request)
return networkID
}
private func daemonSocketURL() throws -> URL {
try Self.daemonSocketURL(from: socketURLResult)
}
private static func daemonSocketURL(from result: Result<URL, Error>) throws -> URL {
let socketURL = try result.get()
let socketPath = socketURL.path(percentEncoded: false)
guard FileManager.default.fileExists(atPath: socketPath) else {
throw DaemonUnavailableError(socketPath: socketPath)
}
return socketURL
}
private func startStreaming() {
task?.cancel()
let socketURLResult = self.socketURLResult
task = Task { [weak self] in
do {
let socketURL = try Self.daemonSocketURL(from: socketURLResult)
let client = NetworksClient.unix(socketURL: socketURL)
for try await response in client.networkList(.init()) {
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.networks = response.network
self.connectionError = nil
}
}
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.connectionError = error.localizedDescription
}
}
}
}
private static func makeCard(for network: Burrow_Network) -> NetworkCardModel {
switch network.type {
case .wireGuard:
WireGuardCard(network: network).card
case .tailnet:
TailnetCard(network: network).card
case .trojan:
unsupportedCard(
id: network.id,
title: "Legacy Trojan Proxy",
detail: "Unsupported SOCKS-era profile. Import a proxy subscription instead."
)
case .proxySubscription:
proxySubscriptionCard(network: network)
case .UNRECOGNIZED(let rawValue):
unsupportedCard(
id: network.id,
title: "Unknown Network",
detail: "Type \(rawValue) is not recognized by this build."
)
@unknown default:
unsupportedCard(
id: network.id,
title: "Unsupported Network",
detail: "Update Burrow to view this network."
)
}
}
private static func proxySubscriptionCard(network: Burrow_Network) -> NetworkCardModel {
guard let subscription = ProxySubscriptionNetwork(network: network) else {
return unsupportedCard(
id: network.id,
title: "Proxy Subscription",
detail: "Imported proxy subscription."
)
}
return NetworkCardModel(
id: subscription.id,
backgroundColor: .teal,
label: AnyView(
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Proxy Subscription")
.font(.headline)
.foregroundStyle(.white.opacity(0.85))
Text(subscription.name)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
}
Spacer()
if let selectedNode = subscription.selectedNode {
VStack(alignment: .leading, spacing: 6) {
Text("Selected node")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
Text(selectedNode.name)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(selectedNode.proxyProtocol.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.white.opacity(0.78))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule()
.fill(.white.opacity(0.18))
)
}
Text("\(selectedNode.server):\(selectedNode.port)")
.font(.caption.monospaced())
.foregroundStyle(.white.opacity(0.78))
.lineLimit(1)
.truncationMode(.middle)
}
}
Text("\(subscription.runtimeSupportedNodes.count) usable of \(subscription.nodes.count) nodes · \(subscription.detectedFormat)")
.font(.footnote)
.foregroundStyle(.white.opacity(0.78))
.lineLimit(1)
.truncationMode(.tail)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
)
)
}
private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel {
NetworkCardModel(
id: id,
backgroundColor: .gray.opacity(0.85),
label: AnyView(
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(2)
.truncationMode(.tail)
Text(detail)
.font(.body)
.foregroundStyle(.white.opacity(0.9))
.lineLimit(4)
.truncationMode(.tail)
Spacer()
Text("Network #\(id)")
.font(.footnote.monospaced())
.foregroundStyle(.white.opacity(0.8))
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
)
)
}
}
enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
case tailscale
case headscale
case burrow
var id: String { rawValue }
var title: String {
switch self {
case .tailscale: "Tailscale"
case .headscale: "Custom Tailnet"
case .burrow: "Burrow"
}
}
var defaultAuthority: String? {
switch self {
case .tailscale:
"https://controlplane.tailscale.com"
case .headscale:
"https://ts.burrow.net"
case .burrow:
nil
}
}
var subtitle: String {
switch self {
case .tailscale:
"Managed Tailnet authority."
case .headscale:
"Custom Tailnet control server."
case .burrow:
"Burrow-native Tailnet authority."
}
}
static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider {
if explicit == .burrow {
return .burrow
}
if isManagedTailscaleAuthority(authority) {
return .tailscale
}
return .headscale
}
static func isManagedTailscaleAuthority(_ authority: String?) -> Bool {
guard let normalized = authority?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "/")),
!normalized.isEmpty
else {
return false
}
return normalized == "https://controlplane.tailscale.com"
|| normalized == "http://controlplane.tailscale.com"
|| normalized == "controlplane.tailscale.com"
}
}
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case wireGuard
case proxySubscription
case tor
case tailnet
var id: String { rawValue }
var title: String {
switch self {
case .wireGuard: "WireGuard"
case .proxySubscription: "Proxy Subscription"
case .tor: "Tor"
case .tailnet: "Tailnet"
}
}
var subtitle: String {
switch self {
case .wireGuard: "Import a tunnel and optional account metadata."
case .proxySubscription: "Import Trojan and Shadowsocks subscription nodes."
case .tor: "Store Arti account and identity preferences."
case .tailnet: "Save Tailnet authority, identity defaults, and login material."
}
}
var accentColor: Color {
switch self {
case .wireGuard: .init("WireGuard")
case .proxySubscription: .teal
case .tor: .orange
case .tailnet: .mint
}
}
var actionTitle: String {
switch self {
case .wireGuard: "Add Network"
case .proxySubscription: "Import"
case .tor: "Save Account"
case .tailnet: "Save Account"
}
}
var availabilityNote: String? {
switch self {
case .wireGuard, .proxySubscription:
nil
case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
case .tailnet:
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon."
}
}
}
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
case web
case none
case password
case preauthKey
var id: String { rawValue }
var title: String {
switch self {
case .web: "Browser Sign-In"
case .none: "None"
case .password: "Password"
case .preauthKey: "Preauth Key"
}
}
}
struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable {
let id: UUID
var kind: AccountNetworkKind
var title: String
var authority: String?
var provider: TailnetProvider?
var accountName: String
var identityName: String
var hostname: String?
var username: String?
var tailnet: String?
var authMode: AccountAuthMode
var note: String?
var createdAt: Date
var updatedAt: Date
}
struct TailnetCard {
var id: Int32
var title: String
var detail: String
init(network: Burrow_Network) {
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
id = network.id
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
detail = [
payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority,
payload?.authority,
payload.map { "Account: \($0.account)" },
]
.compactMap { $0 }
.joined(separator: " · ")
.ifEmpty("Stored Tailnet configuration")
}
var card: NetworkCardModel {
NetworkCardModel(
id: id,
backgroundColor: .mint,
label: AnyView(
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Tailnet")
.font(.headline)
.foregroundStyle(.white.opacity(0.85))
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
}
Spacer()
}
Spacer()
Text(detail)
.font(.body.monospaced())
.foregroundStyle(.white.opacity(0.92))
.lineLimit(4)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
)
)
}
}
@Observable
@MainActor
final class NetworkAccountStore {
private static let storageKey = "burrow.network-accounts"
private let defaults: UserDefaults
private(set) var accounts: [NetworkAccountRecord] = []
init(defaults: UserDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) ?? .standard) {
self.defaults = defaults
load()
}
func upsert(_ record: NetworkAccountRecord, secret: String?) throws {
if let index = accounts.firstIndex(where: { $0.id == record.id }) {
accounts[index] = record
} else {
accounts.append(record)
}
accounts.sort {
if $0.kind == $1.kind {
return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending
}
return $0.kind.rawValue < $1.kind.rawValue
}
try persist()
if let secret, !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try AccountSecretStore.store(secret, for: record.id)
} else {
try AccountSecretStore.removeSecret(for: record.id)
}
}
func delete(_ record: NetworkAccountRecord) throws {
accounts.removeAll { $0.id == record.id }
try persist()
try AccountSecretStore.removeSecret(for: record.id)
}
func hasStoredSecret(for record: NetworkAccountRecord) -> Bool {
AccountSecretStore.hasSecret(for: record.id)
}
private func load() {
guard let data = defaults.data(forKey: Self.storageKey) else {
accounts = []
return
}
do {
accounts = try JSONDecoder().decode([NetworkAccountRecord].self, from: data)
} catch {
accounts = []
}
}
private func persist() throws {
let data = try JSONEncoder().encode(accounts)
defaults.set(data, forKey: Self.storageKey)
}
}
private enum AccountSecretStore {
private static let service = "\(Constants.bundleIdentifier).accounts"
static func hasSecret(for accountID: UUID) -> Bool {
let query = baseQuery(for: accountID)
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
static func store(_ secret: String, for accountID: UUID) throws {
let data = Data(secret.utf8)
let query = baseQuery(for: accountID)
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
guard updateStatus == errSecSuccess else {
throw AccountSecretStoreError.osStatus(updateStatus)
}
return
}
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(item as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw AccountSecretStoreError.osStatus(addStatus)
}
}
static func removeSecret(for accountID: UUID) throws {
let status = SecItemDelete(baseQuery(for: accountID) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AccountSecretStoreError.osStatus(status)
}
}
private static func baseQuery(for accountID: UUID) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountID.uuidString,
]
}
}
private enum AccountSecretStoreError: LocalizedError {
case osStatus(OSStatus)
var errorDescription: String? {
switch self {
case .osStatus(let status):
if let message = SecCopyErrorMessageString(status, nil) as String? {
return message
}
return "Keychain error \(status)"
}
}
}
private extension String {
func ifEmpty(_ fallback: @autoclosure () -> String) -> String {
isEmpty ? fallback() : self
}
}