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.
1085 lines
35 KiB
Swift
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
|
|
}
|
|
}
|