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,22 +1,37 @@
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")
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("Hack Club", action: addHackClubNetwork)
Button("WireGuard", action: addWireGuardNetwork)
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)
@ -24,44 +39,748 @@ public struct BurrowView: View {
}
}
.padding(.top)
NetworkCarouselView()
Spacer()
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()
.handleOAuth2Callback()
}
}
.sheet(item: $activeSheet) { sheet in
ConfigurationSheetView(
sheet: sheet,
networkViewModel: networkViewModel,
accountStore: accountStore
)
}
.onAppear {
runAutomationIfNeeded()
}
}
public init() {
}
private func addHackClubNetwork() {
Task {
try await authenticateWithSlack()
}
}
private func addWireGuardNetwork() {
}
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"
_networkViewModel = State(
initialValue: NetworkViewModel(
socketURLResult: Result { try Constants.socketURL }
)
)
let response = try await session.authorize(webAuthenticationSession)
}
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()
}
}
@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 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,17 +1,21 @@
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 {
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, id: \.id) { network in
ForEach(networks) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
@ -28,12 +32,14 @@ struct NetworkCarouselView: View {
.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(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
}
init(socketURL: URL) {
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
_ = 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 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"))
}
}
}
}

View file

@ -0,0 +1,66 @@
module burrow.dev/tailscale-login-bridge
go 1.26.1
require tailscale.com v1.96.5
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
)

View file

@ -0,0 +1,229 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=

View file

@ -0,0 +1,133 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"time"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/tsnet"
)
type statusResponse struct {
BackendState string `json:"backend_state"`
AuthURL string `json:"auth_url,omitempty"`
Running bool `json:"running"`
NeedsLogin bool `json:"needs_login"`
TailnetName string `json:"tailnet_name,omitempty"`
MagicDNSSuffix string `json:"magic_dns_suffix,omitempty"`
SelfDNSName string `json:"self_dns_name,omitempty"`
TailscaleIPs []string `json:"tailscale_ips,omitempty"`
Health []string `json:"health,omitempty"`
}
func main() {
listen := flag.String("listen", "127.0.0.1:0", "local listen address")
stateDir := flag.String("state-dir", "", "persistent state directory")
hostname := flag.String("hostname", "burrow-apple", "tailnet hostname")
controlURL := flag.String("control-url", "", "optional control URL")
flag.Parse()
if *stateDir == "" {
log.Fatal("--state-dir is required")
}
if err := os.MkdirAll(*stateDir, 0o755); err != nil {
log.Fatalf("create state dir: %v", err)
}
server := &tsnet.Server{
Dir: *stateDir,
Hostname: *hostname,
UserLogf: log.Printf,
}
if *controlURL != "" {
server.ControlURL = *controlURL
}
defer server.Close()
if err := server.Start(); err != nil {
log.Fatalf("start tsnet: %v", err)
}
localClient, err := server.LocalClient()
if err != nil {
log.Fatalf("local client: %v", err)
}
ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Fatalf("listen: %v", err)
}
defer ln.Close()
fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String())
_ = os.Stdout.Sync()
mux := http.NewServeMux()
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
status, err := snapshot(r.Context(), localClient)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(status)
})
mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
go func() {
_ = server.Close()
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
httpServer := &http.Server{
Handler: mux,
}
log.Fatal(httpServer.Serve(ln))
}
func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) {
status, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return nil, err
}
if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" {
if err := localClient.StartLoginInteractive(ctx); err != nil {
return nil, err
}
status, err = localClient.StatusWithoutPeers(ctx)
if err != nil {
return nil, err
}
}
response := &statusResponse{
BackendState: status.BackendState,
AuthURL: status.AuthURL,
Running: status.BackendState == ipn.Running.String(),
NeedsLogin: status.BackendState == ipn.NeedsLogin.String(),
Health: append([]string(nil), status.Health...),
}
if status.CurrentTailnet != nil {
response.TailnetName = status.CurrentTailnet.Name
response.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
}
if status.Self != nil {
response.SelfDNSName = status.Self.DNSName
}
for _, ip := range status.TailscaleIPs {
response.TailscaleIPs = append(response.TailscaleIPs, ip.String())
}
return response, nil
}

View file

@ -1,24 +0,0 @@
use std::env::var;
use anyhow::Result;
use reqwest::Url;
pub async fn login() -> Result<()> {
let state = "vt :P";
let nonce = "no";
let mut url = Url::parse("https://slack.com/openid/connect/authorize")?;
let mut q = url.query_pairs_mut();
q.append_pair("response_type", "code");
q.append_pair("scope", "openid profile email");
q.append_pair("client_id", &var("CLIENT_ID")?);
q.append_pair("state", state);
q.append_pair("team", &var("SLACK_TEAM_ID")?);
q.append_pair("nonce", nonce);
q.append_pair("redirect_uri", "https://burrow.rs/callback");
drop(q);
println!("Continue auth in your browser:\n{}", url.as_str());
Ok(())
}

View file

@ -1,2 +1 @@
pub mod client;
pub mod server;

View file

@ -1,91 +1,627 @@
use anyhow::Result;
use anyhow::{anyhow, Context, Result};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use base64::{engine::general_purpose, Engine as _};
use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use crate::daemon::rpc::grpc_defs::{Network, NetworkType};
use crate::control::{
DnsConfig, Hostinfo, LocalAuthResponse, MapRequest, MapResponse, Node, NodeCapMap,
PacketFilter, PeerCapMap, RegisterRequest, UserProfile,
};
const CREATE_SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS auth_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
profile_pic_url TEXT,
groups_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS auth_local_credential (
user_id INTEGER PRIMARY KEY REFERENCES auth_user(id) ON DELETE CASCADE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
rotated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS auth_session (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days'))
);
CREATE TABLE IF NOT EXISTS control_node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stable_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE,
name TEXT NOT NULL,
node_key TEXT NOT NULL UNIQUE,
machine_key TEXT,
disco_key TEXT,
addresses_json TEXT NOT NULL,
allowed_ips_json TEXT NOT NULL,
endpoints_json TEXT NOT NULL,
home_derp INTEGER,
hostinfo_json TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
primary_routes_json TEXT NOT NULL DEFAULT '[]',
cap_version INTEGER NOT NULL DEFAULT 1,
cap_map_json TEXT NOT NULL DEFAULT '{}',
peer_cap_map_json TEXT NOT NULL DEFAULT '{}',
machine_authorized INTEGER NOT NULL DEFAULT 1,
node_key_expired INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
online INTEGER
);
"#;
#[derive(Clone, Debug)]
pub struct StoredUser {
pub profile: UserProfile,
}
pub fn init_db(path: &str) -> Result<()> {
let conn = Connection::open(path)?;
conn.execute_batch(CREATE_SCHEMA)?;
Ok(())
}
pub fn ensure_local_identity(
path: &str,
username: &str,
email: &str,
display_name: &str,
password: &str,
) -> Result<UserProfile> {
let conn = Connection::open(path)?;
conn.execute(
"INSERT INTO auth_user (email, display_name) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name",
params![email, display_name],
)?;
let user_id: i64 =
conn.query_row("SELECT id FROM auth_user WHERE email = ?", [email], |row| {
row.get(0)
})?;
let existing_hash: Option<String> = conn
.query_row(
"SELECT password_hash FROM auth_local_credential WHERE user_id = ?",
[user_id],
|row| row.get(0),
)
.optional()?;
let password_hash = match existing_hash {
Some(hash) if verify_password(password, &hash) => hash,
_ => hash_password(password)?,
};
conn.execute(
"INSERT INTO auth_local_credential (user_id, username, password_hash)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, rotated_at = datetime('now')",
params![user_id, username, password_hash],
)?;
load_user_profile(&conn, user_id)
}
pub fn authenticate_local(
path: &str,
identifier: &str,
password: &str,
) -> Result<Option<LocalAuthResponse>> {
let conn = Connection::open(path)?;
let record = conn
.query_row(
"SELECT u.id, u.email, u.display_name, u.profile_pic_url, u.groups_json, c.password_hash
FROM auth_user u
JOIN auth_local_credential c ON c.user_id = u.id
WHERE c.username = ? OR u.email = ?",
params![identifier, identifier],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
))
},
)
.optional()?;
let Some((user_id, email, display_name, profile_pic_url, groups_json, password_hash)) = record
else {
return Ok(None);
};
if !verify_password(password, &password_hash) {
return Ok(None);
}
let token = random_token();
conn.execute(
"INSERT INTO auth_session (id, user_id) VALUES (?, ?)",
params![token, user_id],
)?;
Ok(Some(LocalAuthResponse {
access_token: token,
user: UserProfile {
id: user_id,
login_name: email,
display_name,
profile_pic_url,
groups: parse_json(&groups_json)?,
},
}))
}
pub fn user_for_session(path: &str, token: &str) -> Result<Option<StoredUser>> {
let conn = Connection::open(path)?;
let user_id = conn
.query_row(
"SELECT user_id FROM auth_session WHERE id = ? AND expires_at > datetime('now')",
[token],
|row| row.get::<_, i64>(0),
)
.optional()?;
let Some(user_id) = user_id else {
return Ok(None);
};
Ok(Some(load_user(&conn, user_id)?))
}
pub fn upsert_node(path: &str, user: &StoredUser, request: &RegisterRequest) -> Result<Node> {
let conn = Connection::open(path)?;
let existing = find_existing_node(&conn, user.profile.id, request)?;
let name = Node::preferred_name(request);
let allowed_ips = Node::normalized_allowed_ips(request);
match existing {
Some((node_id, stable_id, created_at)) => {
conn.execute(
"UPDATE control_node
SET name = ?, node_key = ?, machine_key = ?, disco_key = ?, addresses_json = ?, allowed_ips_json = ?,
endpoints_json = ?, home_derp = ?, hostinfo_json = ?, tags_json = ?, primary_routes_json = ?,
cap_version = ?, cap_map_json = ?, peer_cap_map_json = ?, updated_at = datetime('now'),
last_seen = datetime('now'), online = 1
WHERE id = ?",
params![
name,
request.node_key,
request.machine_key,
request.disco_key,
to_json(&request.addresses)?,
to_json(&allowed_ips)?,
to_json(&request.endpoints)?,
request.home_derp,
optional_json(&request.hostinfo)?,
to_json(&request.tags)?,
to_json(&request.primary_routes)?,
request.version.max(1),
to_json(&request.cap_map)?,
to_json(&request.peer_cap_map)?,
node_id,
],
)?;
load_node(&conn, node_id, stable_id, Some(created_at))
}
None => {
conn.execute(
"INSERT INTO control_node (
stable_id, user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json,
endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version,
cap_map_json, peer_cap_map_json, last_seen, online
) VALUES ('', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 1)",
params![
user.profile.id,
name,
request.node_key,
request.machine_key,
request.disco_key,
to_json(&request.addresses)?,
to_json(&allowed_ips)?,
to_json(&request.endpoints)?,
request.home_derp,
optional_json(&request.hostinfo)?,
to_json(&request.tags)?,
to_json(&request.primary_routes)?,
request.version.max(1),
to_json(&request.cap_map)?,
to_json(&request.peer_cap_map)?,
],
)?;
let node_id = conn.last_insert_rowid();
let stable_id = format!("bn-{node_id}");
conn.execute(
"UPDATE control_node SET stable_id = ? WHERE id = ?",
params![stable_id, node_id],
)?;
load_node(&conn, node_id, stable_id, None)
}
}
}
pub fn map_for_node(
path: &str,
user: &StoredUser,
request: &MapRequest,
domain: &str,
) -> Result<MapResponse> {
let conn = Connection::open(path)?;
apply_map_request(&conn, user.profile.id, request)?;
let self_row = conn
.query_row(
"SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?",
params![user.profile.id, request.node_key],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.optional()?
.ok_or_else(|| anyhow!("node not registered"))?;
let node = load_node(&conn, self_row.0, self_row.1, Some(self_row.2))?;
let peers = load_peers(&conn, node.id)?;
Ok(MapResponse {
map_session_handle: Some(format!("map-{}", node.stable_id)),
seq: Some(request.map_session_seq.unwrap_or(0) + 1),
node,
peers,
domain: domain.to_owned(),
dns: Some(DnsConfig {
resolvers: vec!["1.1.1.1".to_owned(), "1.0.0.1".to_owned()],
search_domains: vec![domain.to_owned()],
magic_dns: true,
}),
packet_filters: vec![PacketFilter::default()],
})
}
pub static PATH: &str = "./server.sqlite3";
pub fn init_db() -> Result<()> {
let conn = rusqlite::Connection::open(PATH)?;
fn apply_map_request(conn: &Connection, user_id: i64, request: &MapRequest) -> Result<()> {
let current = conn
.query_row(
"SELECT id FROM control_node WHERE user_id = ? AND node_key = ?",
params![user_id, request.node_key],
|row| row.get::<_, i64>(0),
)
.optional()?;
let Some(node_id) = current else {
return Ok(());
};
let hostinfo_json = optional_json(&request.hostinfo)?;
let endpoints_json = to_json(&request.endpoints)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS user (
id PRIMARY KEY,
created_at TEXT NOT NULL
)",
(),
"UPDATE control_node
SET disco_key = COALESCE(?, disco_key),
hostinfo_json = CASE WHEN ? IS NULL THEN hostinfo_json ELSE ? END,
endpoints_json = CASE WHEN ? = '[]' THEN endpoints_json ELSE ? END,
updated_at = datetime('now'),
last_seen = datetime('now'),
online = 1
WHERE id = ?",
params![
request.disco_key,
hostinfo_json,
hostinfo_json,
endpoints_json,
endpoints_json,
node_id,
],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS user_connection (
user_id INTEGER REFERENCES user(id) ON DELETE CASCADE,
openid_provider TEXT NOT NULL,
openid_user_id TEXT NOT NULL,
openid_user_name TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
PRIMARY KEY (openid_provider, openid_user_id)
)",
(),
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS device (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
public_key TEXT NOT NULL,
apns_token TEXT UNIQUE,
user_id INT REFERENCES user(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)),
ipv4 TEXT NOT NULL UNIQUE,
ipv6 TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL UNIQUE,
refresh_token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at))
)",
()
).unwrap();
Ok(())
}
pub fn store_connection(
openid_user: super::providers::OpenIdUser,
openid_provider: &str,
access_token: &str,
refresh_token: Option<&str>,
) -> Result<()> {
log::debug!("Storing openid user {:#?}", openid_user);
let conn = rusqlite::Connection::open(PATH)?;
fn find_existing_node(
conn: &Connection,
user_id: i64,
request: &RegisterRequest,
) -> Result<Option<(i64, String, String)>> {
let mut candidates = vec![request.node_key.as_str()];
if let Some(old) = request.old_node_key.as_deref() {
if old != request.node_key {
candidates.push(old);
}
}
conn.execute(
"INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))",
(&openid_user.sub,),
)?;
conn.execute(
"INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES (
(SELECT id FROM user WHERE id = ?),
?,
?,
?,
?,
?
)",
(&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token),
)?;
Ok(())
for candidate in candidates {
let hit = conn
.query_row(
"SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?",
params![user_id, candidate],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.optional()?;
if hit.is_some() {
return Ok(hit);
}
}
Ok(None)
}
pub fn store_device(
openid_user: super::providers::OpenIdUser,
openid_provider: &str,
access_token: &str,
refresh_token: Option<&str>,
) -> Result<()> {
log::debug!("Storing openid user {:#?}", openid_user);
let conn = rusqlite::Connection::open(PATH)?;
// TODO
Ok(())
fn load_peers(conn: &Connection, self_id: i64) -> Result<Vec<Node>> {
let mut stmt = conn.prepare(
"SELECT id, stable_id, created_at FROM control_node WHERE id != ? AND machine_authorized = 1 ORDER BY id",
)?;
let peers = stmt
.query_map([self_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
peers
.into_iter()
.map(|(id, stable_id, created_at)| load_node(conn, id, stable_id, Some(created_at)))
.collect()
}
fn load_node(
conn: &Connection,
id: i64,
stable_id: String,
created_at_hint: Option<String>,
) -> Result<Node> {
let row = conn.query_row(
"SELECT user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json,
endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version,
cap_map_json, peer_cap_map_json, machine_authorized, node_key_expired,
created_at, updated_at, last_seen, online
FROM control_node WHERE id = ?",
[id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, Option<i32>>(8)?,
row.get::<_, Option<String>>(9)?,
row.get::<_, String>(10)?,
row.get::<_, String>(11)?,
row.get::<_, i32>(12)?,
row.get::<_, String>(13)?,
row.get::<_, String>(14)?,
row.get::<_, i64>(15)?,
row.get::<_, i64>(16)?,
row.get::<_, String>(17)?,
row.get::<_, String>(18)?,
row.get::<_, Option<String>>(19)?,
row.get::<_, Option<i64>>(20)?,
))
},
)?;
Ok(Node {
id,
stable_id,
user_id: row.0,
name: row.1,
node_key: row.2,
machine_key: row.3,
disco_key: row.4,
addresses: parse_json(&row.5)?,
allowed_ips: parse_json(&row.6)?,
endpoints: parse_json(&row.7)?,
home_derp: row.8,
hostinfo: row.9.map(|raw| parse_json::<Hostinfo>(&raw)).transpose()?,
tags: parse_json(&row.10)?,
primary_routes: parse_json(&row.11)?,
cap_version: row.12,
cap_map: parse_json::<NodeCapMap>(&row.13)?,
peer_cap_map: parse_json::<PeerCapMap>(&row.14)?,
machine_authorized: row.15 != 0,
node_key_expired: row.16 != 0,
created_at: Some(created_at_hint.unwrap_or(row.17)),
updated_at: Some(row.18),
last_seen: row.19,
online: row.20.map(|value| value != 0),
})
}
fn load_user(conn: &Connection, user_id: i64) -> Result<StoredUser> {
let profile = load_user_profile(conn, user_id)?;
Ok(StoredUser { profile })
}
fn load_user_profile(conn: &Connection, user_id: i64) -> Result<UserProfile> {
let row = conn.query_row(
"SELECT email, display_name, profile_pic_url, groups_json FROM auth_user WHERE id = ?",
[user_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
},
)?;
Ok(UserProfile {
id: user_id,
login_name: row.0,
display_name: row.1,
profile_pic_url: row.2,
groups: parse_json(&row.3)?,
})
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!("failed to hash password: {err}"))?;
Ok(hash.to_string())
}
fn verify_password(password: &str, password_hash: &str) -> bool {
PasswordHash::new(password_hash)
.ok()
.and_then(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.ok()
})
.is_some()
}
fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn to_json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).context("failed to serialize json")
}
fn optional_json<T: serde::Serialize>(value: &Option<T>) -> Result<Option<String>> {
value.as_ref().map(to_json).transpose()
}
fn parse_json<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
serde_json::from_str(value)
.with_context(|| format!("failed to decode json payload from '{value}'"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::{Hostinfo, RegisterRequest};
use tempfile::TempDir;
fn temp_db() -> Result<(TempDir, String)> {
let dir = tempfile::tempdir()?;
let db_path = dir.path().join("server.sqlite3");
Ok((dir, db_path.to_string_lossy().to_string()))
}
#[test]
fn local_auth_and_map_round_trip() -> Result<()> {
let (_dir, db_path) = temp_db()?;
init_db(&db_path)?;
ensure_local_identity(
&db_path,
"contact",
"contact@burrow.net",
"Burrow Contact",
"password-1",
)?;
let auth = authenticate_local(&db_path, "contact", "password-1")?
.expect("expected login to succeed");
let user =
user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve");
let node = upsert_node(
&db_path,
&user,
&RegisterRequest {
node_key: "nodekey:aaaa".to_owned(),
machine_key: Some("machinekey:aaaa".to_owned()),
disco_key: Some("discokey:aaaa".to_owned()),
addresses: vec!["100.64.0.1/32".to_owned()],
endpoints: vec!["203.0.113.10:41641".to_owned()],
hostinfo: Some(Hostinfo {
hostname: Some("burrow-dev".to_owned()),
os: Some("linux".to_owned()),
os_version: Some("6.13".to_owned()),
services: vec!["ssh".to_owned()],
request_tags: vec!["tag:dev".to_owned()],
}),
..RegisterRequest::default()
},
)?;
assert_eq!(node.name, "burrow-dev");
assert_eq!(node.allowed_ips, vec!["100.64.0.1/32"]);
let map = map_for_node(
&db_path,
&user,
&MapRequest {
node_key: "nodekey:aaaa".to_owned(),
stream: true,
endpoints: vec!["203.0.113.10:41641".to_owned()],
..MapRequest::default()
},
"burrow.net",
)?;
assert_eq!(map.node.node_key, "nodekey:aaaa");
assert_eq!(map.domain, "burrow.net");
assert!(map.dns.expect("dns config").magic_dns);
Ok(())
}
#[test]
fn register_can_rotate_node_keys() -> Result<()> {
let (_dir, db_path) = temp_db()?;
init_db(&db_path)?;
ensure_local_identity(
&db_path,
"contact",
"contact@burrow.net",
"Burrow Contact",
"password-1",
)?;
let auth = authenticate_local(&db_path, "contact@burrow.net", "password-1")?
.expect("expected login to succeed");
let user =
user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve");
upsert_node(
&db_path,
&user,
&RegisterRequest {
node_key: "nodekey:old".to_owned(),
addresses: vec!["100.64.0.2/32".to_owned()],
..RegisterRequest::default()
},
)?;
let rotated = upsert_node(
&db_path,
&user,
&RegisterRequest {
node_key: "nodekey:new".to_owned(),
old_node_key: Some("nodekey:old".to_owned()),
addresses: vec!["100.64.0.3/32".to_owned()],
..RegisterRequest::default()
},
)?;
assert_eq!(rotated.node_key, "nodekey:new");
assert_eq!(rotated.addresses, vec!["100.64.0.3/32"]);
Ok(())
}
}

View file

@ -1,32 +1,277 @@
pub mod db;
pub mod providers;
pub mod tailscale;
use anyhow::Result;
use axum::{http::StatusCode, routing::post, Router};
use providers::slack::auth;
use std::{env, path::Path};
use anyhow::{Context, Result};
use axum::{
extract::{Json, Path as AxumPath, State},
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Router,
};
use tokio::signal;
use crate::control::{
LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest,
RegisterResponse, BURROW_TAILNET_DOMAIN,
};
#[derive(Clone, Debug)]
pub struct BootstrapIdentity {
pub username: String,
pub email: String,
pub display_name: String,
pub password_file: String,
}
impl Default for BootstrapIdentity {
fn default() -> Self {
Self {
username: "contact".to_owned(),
email: "contact@burrow.net".to_owned(),
display_name: "Burrow Contact".to_owned(),
password_file: "intake/forgejo_pass_contact_at_burrow_net.txt".to_owned(),
}
}
}
#[derive(Clone, Debug)]
pub struct AuthServerConfig {
pub listen: String,
pub db_path: String,
pub tailnet_domain: String,
pub bootstrap: BootstrapIdentity,
}
impl Default for AuthServerConfig {
fn default() -> Self {
Self {
listen: "0.0.0.0:8080".to_owned(),
db_path: db::PATH.to_owned(),
tailnet_domain: BURROW_TAILNET_DOMAIN.to_owned(),
bootstrap: BootstrapIdentity::default(),
}
}
}
impl AuthServerConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(value) = env::var("BURROW_AUTH_LISTEN") {
config.listen = value;
}
if let Ok(value) = env::var("BURROW_AUTH_DB_PATH") {
config.db_path = value;
}
if let Ok(value) = env::var("BURROW_AUTH_TAILNET_DOMAIN") {
config.tailnet_domain = value;
}
if let Ok(value) = env::var("BURROW_BOOTSTRAP_USERNAME") {
config.bootstrap.username = value;
}
if let Ok(value) = env::var("BURROW_BOOTSTRAP_EMAIL") {
config.bootstrap.email = value;
}
if let Ok(value) = env::var("BURROW_BOOTSTRAP_DISPLAY_NAME") {
config.bootstrap.display_name = value;
}
if let Ok(value) = env::var("BURROW_BOOTSTRAP_PASSWORD_FILE") {
config.bootstrap.password_file = value;
}
config
}
fn bootstrap_password(&self) -> Result<Option<String>> {
let path = Path::new(&self.bootstrap.password_file);
if !path.exists() {
return Ok(None);
}
let password = std::fs::read_to_string(path).with_context(|| {
format!("failed to read bootstrap password from {}", path.display())
})?;
let password = password.trim().to_owned();
if password.is_empty() {
return Ok(None);
}
Ok(Some(password))
}
}
#[derive(Clone)]
struct AppState {
config: AuthServerConfig,
tailscale: tailscale::TailscaleBridgeManager,
}
type AppResult<T> = Result<T, (StatusCode, String)>;
pub async fn serve() -> Result<()> {
db::init_db()?;
serve_with_config(AuthServerConfig::from_env()).await
}
let app = Router::new()
.route("/slack-auth", post(auth))
.route("/device/new", post(device_new));
pub async fn serve_with_config(config: AuthServerConfig) -> Result<()> {
db::init_db(&config.db_path)?;
if let Some(password) = config.bootstrap_password()? {
db::ensure_local_identity(
&config.db_path,
&config.bootstrap.username,
&config.bootstrap.email,
&config.bootstrap.display_name,
&password,
)?;
}
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
log::info!("Starting auth server on port 8080");
let app = build_router(config.clone());
let listener = tokio::net::TcpListener::bind(&config.listen).await?;
log::info!("Starting auth server on {}", config.listen);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
.await?;
Ok(())
}
async fn device_new() -> StatusCode {
pub fn build_router(config: AuthServerConfig) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/device/new", post(device_new))
.route("/v1/auth/login", post(login_local))
.route("/v1/control/register", post(control_register))
.route("/v1/control/map", post(control_map))
.route("/v1/tailscale/login/start", post(tailscale_login_start))
.route("/v1/tailscale/login/:session_id", get(tailscale_login_status))
.with_state(AppState {
config,
tailscale: tailscale::TailscaleBridgeManager::default(),
})
}
async fn login_local(
State(state): State<AppState>,
Json(request): Json<LocalAuthRequest>,
) -> AppResult<Json<LocalAuthResponse>> {
let db_path = state.config.db_path.clone();
blocking(move || db::authenticate_local(&db_path, &request.identifier, &request.password))
.await?
.map(Json)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "invalid credentials".to_owned()))
}
async fn control_register(
headers: HeaderMap,
State(state): State<AppState>,
Json(request): Json<RegisterRequest>,
) -> AppResult<Json<RegisterResponse>> {
let token = bearer_token(&headers)?;
let db_path = state.config.db_path.clone();
let user = blocking({
let db_path = db_path.clone();
let token = token.clone();
move || db::user_for_session(&db_path, &token)
})
.await?
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?;
let response_user = user.profile.clone();
let node = blocking(move || db::upsert_node(&db_path, &user, &request)).await?;
Ok(Json(RegisterResponse {
user: response_user,
machine_authorized: node.machine_authorized,
node_key_expired: node.node_key_expired,
auth_url: None,
error: None,
node,
}))
}
async fn control_map(
headers: HeaderMap,
State(state): State<AppState>,
Json(request): Json<MapRequest>,
) -> AppResult<Json<MapResponse>> {
let token = bearer_token(&headers)?;
let db_path = state.config.db_path.clone();
let domain = state.config.tailnet_domain.clone();
let user = blocking({
let db_path = db_path.clone();
let token = token.clone();
move || db::user_for_session(&db_path, &token)
})
.await?
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?;
let response = blocking(move || db::map_for_node(&db_path, &user, &request, &domain)).await?;
Ok(Json(response))
}
async fn tailscale_login_start(
State(state): State<AppState>,
Json(request): Json<tailscale::TailscaleLoginStartRequest>,
) -> AppResult<Json<tailscale::TailscaleLoginStartResponse>> {
let response = state
.tailscale
.start_login(request)
.await
.map_err(internal_error)?;
Ok(Json(response))
}
async fn tailscale_login_status(
AxumPath(session_id): AxumPath<String>,
State(state): State<AppState>,
) -> AppResult<Json<tailscale::TailscaleLoginStatus>> {
state
.tailscale
.status(&session_id)
.await
.map_err(internal_error)?
.map(Json)
.ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned()))
}
async fn healthz() -> impl IntoResponse {
StatusCode::OK
}
async fn device_new() -> impl IntoResponse {
StatusCode::OK
}
async fn blocking<F, T>(work: F) -> AppResult<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work)
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?
.map_err(internal_error)
}
fn internal_error(err: anyhow::Error) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
fn bearer_token(headers: &HeaderMap) -> AppResult<String> {
let value = headers.get(AUTHORIZATION).ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
"missing authorization header".to_owned(),
)
})?;
let value = value.to_str().map_err(|_| {
(
StatusCode::BAD_REQUEST,
"invalid authorization header".to_owned(),
)
})?;
value
.strip_prefix("Bearer ")
.map(ToOwned::to_owned)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "expected bearer token".to_owned()))
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
@ -51,12 +296,102 @@ async fn shutdown_signal() {
}
}
// mod db {
// use rusqlite::{Connection, Result};
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
};
use tempfile::tempdir;
use tower::ServiceExt;
// #[derive(Debug)]
// struct User {
// id: i32,
// created_at: String,
// }
// }
#[tokio::test]
async fn login_register_and_map_round_trip() -> Result<()> {
let dir = tempdir()?;
let password_file = dir.path().join("bootstrap-password.txt");
std::fs::write(&password_file, "bootstrap-pass\n")?;
let db_path = dir.path().join("server.sqlite3");
let config = AuthServerConfig {
listen: "127.0.0.1:0".to_owned(),
db_path: db_path.to_string_lossy().to_string(),
tailnet_domain: "burrow.net".to_owned(),
bootstrap: BootstrapIdentity {
password_file: password_file.to_string_lossy().to_string(),
..BootstrapIdentity::default()
},
};
db::init_db(&config.db_path)?;
let password = config.bootstrap_password()?.expect("bootstrap password");
db::ensure_local_identity(
&config.db_path,
&config.bootstrap.username,
&config.bootstrap.email,
&config.bootstrap.display_name,
&password,
)?;
let app = build_router(config);
let response = app
.clone()
.oneshot(
Request::post("/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&LocalAuthRequest {
identifier: "contact".to_owned(),
password: "bootstrap-pass".to_owned(),
})?))?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let login: LocalAuthResponse =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?;
let response = app
.clone()
.oneshot(
Request::post("/v1/control/register")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {}", login.access_token))
.body(Body::from(serde_json::to_vec(&RegisterRequest {
node_key: "nodekey:1234".to_owned(),
machine_key: Some("machinekey:1234".to_owned()),
addresses: vec!["100.64.0.10/32".to_owned()],
endpoints: vec!["198.51.100.10:41641".to_owned()],
hostinfo: Some(crate::control::Hostinfo {
hostname: Some("devbox".to_owned()),
os: Some("linux".to_owned()),
os_version: Some("6.13".to_owned()),
services: vec!["ssh".to_owned()],
request_tags: vec!["tag:dev".to_owned()],
}),
..RegisterRequest::default()
})?))?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::post("/v1/control/map")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {}", login.access_token))
.body(Body::from(serde_json::to_vec(&MapRequest {
node_key: "nodekey:1234".to_owned(),
stream: true,
endpoints: vec!["198.51.100.10:41641".to_owned()],
..MapRequest::default()
})?))?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let map: MapResponse =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?;
assert_eq!(map.domain, "burrow.net");
assert_eq!(map.node.name, "devbox");
assert!(map.dns.expect("dns").magic_dns);
Ok(())
}
}

View file

@ -1,8 +0,0 @@
pub mod slack;
pub use super::db;
#[derive(serde::Deserialize, Default, Debug)]
pub struct OpenIdUser {
pub sub: String,
pub name: String,
}

View file

@ -1,102 +0,0 @@
use anyhow::Result;
use axum::{
extract::Json,
http::StatusCode,
routing::{get, post},
};
use reqwest::header::AUTHORIZATION;
use serde::Deserialize;
use super::db::store_connection;
#[derive(Deserialize)]
pub struct SlackToken {
slack_token: String,
}
pub async fn auth(Json(payload): Json<SlackToken>) -> (StatusCode, String) {
let slack_user = match fetch_slack_user(&payload.slack_token).await {
Ok(user) => user,
Err(e) => {
log::error!("Failed to fetch Slack user: {:?}", e);
return (StatusCode::UNAUTHORIZED, String::new());
}
};
log::info!(
"Slack user {} ({}) logged in.",
slack_user.name,
slack_user.sub
);
let conn = match store_connection(slack_user, "slack", &payload.slack_token, None) {
Ok(user) => user,
Err(e) => {
log::error!("Failed to fetch Slack user: {:?}", e);
return (StatusCode::UNAUTHORIZED, String::new());
}
};
(StatusCode::OK, String::new())
}
async fn fetch_slack_user(access_token: &str) -> Result<super::OpenIdUser> {
let client = reqwest::Client::new();
let res = client
.get("https://slack.com/api/openid.connect.userInfo")
.header(AUTHORIZATION, format!("Bearer {}", access_token))
.send()
.await?
.json::<serde_json::Value>()
.await?;
let res_ok = res
.get("ok")
.and_then(|v| v.as_bool())
.ok_or(anyhow::anyhow!("Slack user object not ok!"))?;
if !res_ok {
return Err(anyhow::anyhow!("Slack user object not ok!"));
}
Ok(serde_json::from_value(res)?)
}
// async fn fetch_save_slack_user_data(query: Query<CallbackQuery>) -> anyhow::Result<()> {
// let client = reqwest::Client::new();
// log::trace!("Code was {}", &query.code);
// let mut url = Url::parse("https://slack.com/api/openid.connect.token")?;
// {
// let mut q = url.query_pairs_mut();
// q.append_pair("client_id", &var("CLIENT_ID")?);
// q.append_pair("client_secret", &var("CLIENT_SECRET")?);
// q.append_pair("code", &query.code);
// q.append_pair("grant_type", "authorization_code");
// q.append_pair("redirect_uri", "https://burrow.rs/callback");
// }
// let data = client
// .post(url)
// .send()
// .await?
// .json::<slack::CodeExchangeResponse>()
// .await?;
// if !data.ok {
// return Err(anyhow::anyhow!("Slack code exchange response not ok!"));
// }
// if let Some(access_token) = data.access_token {
// log::trace!("Access token is {access_token}");
// let user = slack::fetch_slack_user(&access_token)
// .await
// .map_err(|err| anyhow::anyhow!("Failed to fetch Slack user info {:#?}", err))?;
// db::store_user(user, access_token, String::new())
// .map_err(|_| anyhow::anyhow!("Failed to store user in db"))?;
// Ok(())
// } else {
// Err(anyhow::anyhow!("Access token not found in response"))
// }
// }

View file

@ -0,0 +1,320 @@
use std::{
collections::HashMap,
env,
path::{Path, PathBuf},
process::Stdio,
sync::Arc,
time::Duration,
};
use anyhow::{anyhow, Context, Result};
use rand::RngCore;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::{Child, Command},
sync::Mutex,
task::JoinHandle,
};
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TailscaleLoginStartRequest {
pub account_name: String,
pub identity_name: String,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub control_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TailscaleLoginStatus {
pub backend_state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
#[serde(default)]
pub running: bool,
#[serde(default)]
pub needs_login: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailnet_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub magic_dns_suffix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_dns_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tailscale_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub health: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct TailscaleLoginStartResponse {
pub session_id: String,
pub status: TailscaleLoginStatus,
}
#[derive(Clone, Default)]
pub struct TailscaleBridgeManager {
client: Client,
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
}
struct ManagedSession {
session_id: String,
listen_url: String,
state_dir: PathBuf,
child: Arc<Mutex<Child>>,
_stderr_task: JoinHandle<()>,
}
#[derive(Debug, Deserialize)]
struct HelperHello {
listen_addr: String,
}
impl TailscaleBridgeManager {
pub async fn start_login(
&self,
request: TailscaleLoginStartRequest,
) -> Result<TailscaleLoginStartResponse> {
let key = session_key(&request.account_name, &request.identity_name);
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
let status = self.fetch_status(existing.as_ref()).await?;
return Ok(TailscaleLoginStartResponse {
session_id: existing.session_id.clone(),
status,
});
}
let state_dir = state_root().join(session_dir_name(&request));
tokio::fs::create_dir_all(&state_dir)
.await
.with_context(|| format!("failed to create {}", state_dir.display()))?;
let mut child = helper_command(&request, &state_dir)?
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn tailscale login helper")?;
let stdout = child
.stdout
.take()
.context("tailscale helper stdout unavailable")?;
let stderr = child
.stderr
.take()
.context("tailscale helper stderr unavailable")?;
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
let mut lines = BufReader::new(stdout).lines();
lines.next_line().await
})
.await
.context("timed out waiting for tailscale helper startup")??
.context("tailscale helper exited before reporting listen address")?;
let hello: HelperHello =
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("tailscale-login-bridge: {line}");
}
});
let session = Arc::new(ManagedSession {
session_id: random_session_id(),
listen_url: format!("http://{}", hello.listen_addr),
state_dir,
child: Arc::new(Mutex::new(child)),
_stderr_task: stderr_task,
});
let status = self.wait_for_status(session.as_ref()).await?;
let response = TailscaleLoginStartResponse {
session_id: session.session_id.clone(),
status,
};
self.sessions.lock().await.insert(key, session);
Ok(response)
}
pub async fn status(&self, session_id: &str) -> Result<Option<TailscaleLoginStatus>> {
let session = {
let sessions = self.sessions.lock().await;
sessions
.values()
.find(|session| session.session_id == session_id)
.cloned()
};
match session {
Some(session) => self.fetch_status(session.as_ref()).await.map(Some),
None => Ok(None),
}
}
async fn wait_for_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
let mut last_error = None;
let mut last_status = None;
for _ in 0..40 {
match self.fetch_status(session).await {
Ok(status) if status.running || status.auth_url.is_some() => return Ok(status),
Ok(status) => last_status = Some(status),
Err(err) => last_error = Some(err),
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
if let Some(status) = last_status {
return Ok(status);
}
Err(last_error.unwrap_or_else(|| anyhow!("tailscale helper did not become ready")))
}
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
let mut child = session.child.lock().await;
if let Some(status) = child.try_wait()? {
return Err(anyhow!(
"tailscale helper exited with status {status} for {}",
session.state_dir.display()
));
}
drop(child);
let response = self
.client
.get(format!("{}/status", session.listen_url))
.send()
.await
.context("failed to query tailscale helper status")?
.error_for_status()
.context("tailscale helper status request failed")?;
response
.json::<TailscaleLoginStatus>()
.await
.context("invalid tailscale helper status response")
}
}
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
Command::new(path)
} else {
let helper_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("Tools/tailscale-login-bridge");
let mut command = Command::new("go");
command.current_dir(helper_dir).arg("run").arg(".");
command.env("GOWORK", "off");
command
};
command
.arg("--listen")
.arg("127.0.0.1:0")
.arg("--state-dir")
.arg(state_dir)
.arg("--hostname")
.arg(default_hostname(request));
if let Some(control_url) = request.control_url.as_deref() {
let trimmed = control_url.trim();
if !trimmed.is_empty() {
command.arg("--control-url").arg(trimmed);
}
}
Ok(command)
}
fn state_root() -> PathBuf {
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
return PathBuf::from(path);
}
let home = env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_vendor = "apple") {
return home
.join("Library")
.join("Application Support")
.join("Burrow")
.join("tailscale");
}
home.join(".local").join("share").join("burrow").join("tailscale")
}
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
format!(
"{}-{}",
slug(&request.account_name),
slug(&request.identity_name)
)
}
fn session_key(account_name: &str, identity_name: &str) -> String {
format!("{account_name}:{identity_name}")
}
fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
request
.hostname
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("burrow-{}", slug(&request.identity_name)))
}
fn random_session_id() -> String {
let mut bytes = [0_u8; 12];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn slug(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch.to_ascii_lowercase());
} else if ch == '-' || ch == '_' {
output.push('-');
}
}
if output.is_empty() {
"default".to_owned()
} else {
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_sanitizes_input() {
assert_eq!(slug("Apple Phone"), "applephone");
assert_eq!(slug("default_identity"), "default-identity");
assert_eq!(slug(""), "default");
}
#[test]
fn state_dir_is_stable_by_account_and_identity() {
let request = TailscaleLoginStartRequest {
account_name: "default".to_owned(),
identity_name: "apple".to_owned(),
hostname: None,
control_url: None,
};
assert_eq!(session_dir_name(&request), "default-apple");
assert_eq!(default_hostname(&request), "burrow-apple");
}
}

View file

@ -0,0 +1,87 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TailnetProvider {
Tailscale,
Headscale,
Burrow,
}
impl Default for TailnetProvider {
fn default() -> Self {
Self::Tailscale
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TailnetConfig {
#[serde(default)]
pub provider: TailnetProvider,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailnet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
}
impl TailnetConfig {
pub fn from_slice(bytes: &[u8]) -> Result<Self> {
let payload = std::str::from_utf8(bytes).context("tailnet payload must be valid UTF-8")?;
Self::from_str(payload)
}
pub fn from_str(payload: &str) -> Result<Self> {
let trimmed = payload.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).context("invalid tailnet JSON payload");
}
toml::from_str(trimmed).context("invalid tailnet TOML payload")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_json_payload() {
let config = TailnetConfig::from_str(
r#"{
"provider":"tailscale",
"account":"default",
"identity":"apple",
"tailnet":"example.ts.net",
"hostname":"burrow-phone"
}"#,
)
.unwrap();
assert_eq!(config.provider, TailnetProvider::Tailscale);
assert_eq!(config.account.as_deref(), Some("default"));
assert_eq!(config.identity.as_deref(), Some("apple"));
}
#[test]
fn parses_toml_payload() {
let config = TailnetConfig::from_str(
r#"
provider = "headscale"
authority = "https://headscale.example.com"
account = "default"
identity = "apple"
"#,
)
.unwrap();
assert_eq!(config.provider, TailnetProvider::Headscale);
assert_eq!(
config.authority.as_deref(),
Some("https://headscale.example.com")
);
}
}

253
burrow/src/control/mod.rs Normal file
View file

@ -0,0 +1,253 @@
pub mod config;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use config::{TailnetConfig, TailnetProvider};
pub const BURROW_CAPABILITY_VERSION: i32 = 1;
pub const BURROW_TAILNET_DOMAIN: &str = "burrow.net";
pub type NodeCapMap = BTreeMap<String, Vec<Value>>;
pub type PeerCapMap = BTreeMap<String, Vec<Value>>;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Hostinfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub request_tags: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserProfile {
pub id: i64,
pub login_name: String,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_pic_url: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisterAuth {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Node {
pub id: i64,
pub stable_id: String,
pub name: String,
pub user_id: i64,
pub node_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disco_key: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub addresses: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub endpoints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_derp: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostinfo: Option<Hostinfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub primary_routes: Vec<String>,
#[serde(default = "default_capability_version")]
pub cap_version: i32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cap_map: NodeCapMap,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub peer_cap_map: PeerCapMap,
#[serde(default)]
pub machine_authorized: bool,
#[serde(default)]
pub node_key_expired: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
}
impl Node {
pub fn preferred_name(request: &RegisterRequest) -> String {
if let Some(name) = request.name.as_deref() {
return name.to_owned();
}
if let Some(hostname) = request
.hostinfo
.as_ref()
.and_then(|hostinfo| hostinfo.hostname.as_deref())
{
return hostname.to_owned();
}
format!("node-{}", short_key(&request.node_key))
}
pub fn normalized_allowed_ips(request: &RegisterRequest) -> Vec<String> {
if request.allowed_ips.is_empty() {
return request.addresses.clone();
}
request.allowed_ips.clone()
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisterRequest {
#[serde(default = "default_capability_version")]
pub version: i32,
pub node_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_node_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disco_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<RegisterAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiry: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub followup: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostinfo: Option<Hostinfo>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailnet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub addresses: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub endpoints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_derp: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub primary_routes: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cap_map: NodeCapMap,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub peer_cap_map: PeerCapMap,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct RegisterResponse {
pub user: UserProfile,
pub node: Node,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
pub machine_authorized: bool,
pub node_key_expired: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MapRequest {
#[serde(default = "default_capability_version")]
pub version: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compress: Option<String>,
#[serde(default)]
pub keep_alive: bool,
pub node_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disco_key: Option<String>,
#[serde(default)]
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostinfo: Option<Hostinfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub map_session_handle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub map_session_seq: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub endpoints: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub debug_flags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_handle: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DnsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resolvers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search_domains: Vec<String>,
#[serde(default)]
pub magic_dns: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PacketFilter {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub destinations: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protocols: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct MapResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub map_session_handle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
pub node: Node,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<Node>,
pub domain: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns: Option<DnsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packet_filters: Vec<PacketFilter>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalAuthRequest {
pub identifier: String,
pub password: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalAuthResponse {
pub access_token: String,
pub user: UserProfile,
}
fn default_capability_version() -> i32 {
BURROW_CAPABILITY_VERSION
}
fn short_key(key: &str) -> String {
key.chars().take(8).collect()
}

View file

@ -63,8 +63,6 @@ mod tests {
};
use anyhow::{anyhow, Result};
use iroh::PublicKey;
use serde_json::json;
use tokio::time::{timeout, Duration};
use super::*;
@ -172,15 +170,15 @@ mod tests {
.networks_client
.network_add(Network {
id: 2,
r#type: NetworkType::HackClub.into(),
payload: sample_hackclub_payload(),
r#type: NetworkType::WireGuard.into(),
payload: sample_wireguard_payload_with("10.77.0.2/32", 1380),
})
.await?;
let networks_after_mesh_add = next_networks(&mut network_stream).await?;
let networks_after_second_add = next_networks(&mut network_stream).await?;
assert_eq!(
network_ids(&networks_after_mesh_add),
vec![(1, NetworkType::WireGuard), (2, NetworkType::HackClub)]
network_ids(&networks_after_second_add),
vec![(1, NetworkType::WireGuard), (2, NetworkType::WireGuard)]
);
let still_wireguard = next_configuration(&mut config_stream).await?;
@ -194,12 +192,12 @@ mod tests {
let networks_after_reorder = next_networks(&mut network_stream).await?;
assert_eq!(
network_ids(&networks_after_reorder),
vec![(2, NetworkType::HackClub), (1, NetworkType::WireGuard)]
vec![(2, NetworkType::WireGuard), (1, NetworkType::WireGuard)]
);
let mesh_config = next_configuration(&mut config_stream).await?;
assert_eq!(mesh_config.addresses, vec!["10.77.0.2/32"]);
assert_eq!(mesh_config.mtu, 1380);
let second_wireguard_config = next_configuration(&mut config_stream).await?;
assert_eq!(second_wireguard_config.addresses, vec!["10.77.0.2/32"]);
assert_eq!(second_wireguard_config.mtu, 1380);
daemon_task.abort();
let _ = daemon_task.await;
@ -237,16 +235,10 @@ Endpoint = wg.burrow.rs:51820
.to_vec()
}
fn sample_hackclub_payload() -> Vec<u8> {
let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string();
json!({
"endpoint_id": endpoint_id,
"addresses": ["127.0.0.1:7777"],
"local_addresses": ["10.77.0.2/32"],
"mtu": 1380,
"tun_name": "burrow-test-mesh",
})
.to_string()
fn sample_wireguard_payload_with(address: &str, mtu: u16) -> Vec<u8> {
format!(
"[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = wg.burrow.rs:51820\n"
)
.into_bytes()
}

View file

@ -9,7 +9,7 @@ use super::rpc::{
ServerConfig,
};
use crate::{
mesh::iroh::{self as mesh_iroh, HackClubNetworkConfig, MeshHandle},
control::TailnetConfig,
wireguard::{Config, Interface as WireGuardInterface},
};
@ -28,14 +28,14 @@ pub enum ResolvedTunnel {
Passthrough {
identity: RuntimeIdentity,
},
Tailnet {
identity: RuntimeIdentity,
config: TailnetConfig,
},
WireGuard {
identity: RuntimeIdentity,
config: Config,
},
HackClub {
identity: RuntimeIdentity,
config: HackClubNetworkConfig,
},
}
impl ResolvedTunnel {
@ -53,24 +53,24 @@ impl ResolvedTunnel {
};
match network.r#type() {
NetworkType::Tailnet => {
let config = TailnetConfig::from_slice(&network.payload)?;
Ok(Self::Tailnet { identity, config })
}
NetworkType::WireGuard => {
let payload = String::from_utf8(network.payload.clone())
.context("wireguard payload must be valid UTF-8")?;
let config = Config::from_content_fmt(&payload, "ini")?;
Ok(Self::WireGuard { identity, config })
}
NetworkType::HackClub => {
let config = HackClubNetworkConfig::from_payload(&network.payload)?;
Ok(Self::HackClub { identity, config })
}
}
}
pub fn identity(&self) -> &RuntimeIdentity {
match self {
Self::Passthrough { identity }
| Self::WireGuard { identity, .. }
| Self::HackClub { identity, .. } => identity,
| Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } => identity,
}
}
@ -81,12 +81,12 @@ impl ResolvedTunnel {
name: None,
mtu: Some(1500),
}),
Self::WireGuard { config, .. } => ServerConfig::try_from(config),
Self::HackClub { config, .. } => Ok(ServerConfig {
address: config.local_addresses.clone(),
name: config.tun_name.clone(),
mtu: config.mtu.map(i32::from),
Self::Tailnet { .. } => Ok(ServerConfig {
address: Vec::new(),
name: None,
mtu: Some(1280),
}),
Self::WireGuard { config, .. } => ServerConfig::try_from(config),
}
}
@ -96,6 +96,10 @@ impl ResolvedTunnel {
) -> Result<ActiveTunnel> {
match self {
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
Self::Tailnet { config, .. } => Err(anyhow::anyhow!(
"tailnet runtime is not wired in this checkout yet ({:?})",
config.provider
)),
Self::WireGuard { identity, config } => {
let tun = TunOptions::new().open()?;
tun_interface.write().await.replace(tun);
@ -110,23 +114,6 @@ impl ResolvedTunnel {
}
}
}
Self::HackClub { identity, config } => {
let mut tun_opts = TunOptions::new();
if let Some(name) = config.tun_name.as_deref() {
tun_opts = tun_opts.name(name);
}
let tun = tun_opts.open()?;
tun_interface.write().await.replace(tun);
match mesh_iroh::spawn_hackclub_tunnel(config, tun_interface.clone()).await {
Ok(handle) => Ok(ActiveTunnel::HackClub { identity, handle }),
Err(err) => {
tun_interface.write().await.take();
Err(err)
}
}
}
}
}
}
@ -140,18 +127,13 @@ pub enum ActiveTunnel {
interface: Arc<RwLock<WireGuardInterface>>,
task: JoinHandle<Result<()>>,
},
HackClub {
identity: RuntimeIdentity,
handle: MeshHandle,
},
}
impl ActiveTunnel {
pub fn identity(&self) -> &RuntimeIdentity {
match self {
Self::Passthrough { identity }
| Self::WireGuard { identity, .. }
| Self::HackClub { identity, .. } => identity,
| Self::WireGuard { identity, .. } => identity,
}
}
@ -165,11 +147,6 @@ impl ActiveTunnel {
task_result??;
Ok(())
}
Self::HackClub { handle, .. } => {
let result = handle.shutdown().await;
tun_interface.write().await.take();
result
}
}
}
}

View file

@ -4,10 +4,10 @@ use anyhow::Result;
use rusqlite::{params, Connection};
use crate::{
control::TailnetConfig,
daemon::rpc::grpc_defs::{
Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType,
},
mesh::iroh::HackClubNetworkConfig,
wireguard::config::{Config, Interface, Peer},
};
@ -203,8 +203,8 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> {
let payload_str = String::from_utf8(network.payload.clone())?;
Config::from_content_fmt(&payload_str, "ini")?;
}
NetworkType::HackClub => {
HackClubNetworkConfig::from_payload(&network.payload)?;
NetworkType::Tailnet => {
TailnetConfig::from_slice(&network.payload)?;
}
}
Ok(())
@ -243,8 +243,6 @@ fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use iroh::PublicKey;
use serde_json::json;
use tempfile::tempdir;
fn sample_wireguard_payload() -> Vec<u8> {
@ -262,19 +260,24 @@ Endpoint = wg.burrow.rs:51820
.to_vec()
}
fn sample_hackclub_payload(name: &str, address: &str) -> Vec<u8> {
let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string();
json!({
"endpoint_id": endpoint_id,
"addresses": ["127.0.0.1:7777"],
"local_addresses": [address],
"mtu": 1380,
"tun_name": name,
})
.to_string()
fn sample_wireguard_payload_with_address(address: &str, mtu: u16) -> Vec<u8> {
format!(
"[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0\nEndpoint = wg.burrow.rs:51820\n"
)
.into_bytes()
}
fn sample_tailnet_payload() -> Vec<u8> {
br#"{
"provider":"tailscale",
"account":"default",
"identity":"apple",
"tailnet":"example.ts.net",
"hostname":"burrow-phone"
}"#
.to_vec()
}
#[test]
fn test_db() {
let conn = Connection::open_in_memory().unwrap();
@ -304,8 +307,18 @@ Endpoint = wg.burrow.rs:51820
&conn,
&RPCNetwork {
id: 2,
r#type: NetworkType::HackClub.into(),
payload: sample_hackclub_payload("burrow-test-0", "10.42.0.2/32"),
r#type: NetworkType::Tailnet.into(),
payload: sample_tailnet_payload(),
},
)
.unwrap();
add_network(
&conn,
&RPCNetwork {
id: 3,
r#type: NetworkType::WireGuard.into(),
payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380),
},
)
.unwrap();
@ -313,19 +326,29 @@ Endpoint = wg.burrow.rs:51820
assert!(add_network(
&conn,
&RPCNetwork {
id: 3,
id: 4,
r#type: NetworkType::WireGuard.into(),
payload: b"not-a-config".to_vec(),
},
)
.is_err());
assert!(add_network(
&conn,
&RPCNetwork {
id: 5,
r#type: NetworkType::Tailnet.into(),
payload: b"not-a-tailnet-config".to_vec(),
},
)
.is_err());
let ids: Vec<i32> = list_networks(&conn)
.unwrap()
.into_iter()
.map(|n| n.id)
.collect();
assert_eq!(ids, vec![1, 2]);
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
@ -333,17 +356,17 @@ Endpoint = wg.burrow.rs:51820
let conn = Connection::open_in_memory().unwrap();
initialize_tables(&conn).unwrap();
for (id, name, address) in [
(1, "burrow-test-1", "10.42.0.2/32"),
(2, "burrow-test-2", "10.42.0.3/32"),
(3, "burrow-test-3", "10.42.0.4/32"),
for (id, address, mtu) in [
(1, "10.42.0.2/32", 1380),
(2, "10.42.0.3/32", 1381),
(3, "10.42.0.4/32", 1382),
] {
add_network(
&conn,
&RPCNetwork {
id,
r#type: NetworkType::HackClub.into(),
payload: sample_hackclub_payload(name, address),
r#type: NetworkType::WireGuard.into(),
payload: sample_wireguard_payload_with_address(address, mtu),
},
)
.unwrap();

View file

@ -45,7 +45,7 @@ message Network {
enum NetworkType {
WireGuard = 0;
HackClub = 1;
Tailnet = 1;
}
message NetworkListResponse {