Add Tailnet accounts and Tailscale login flow

This commit is contained in:
Conrad Kramer 2026-03-31 12:52:21 -07:00
parent f9062eae33
commit 7670a75840
29 changed files with 3538 additions and 775 deletions

View file

@ -6,6 +6,8 @@ import SwiftUI
@main
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var windowController: NSWindowController?
private let quitItem: NSMenuItem = {
let quitItem = NSMenuItem(
title: "Quit Burrow",
@ -17,6 +19,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return quitItem
}()
private lazy var openItem: NSMenuItem = {
let item = NSMenuItem(
title: "Open Burrow",
action: #selector(openWindow),
keyEquivalent: "o"
)
item.target = self
item.keyEquivalentModifierMask = .command
return item
}()
private let toggleItem: NSMenuItem = {
let toggleView = NSHostingView(rootView: MenuItemToggleView())
toggleView.frame.size = CGSize(width: 300, height: 32)
@ -31,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let menu = NSMenu()
menu.items = [
toggleItem,
openItem,
.separator(),
quitItem
]
@ -49,5 +63,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem.menu = menu
}
@objc
private func openWindow() {
if let window = windowController?.window {
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
return
}
let contentView = BurrowView()
let hostingController = NSHostingController(rootView: contentView)
let window = NSWindow(contentViewController: hostingController)
window.title = "Burrow"
window.setContentSize(NSSize(width: 820, height: 720))
window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable])
window.center()
let controller = NSWindowController(window: window)
controller.shouldCascadeWindows = true
controller.showWindow(nil)
windowController = controller
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
#endif

View file

@ -23,7 +23,6 @@
D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; };
D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; };
D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; };
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; };
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; };
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; };
@ -33,7 +32,6 @@
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; };
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; };
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; };
D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; };
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; };
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; };
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; };
@ -160,7 +158,6 @@
D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = "<group>"; };
D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = "<group>"; };
D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = "<group>"; };
D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = "<group>"; };
D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -171,7 +168,6 @@
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = "<group>"; };
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = "<group>"; };
D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = "<group>"; };
D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = "<group>"; };
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = "<group>"; };
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = "<group>"; };
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = "<group>"; };
@ -340,7 +336,6 @@
D0D4E4A02C8D921A007F820A /* Networks */ = {
isa = PBXGroup;
children = (
D0D4E49D2C8D921A007F820A /* HackClub.swift */,
D0D4E49E2C8D921A007F820A /* Network.swift */,
D0D4E49F2C8D921A007F820A /* WireGuard.swift */,
);
@ -358,7 +353,6 @@
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */,
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */,
D0D4E4A82C8D921A007F820A /* NetworkView.swift */,
D0D4E4A92C8D921A007F820A /* OAuth2.swift */,
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */,
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */,
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */,
@ -634,7 +628,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */,
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */,
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */,
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */,
@ -644,7 +637,6 @@
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */,
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */,
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */,
D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */,
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */,
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */,
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */,

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x50",
"green" : "0x37",
"red" : "0xEC"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "flag-standalone-wtransparent.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,67 +1,786 @@
import AuthenticationServices
import BurrowConfiguration
import Foundation
import SwiftUI
#if !os(macOS)
public struct BurrowView: View {
@Environment(\.webAuthenticationSession)
private var webAuthenticationSession
@State private var networkViewModel: NetworkViewModel
@State private var accountStore = NetworkAccountStore()
@State private var activeSheet: ConfigurationSheet?
@State private var didRunAutomation = false
public var body: some View {
NavigationStack {
VStack {
HStack {
Text("Networks")
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
Menu {
Button("Hack Club", action: addHackClubNetwork)
Button("WireGuard", action: addWireGuardNetwork)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
.accessibilityLabel("Add")
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("Burrow")
.font(.largeTitle)
.fontWeight(.bold)
Text("Networks and accounts")
.font(.headline)
.foregroundStyle(.secondary)
}
Spacer()
Menu {
Button("Add WireGuard Network") {
activeSheet = .wireGuard
}
Button("Save Tor Account") {
activeSheet = .tor
}
Button("Add Tailnet Account") {
activeSheet = .tailnet
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
.accessibilityLabel("Add")
}
}
.padding(.top)
VStack(alignment: .leading, spacing: 12) {
sectionHeader(
title: "Networks",
detail: "Stored daemon networks and their active account selectors"
)
if let connectionError = networkViewModel.connectionError {
Text(connectionError)
.font(.footnote)
.foregroundStyle(.secondary)
}
NetworkCarouselView(networks: networkViewModel.cards)
}
VStack(alignment: .leading, spacing: 12) {
sectionHeader(
title: "Accounts",
detail: "Per-network identities and sign-in state"
)
if accountStore.accounts.isEmpty {
ContentUnavailableView(
"No Accounts Yet",
systemImage: "person.crop.circle.badge.plus",
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
)
.frame(maxWidth: .infinity, minHeight: 180)
} else {
LazyVStack(spacing: 12) {
ForEach(accountStore.accounts) { account in
AccountRowView(
account: account,
hasSecret: accountStore.hasStoredSecret(for: account)
)
}
}
}
}
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
title: "Tunnel",
detail: "Current system extension state"
)
TunnelStatusView()
TunnelButton()
.padding(.bottom)
}
}
.padding(.top)
NetworkCarouselView()
Spacer()
TunnelStatusView()
TunnelButton()
.padding(.bottom)
.padding()
}
.padding()
.handleOAuth2Callback()
}
.sheet(item: $activeSheet) { sheet in
ConfigurationSheetView(
sheet: sheet,
networkViewModel: networkViewModel,
accountStore: accountStore
)
}
.onAppear {
runAutomationIfNeeded()
}
}
public init() {
_networkViewModel = State(
initialValue: NetworkViewModel(
socketURLResult: Result { try Constants.socketURL }
)
)
}
private func addHackClubNetwork() {
Task {
try await authenticateWithSlack()
private func runAutomationIfNeeded() {
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else {
return
}
didRunAutomation = true
activeSheet = .tailnet
}
@ViewBuilder
private func sectionHeader(title: String, detail: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2.weight(.semibold))
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private enum ConfigurationSheet: String, Identifiable {
case wireGuard
case tor
case tailnet
var id: String { rawValue }
var kind: AccountNetworkKind {
switch self {
case .wireGuard: .wireGuard
case .tor: .tor
case .tailnet: .headscale
}
}
}
private struct AccountDraft {
var title = ""
var accountName = ""
var identityName = ""
var wireGuardConfig = ""
var tailnetProvider: TailnetProvider = .tailscale
var authority = ""
var tailnet = ""
var hostname = ProcessInfo.processInfo.hostName
var username = ""
var secret = ""
var authMode: AccountAuthMode = .web
var torAddresses = "100.64.0.2/32"
var torDNS = "1.1.1.1, 1.0.0.1"
var torMTU = "1400"
var torListen = "127.0.0.1:9040"
init(sheet: ConfigurationSheet) {
switch sheet {
case .wireGuard:
break
case .tor:
title = "Default Tor"
accountName = "default"
identityName = "apple"
case .tailnet:
title = "Tailnet"
accountName = "default"
identityName = "apple"
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
}
}
}
private struct ConfigurationSheetView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
let sheet: ConfigurationSheet
let networkViewModel: NetworkViewModel
let accountStore: NetworkAccountStore
@State private var draft: AccountDraft
@State private var isSubmitting = false
@State private var errorMessage: String?
@State private var loginSessionID: String?
@State private var loginStatus: TailnetLoginStatus?
@State private var pollingTask: Task<Void, Never>?
@State private var didRunAutomation = false
init(
sheet: ConfigurationSheet,
networkViewModel: NetworkViewModel,
accountStore: NetworkAccountStore
) {
self.sheet = sheet
self.networkViewModel = networkViewModel
self.accountStore = accountStore
_draft = State(initialValue: AccountDraft(sheet: sheet))
}
var body: some View {
NavigationStack {
Form {
Section {
Text(sheet.kind.subtitle)
.font(.callout)
.foregroundStyle(.secondary)
if let availabilityNote = sheet.kind.availabilityNote {
Text(availabilityNote)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Identity") {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
}
}
switch sheet {
case .wireGuard:
Section("WireGuard Configuration") {
TextEditor(text: $draft.wireGuardConfig)
.font(.body.monospaced())
.frame(minHeight: 220)
}
case .tor:
Section("Tor Preferences") {
TextField("Virtual Addresses", text: $draft.torAddresses)
TextField("DNS Resolvers", text: $draft.torDNS)
TextField("MTU", text: $draft.torMTU)
TextField("Transparent Listener", text: $draft.torListen)
}
case .tailnet:
tailnetSections
}
if let errorMessage {
Section {
Text(errorMessage)
.foregroundStyle(.red)
}
}
}
.navigationTitle(sheet.kind.title)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(confirmationTitle) {
submit()
}
.disabled(isSubmitting || submissionDisabled)
}
}
}
.frame(minWidth: 520, minHeight: 620)
.onAppear {
runAutomationIfNeeded()
}
.onDisappear {
pollingTask?.cancel()
}
}
private func addWireGuardNetwork() {
@ViewBuilder
private var tailnetSections: some View {
Section("Tailnet Provider") {
Picker("Provider", selection: $draft.tailnetProvider) {
ForEach(TailnetProvider.allCases) { provider in
Text(provider.title).tag(provider)
}
}
Text(draft.tailnetProvider.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Tailnet") {
if draft.tailnetProvider.requiresControlURL {
TextField("Server URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
}
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
if draft.tailnetProvider.usesWebLogin {
Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
TextField("Username", text: $draft.username)
.burrowLoginField()
.autocorrectionDisabled()
Picker("Authentication", selection: $draft.authMode) {
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
Text(mode.title).tag(mode)
}
}
if draft.authMode != .none {
SecureField(
draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret
)
}
}
}
if draft.tailnetProvider.usesWebLogin {
Section("Tailscale Sign-In") {
if let loginStatus {
labeledValue("State", loginStatus.backendState)
if let tailnetName = loginStatus.tailnetName {
labeledValue("Tailnet", tailnetName)
}
if let dnsName = loginStatus.selfDNSName {
labeledValue("Device", dnsName)
}
if !loginStatus.tailscaleIPs.isEmpty {
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
}
if let authURL = loginStatus.authURL {
labeledValue("Login URL", authURL)
Button("Open Login Page") {
if let url = URL(string: authURL) {
openURL(url)
}
}
}
if !loginStatus.health.isEmpty {
Text(loginStatus.health.joined(separator: ""))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
private func authenticateWithSlack() async throws {
guard
let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"),
let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"),
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
let session = OAuth2.Session(
authorizationEndpoint: authorizationEndpoint,
tokenEndpoint: tokenEndpoint,
redirectURI: redirectURI,
scopes: ["openid", "profile"],
clientID: "2210535565.6884042183125",
clientSecret: "2793c8a5255cae38830934c664eeb62d"
)
let response = try await session.authorize(webAuthenticationSession)
private var confirmationTitle: String {
switch sheet {
case .wireGuard:
return "Add Network"
case .tor:
return "Save Account"
case .tailnet:
if draft.tailnetProvider.usesWebLogin {
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
}
return "Save Account"
}
}
private var submissionDisabled: Bool {
switch sheet {
case .wireGuard:
return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .tor:
return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil
case .tailnet:
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
return true
}
if draft.tailnetProvider.usesWebLogin {
return false
}
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
return true
}
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
return true
}
return false
}
}
private func submit() {
isSubmitting = true
errorMessage = nil
Task { @MainActor in
defer { isSubmitting = false }
do {
switch sheet {
case .wireGuard:
try await submitWireGuard()
dismiss()
case .tor:
try submitTor()
dismiss()
case .tailnet:
try await submitTailnet()
}
} catch {
errorMessage = error.localizedDescription
}
}
}
private func submitWireGuard() async throws {
let networkID = try await networkViewModel.addWireGuardNetwork(
configText: draft.wireGuardConfig
)
let title = titleOrFallback("WireGuard \(networkID)")
let record = NetworkAccountRecord(
id: UUID(),
kind: .wireGuard,
title: title,
authority: nil,
provider: nil,
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "network-\(networkID)"),
hostname: nil,
username: nil,
tailnet: nil,
authMode: .none,
note: "Linked to daemon network #\(networkID).",
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: nil)
}
private func submitTor() throws {
let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))")
let note = [
"Addresses: \(csvSummary(draft.torAddresses))",
"DNS: \(csvSummary(draft.torDNS))",
"MTU: \(normalized(draft.torMTU, fallback: "1400"))",
"Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))",
].joined(separator: "")
let record = NetworkAccountRecord(
id: UUID(),
kind: .tor,
title: title,
authority: "arti://local",
provider: nil,
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: nil,
username: nil,
tailnet: nil,
authMode: .none,
note: note,
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: nil)
}
private func submitTailnet() async throws {
if draft.tailnetProvider.usesWebLogin {
if loginStatus?.running == true {
try await saveTailnetAccount(secret: nil, username: nil)
dismiss()
} else {
try await startTailscaleLogin()
}
return
}
let secret = draft.authMode == .none ? nil : draft.secret
let username = normalizedOptional(draft.username)
try await saveTailnetAccount(secret: secret, username: username)
dismiss()
}
private func startTailscaleLogin() async throws {
let response = try await TailnetBridgeClient.startLogin(
TailnetLoginStartRequest(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: normalizedOptional(draft.hostname),
controlURL: draft.tailnetProvider.defaultAuthority
)
)
loginSessionID = response.sessionID
loginStatus = response.status
if let authURL = response.status.authURL, let url = URL(string: authURL) {
openLoginURL(url)
}
startPollingTailscaleLogin()
}
private func runAutomationIfNeeded() {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin
else {
return
}
didRunAutomation = true
draft.tailnetProvider = .tailscale
draft.title = automation.title ?? draft.title
draft.accountName = automation.accountName ?? draft.accountName
draft.identityName = automation.identityName ?? draft.identityName
draft.hostname = automation.hostname ?? draft.hostname
Task { @MainActor in
do {
try await startTailscaleLogin()
} catch {
errorMessage = error.localizedDescription
}
}
}
private func startPollingTailscaleLogin() {
pollingTask?.cancel()
guard let loginSessionID else { return }
pollingTask = Task { @MainActor in
while !Task.isCancelled {
do {
let status = try await TailnetBridgeClient.status(sessionID: loginSessionID)
let previousAuthURL = loginStatus?.authURL
loginStatus = status
if previousAuthURL == nil,
let authURL = status.authURL,
let url = URL(string: authURL)
{
openLoginURL(url)
}
if status.running {
return
}
} catch {
errorMessage = error.localizedDescription
return
}
try? await Task.sleep(for: .seconds(2))
}
}
}
private func openLoginURL(_ url: URL) {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
openURL(url) { accepted in
guard !accepted else { return }
errorMessage = "Burrow got a Tailscale login URL, but iOS did not open it automatically."
}
}
}
private func saveTailnetAccount(secret: String?, username: String?) async throws {
let provider = draft.tailnetProvider
let title = titleOrFallback(
hostnameFallback(
from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
fallback: provider.title
)
)
let payload = TailnetNetworkPayload(
provider: provider,
authority: normalizedOptional(provider.defaultAuthority ?? draft.authority),
account: normalized(draft.accountName, fallback: "default"),
identity: normalized(draft.identityName, fallback: "apple"),
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
hostname: normalizedOptional(draft.hostname)
)
var noteParts: [String] = [
provider.title,
provider.usesWebLogin
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
: "Auth: \(draft.authMode.title)",
]
if let dnsName = loginStatus?.selfDNSName {
noteParts.append("Device: \(dnsName)")
}
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
noteParts.append("MagicDNS: \(magicDNSSuffix)")
}
do {
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
noteParts.append("Linked to daemon network #\(networkID)")
} catch {
noteParts.append("Daemon network add pending")
}
let record = NetworkAccountRecord(
id: UUID(),
kind: .headscale,
title: title,
authority: payload.authority,
provider: provider,
accountName: payload.account,
identityName: payload.identity,
hostname: payload.hostname,
username: username,
tailnet: payload.tailnet,
authMode: provider.usesWebLogin ? .web : draft.authMode,
note: noteParts.joined(separator: ""),
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: secret)
}
private func normalized(_ value: String, fallback: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
private func normalizedOptional(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func titleOrFallback(_ fallback: String) -> String {
normalized(draft.title, fallback: fallback)
}
private func csvSummary(_ value: String) -> String {
value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
private func hostnameFallback(from value: String, fallback: String) -> String {
guard let url = URL(string: value), let host = url.host, !host.isEmpty else {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
return host
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.body.monospaced())
}
}
}
private struct AccountRowView: View {
let account: NetworkAccountRecord
let hasSecret: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(account.title)
.font(.headline)
HStack(spacing: 8) {
Text(account.kind.title)
if let provider = account.provider {
Text(provider.title)
}
}
.font(.subheadline)
.foregroundStyle(account.kind.accentColor)
}
Spacer()
if hasSecret {
Label("Credential stored", systemImage: "key.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let authority = account.authority {
labeledValue("Authority", authority)
}
labeledValue("Account", account.accountName)
labeledValue("Identity", account.identityName)
if let hostname = account.hostname {
labeledValue("Hostname", hostname)
}
if let username = account.username {
labeledValue("Username", username)
}
if let tailnet = account.tailnet {
labeledValue("Tailnet", tailnet)
}
if let note = account.note {
Text(note)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.body.monospaced())
}
}
}
private extension View {
@ViewBuilder
func burrowLoginField() -> some View {
#if os(iOS)
textInputAutocapitalization(.never)
#else
self
#endif
}
}
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
}
let action: Action
let title: String?
let accountName: String?
let identityName: String?
let hostname: String?
static let current: BurrowAutomationConfig? = {
let environment = ProcessInfo.processInfo.environment
guard let rawAction = environment["BURROW_UI_AUTOMATION"],
let action = Action(rawValue: rawAction)
else {
return nil
}
return BurrowAutomationConfig(
action: action,
title: environment["BURROW_UI_AUTOMATION_TITLE"],
accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"],
identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"],
hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"]
)
}()
}
#if DEBUG
@ -72,4 +791,3 @@ struct NetworkView_Previews: PreviewProvider {
}
}
#endif
#endif

View file

@ -1,39 +1,45 @@
import SwiftUI
struct NetworkCarouselView: View {
var networks: [any Network] = [
HackClub(id: 1),
HackClub(id: 2),
WireGuard(id: 4),
HackClub(id: 5)
]
var networks: [NetworkCardModel]
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks, id: \.id) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
Group {
if networks.isEmpty {
ContentUnavailableView(
"No Networks Yet",
systemImage: "network.slash",
description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.")
)
.frame(maxWidth: .infinity, minHeight: 175)
} else {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
}
}
}
}
.scrollTargetLayout()
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
#if DEBUG
struct NetworkCarouselView_Previews: PreviewProvider {
static var previews: some View {
NetworkCarouselView()
NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card])
}
}
#endif

View file

@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel {
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = bundleIdentifier
proto.serverAddress = "hackclub.com"
proto.serverAddress = "burrow.rs"
manager.protocolConfiguration = proto
try await manager.save()

View file

@ -31,8 +31,8 @@ struct NetworkView<Content: View>: View {
}
extension NetworkView where Content == AnyView {
init(network: any Network) {
init(network: NetworkCardModel) {
color = network.backgroundColor
content = { AnyView(network.label) }
content = { network.label }
}
}

View file

@ -1,27 +0,0 @@
import BurrowCore
import SwiftUI
struct HackClub: Network {
typealias NetworkType = Burrow_WireGuardNetwork
static let type: Burrow_NetworkType = .hackClub
var id: Int32
var backgroundColor: Color { .init("HackClub") }
@MainActor var label: some View {
GeometryReader { reader in
VStack(alignment: .leading) {
Image("HackClub")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: reader.size.height / 4)
Spacer()
Text("@conradev")
.foregroundStyle(.white)
.font(.body.monospaced())
}
.padding()
.frame(maxWidth: .infinity)
}
}
}

View file

@ -1,36 +1,539 @@
import Atomics
import BurrowConfiguration
import BurrowCore
import Foundation
import Security
import SwiftProtobuf
import SwiftUI
protocol Network {
associatedtype NetworkType: Message
associatedtype Label: View
struct NetworkCardModel: Identifiable {
let id: Int32
let backgroundColor: Color
let label: AnyView
}
static var type: Burrow_NetworkType { get }
struct TailnetNetworkPayload: Codable, Sendable {
var provider: TailnetProvider
var authority: String?
var account: String
var identity: String
var tailnet: String?
var hostname: String?
var id: Int32 { get }
var backgroundColor: Color { get }
func encoded() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(self)
}
}
@MainActor var label: Label { get }
struct TailnetLoginStartRequest: Codable, Sendable {
var accountName: String
var identityName: String
var hostname: String?
var controlURL: String?
}
struct TailnetLoginStatus: Codable, Sendable {
var backendState: String
var authURL: String?
var running: Bool
var needsLogin: Bool
var tailnetName: String?
var magicDNSSuffix: String?
var selfDNSName: String?
var tailscaleIPs: [String]
var health: [String]
}
struct TailnetLoginStartResponse: Codable, Sendable {
var sessionID: String
var status: TailnetLoginStatus
}
enum TailnetBridgeClient {
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse {
var urlRequest = URLRequest(
url: baseURL.appendingPathComponent("v1/tailscale/login/start")
)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
urlRequest.httpBody = try encoder.encode(request)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
try validate(response: response, data: data)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(TailnetLoginStartResponse.self, from: data)
}
static func status(sessionID: String) async throws -> TailnetLoginStatus {
let url = baseURL
.appendingPathComponent("v1/tailscale/login")
.appendingPathComponent(sessionID)
let (data, response) = try await URLSession.shared.data(from: url)
try validate(response: response, data: data)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(TailnetLoginStatus.self, from: data)
}
private static func validate(response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines
)
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
}
}
}
enum TailnetBridgeError: LocalizedError {
case server(String)
var errorDescription: String? {
switch self {
case .server(let message):
message
}
}
}
@Observable
@MainActor
final class NetworkViewModel: Sendable {
private(set) var networks: [Burrow_Network] = []
private(set) var connectionError: String?
private let socketURLResult: Result<URL, Error>
private var task: Task<Void, Error>!
nonisolated(unsafe) private var task: Task<Void, Never>?
init(socketURL: URL) {
init(socketURLResult: Result<URL, Error>) {
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())
}
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
let client = NetworksClient.unix(socketURL: socketURL)
for try await networks in client.networkList(.init()) {
guard let viewModel = self else { continue }
Task { @MainActor in
viewModel.networks = networks.network
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 usesWebLogin: Bool {
self == .tailscale
}
var requiresControlURL: Bool {
self != .tailscale
}
var defaultAuthority: String? {
switch self {
case .tailscale:
"https://controlplane.tailscale.com"
case .headscale, .burrow:
nil
}
}
var subtitle: String {
switch self {
case .tailscale:
"Use Tailscale's real browser login flow."
case .headscale:
"Store a Headscale control-plane endpoint and credentials."
case .burrow:
"Store Burrow control-plane credentials."
}
}
}
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case wireGuard
case tor
case headscale
var id: String { rawValue }
var title: String {
switch self {
case .wireGuard: "WireGuard"
case .tor: "Tor"
case .headscale: "Tailnet"
}
}
var subtitle: String {
switch self {
case .wireGuard: "Import a tunnel and optional account metadata."
case .tor: "Store Arti account and identity preferences."
case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities."
}
}
var accentColor: Color {
switch self {
case .wireGuard: .init("WireGuard")
case .tor: .orange
case .headscale: .mint
}
}
var actionTitle: String {
switch self {
case .wireGuard: "Add Network"
case .tor: "Save Account"
case .headscale: "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 .headscale:
"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 web
case password
case preauthKey
var id: String { rawValue }
var title: String {
switch self {
case .none: "None"
case .web: "Web Login"
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 provider: String
var title: String
var detail: String
init(network: Burrow_Network) {
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
id = network.id
provider = payload?.provider.title ?? "Tailnet"
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
detail = [
payload?.provider.title,
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(provider)
.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
}
}

View file

@ -1,14 +1,40 @@
import BurrowCore
import Foundation
import SwiftUI
struct WireGuard: Network {
typealias NetworkType = Burrow_WireGuardNetwork
static let type: BurrowCore.Burrow_NetworkType = .wireGuard
struct WireGuardCard {
var id: Int32
var backgroundColor: Color { .init("WireGuard") }
var title: String
var detail: String
@MainActor var label: some View {
init(id: Int32, title: String = "WireGuard", detail: String = "Stored configuration") {
self.id = id
self.title = title
self.detail = detail
}
init(network: Burrow_Network) {
let payload = String(data: network.payload, encoding: .utf8) ?? ""
let address = Self.firstValue(for: "Address", in: payload)
let endpoint = Self.firstValue(for: "Endpoint", in: payload)
self.id = network.id
self.title = "WireGuard"
self.detail = [address, endpoint]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " · ")
.ifEmpty("Stored configuration")
}
var card: NetworkCardModel {
NetworkCardModel(
id: id,
backgroundColor: .init("WireGuard"),
label: AnyView(label)
)
}
private var label: some View {
GeometryReader { reader in
VStack(alignment: .leading) {
HStack {
@ -23,12 +49,29 @@ struct WireGuard: Network {
}
.frame(maxWidth: .infinity, maxHeight: reader.size.height / 4)
Spacer()
Text("@conradev")
Text(detail)
.foregroundStyle(.white)
.font(.body.monospaced())
.lineLimit(3)
}
.padding()
.frame(maxWidth: .infinity)
}
}
private static func firstValue(for key: String, in config: String) -> String? {
config
.split(whereSeparator: \.isNewline)
.map(String.init)
.first(where: { $0.hasPrefix("\(key) = ") })?
.split(separator: "=", maxSplits: 1)
.last
.map { $0.trimmingCharacters(in: .whitespaces) }
}
}
private extension String {
func ifEmpty(_ fallback: @autoclosure () -> String) -> String {
isEmpty ? fallback() : self
}
}

View file

@ -1,293 +0,0 @@
import AuthenticationServices
import Foundation
import os
import SwiftUI
enum OAuth2 {
enum Error: Swift.Error {
case unknown
case invalidAuthorizationURL
case invalidCallbackURL
case invalidRedirectURI
}
struct Credential {
var accessToken: String
var refreshToken: String?
var expirationDate: Date?
}
struct Session {
var authorizationEndpoint: URL
var tokenEndpoint: URL
var redirectURI: URL
var responseType = OAuth2.ResponseType.code
var scopes: Set<String>
var clientID: String
var clientSecret: String
fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation<URL, Swift.Error>]> = {
.init(initialState: [:])
}()
fileprivate static func handle(url: URL) {
let continuations = queue.withLock { continuations in
let copy = continuations
continuations.removeAll()
return copy
}
for (_, continuation) in continuations {
continuation.resume(returning: url)
}
}
init(
authorizationEndpoint: URL,
tokenEndpoint: URL,
redirectURI: URL,
scopes: Set<String>,
clientID: String,
clientSecret: String
) {
self.authorizationEndpoint = authorizationEndpoint
self.tokenEndpoint = tokenEndpoint
self.redirectURI = redirectURI
self.scopes = scopes
self.clientID = clientID
self.clientSecret = clientSecret
}
private var authorizationURL: URL {
get throws {
var queryItems: [URLQueryItem] = [
.init(name: "client_id", value: clientID),
.init(name: "response_type", value: responseType.rawValue),
.init(name: "redirect_uri", value: redirectURI.absoluteString)
]
if !scopes.isEmpty {
queryItems.append(.init(name: "scope", value: scopes.joined(separator: ",")))
}
guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidAuthorizationURL
}
components.queryItems = queryItems
guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL }
return authorizationURL
}
}
private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse {
switch responseType {
case .code:
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidCallbackURL
}
return try await handle(response: try components.decode(OAuth2.CodeResponse.self))
default:
throw OAuth2.Error.invalidCallbackURL
}
}
private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse {
var components = URLComponents()
components.queryItems = [
.init(name: "client_id", value: clientID),
.init(name: "client_secret", value: clientSecret),
.init(name: "grant_type", value: GrantType.authorizationCode.rawValue),
.init(name: "code", value: response.code),
.init(name: "redirect_uri", value: redirectURI.absoluteString)
]
let httpBody = Data(components.percentEncodedQuery!.utf8)
var request = URLRequest(url: tokenEndpoint)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = httpBody
let session = URLSession(configuration: .ephemeral)
let (data, _) = try await session.data(for: request)
return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data)
}
func authorize(_ session: WebAuthenticationSession) async throws -> Credential {
let authorizationURL = try authorizationURL
let callbackURL = try await session.start(
url: authorizationURL,
redirectURI: redirectURI
)
return try await handle(callbackURL: callbackURL).credential
}
}
private struct CodeResponse: Codable {
var code: String
var state: String?
}
private struct AccessTokenResponse: Codable {
var accessToken: String
var tokenType: TokenType
var expiresIn: Double?
var refreshToken: String?
var credential: Credential {
.init(
accessToken: accessToken,
refreshToken: refreshToken,
expirationDate: expiresIn.map { Date(timeIntervalSinceNow: $0) }
)
}
}
enum TokenType: Codable, RawRepresentable {
case bearer
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "bearer": .bearer
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .bearer: "bearer"
case .unknown(let type): type
}
}
}
enum GrantType: Codable, RawRepresentable {
case authorizationCode
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "authorization_code": .authorizationCode
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .authorizationCode: "authorization_code"
case .unknown(let type): type
}
}
}
enum ResponseType: Codable, RawRepresentable {
case code
case idToken
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "code": .code
case "id_token": .idToken
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .code: "code"
case .idToken: "id_token"
case .unknown(let type): type
}
}
}
fileprivate static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
fileprivate static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
}
extension WebAuthenticationSession: @unchecked @retroactive Sendable {
}
extension WebAuthenticationSession {
#if canImport(BrowserEngineKit)
@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *)
fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback {
switch redirectURI.scheme {
case "https":
guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI }
return .https(host: host, path: redirectURI.path)
case "http":
throw OAuth2.Error.invalidRedirectURI
case .some(let scheme):
return .customScheme(scheme)
case .none:
throw OAuth2.Error.invalidRedirectURI
}
}
#endif
fileprivate func start(url: URL, redirectURI: URL) async throws -> URL {
#if canImport(BrowserEngineKit)
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
return try await authenticate(
using: url,
callback: try Self.callback(for: redirectURI),
additionalHeaderFields: [:]
)
}
#endif
return try await withThrowingTaskGroup(of: URL.self) { group in
group.addTask {
return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "")
}
let id = Int.random(in: 0..<Int.max)
group.addTask {
return try await withCheckedThrowingContinuation { continuation in
OAuth2.Session.queue.withLock { $0[id] = continuation }
}
}
guard let url = try await group.next() else { throw OAuth2.Error.invalidCallbackURL }
group.cancelAll()
OAuth2.Session.queue.withLock { $0[id] = nil }
return url
}
}
}
extension View {
func handleOAuth2Callback() -> some View {
onOpenURL { url in OAuth2.Session.handle(url: url) }
}
}
extension URLComponents {
fileprivate func decode<T: Decodable>(_ type: T.Type) throws -> T {
guard let queryItems else {
throw DecodingError.valueNotFound(
T.self,
.init(codingPath: [], debugDescription: "Missing query items")
)
}
let data = try OAuth2.encoder.encode(try queryItems.values)
return try OAuth2.decoder.decode(T.self, from: data)
}
}
extension Sequence where Element == URLQueryItem {
fileprivate var values: [String: String?] {
get throws {
try Dictionary(map { ($0.name, $0.value) }) { _, _ in
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items"))
}
}
}
}