burrow/Apple/UI/Networks/Network.swift
JettChenT 6c6bfe5434 Remove unused NetworkType::Trojan tombstone
The Trojan network type only ever existed as a non-runnable tombstone:
the SOCKS-era Trojan runtime it guarded against (BEP-0009) was never
merged to main, and no stored network uses it. Drop the enum value and
its dead match arms across the daemon, GTK summary, and Apple bindings.

The Trojan *protocol* outbound used by proxy-subscription nodes
(TrojanOutbound / ProxyOutbound::Trojan / isTrojanTCP) is unaffected.

- proto: remove `Trojan = 2`, add `reserved 2; reserved "Trojan";`
- daemon: drop ResolvedTunnel/ActiveTunnel::Trojan variants + match arms
  (runtime.rs) and the validate_network_payload arm (database.rs)
- gtk: drop the "Legacy Trojan Proxy" summary arm
- apple: drop the makeCard .trojan arm and regenerate the Swift
  NetworkType enum (case/rawValue/init/allCases/nameMap)

An unknown stored type now surfaces via the existing UNRECOGNIZED path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:06:01 -07:00

1317 lines
42 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 smux: StoredJSONValue?
var udp: Bool?
var udpOverTCP: Bool?
var clientFingerprint: String?
var runtimeSupported: Bool {
switch type {
case "trojan":
return network?.isTrojanTCP ?? true
case "shadowsocks":
guard Self.supportedShadowsocksSmux(smux) else {
return false
}
return Self.supportedShadowsocksPlugin(plugin, clientFingerprint: clientFingerprint)
&& Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "")
default:
return false
}
}
enum CodingKeys: String, CodingKey {
case type
case network
case cipher
case plugin
case smux
case udp
case udpOverTCP = "udp_over_tcp"
case clientFingerprint = "client_fingerprint"
}
private static let supportedShadowsocksCiphers: Set<String> = [
"none",
"plain",
"table",
"rc4-md5",
"rc4",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-128-cfb1",
"aes-128-cfb8",
"aes-128-cfb128",
"aes-192-cfb",
"aes-192-cfb1",
"aes-192-cfb8",
"aes-192-cfb128",
"aes-256-cfb",
"aes-256-cfb1",
"aes-256-cfb8",
"aes-256-cfb128",
"aes-128-ofb",
"aes-192-ofb",
"aes-256-ofb",
"camellia-128-ctr",
"camellia-192-ctr",
"camellia-256-ctr",
"camellia-128-cfb",
"camellia-128-cfb1",
"camellia-128-cfb8",
"camellia-128-cfb128",
"camellia-192-cfb",
"camellia-192-cfb1",
"camellia-192-cfb8",
"camellia-192-cfb128",
"camellia-256-cfb",
"camellia-256-cfb1",
"camellia-256-cfb8",
"camellia-256-cfb128",
"camellia-128-ofb",
"camellia-192-ofb",
"camellia-256-ofb",
"chacha20",
"chacha20-ietf",
"xchacha20",
"aes-128-gcm",
"aead_aes_128_gcm",
"aes-192-gcm",
"aes-256-gcm",
"aead_aes_256_gcm",
"aes-128-ccm",
"aes-192-ccm",
"aes-256-ccm",
"aes-128-gcm-siv",
"aes-256-gcm-siv",
"chacha8-ietf-poly1305",
"chacha20-ietf-poly1305",
"chacha20-poly1305",
"aead_chacha20_poly1305",
"rabbit128-poly1305",
"xchacha8-ietf-poly1305",
"xchacha20-ietf-poly1305",
"sm4-gcm",
"sm4-ccm",
"aegis-128l",
"aegis-256",
"aez-384",
"deoxys-ii-256-128",
"ascon128",
"ascon128a",
"lea-128-gcm",
"lea-192-gcm",
"lea-256-gcm",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-aes-128-ccm",
"2022-blake3-aes-256-ccm",
"2022-blake3-chacha20-poly1305",
"2022-blake3-chacha8-poly1305",
]
private static func supportedShadowsocksPlugin(
_ plugin: StoredJSONValue?,
clientFingerprint: String?
) -> Bool {
guard let plugin else {
return true
}
guard case .object(let object) = plugin,
case .string(let name)? = object["name"]
else {
return false
}
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let opts: [String: StoredJSONValue]
if case .object(let decodedOpts)? = object["opts"] {
opts = decodedOpts
} else {
opts = [:]
}
if normalizedName == "restls",
let clientFingerprint,
Self.shadowsocksClientFingerprintIsActive(clientFingerprint)
{
return false
}
if normalizedName == "v2ray-plugin" || normalizedName == "gost-plugin" {
guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else {
return false
}
guard mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "websocket" else {
return false
}
return true
}
if normalizedName == "shadow-tls" {
let version = opts["version"]?.stringValue ?? "2"
let normalizedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
switch normalizedVersion {
case "1":
return true
case "2", "3":
if let clientFingerprint,
Self.shadowsocksClientFingerprintIsActive(clientFingerprint)
{
return false
}
return true
default:
return false
}
}
if normalizedName == "kcptun" {
guard let smuxVersion = opts["smuxver"]?.stringValue else {
return true
}
guard let version = Double(smuxVersion.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return false
}
return version >= 0
}
if normalizedName == "restls" {
let versionHint = opts["version-hint"]?.stringValue ?? ""
switch versionHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "tls12", "tls13":
return true
default:
return false
}
}
guard normalizedName == "obfs" else {
return true
}
guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else {
return false
}
switch mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "http", "tls":
return true
default:
return false
}
}
private static func shadowsocksClientFingerprintIsActive(_ value: String) -> Bool {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return [
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"chrome120",
"firefox120",
"safari16",
"chrome_psk",
"chrome_psk_shuffle",
"chrome_padding_psk_shuffle",
"chrome_pq",
"chrome_pq_psk",
"randomized",
].contains(normalized)
}
private static func supportedShadowsocksSmux(_ smux: StoredJSONValue?) -> Bool {
guard case .object(let object)? = smux else {
return true
}
guard object["enabled"]?.boolValue ?? false else {
return true
}
let protocolName = object["protocol"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? "h2mux"
guard protocolName == "h2mux" || protocolName == "smux" || protocolName == "yamux" else {
return false
}
return true
}
}
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
var stringValue: String? {
switch self {
case .string(let value):
return value
case .number(let value):
if value.rounded(.towardZero) == value {
return String(Int(value))
}
return String(value)
case .bool(let value):
return String(value)
default:
return nil
}
}
var boolValue: Bool? {
switch self {
case .bool(let value):
return value
case .string(let value):
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "y":
return true
case "0", "false", "no", "n":
return false
default:
return nil
}
case .number(let value):
return value != 0
default:
return nil
}
}
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 .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
}
}