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? } 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 ) } } @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 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 discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { let socketURL = try socketURLResult.get() return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) } func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { let socketURL = try socketURLResult.get() return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) } private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { let socketURL = try socketURLResult.get() 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 startStreaming() { task?.cancel() let socketURLResult = self.socketURLResult task = Task { [weak self] in do { let socketURL = try socketURLResult.get() 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 .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 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) Text(detail) .font(.body) .foregroundStyle(.white.opacity(0.9)) 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: "Headscale" 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 tor case tailnet var id: String { rawValue } var title: String { switch self { case .wireGuard: "WireGuard" case .tor: "Tor" case .tailnet: "Tailnet" } } var subtitle: String { switch self { case .wireGuard: "Import a tunnel and optional account metadata." case .tor: "Store Arti account and identity preferences." case .tailnet: "Save Tailnet authority, identity, and login material." } } var accentColor: Color { switch self { case .wireGuard: .init("WireGuard") case .tor: .orange case .tailnet: .mint } } var actionTitle: String { switch self { case .wireGuard: "Add Network" case .tor: "Save Account" case .tailnet: "Save Account" } } var availabilityNote: String? { switch self { case .wireGuard: 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 be stored in the daemon." } } } enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { case none case password case preauthKey var id: String { rawValue } var title: String { switch self { 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 } }