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 = [ "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 @ObservationIgnored private var task: Task? init(socketURLResult: Result) { 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) 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 } }