1323 lines
42 KiB
Swift
1323 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 .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
|
|
}
|
|
}
|