Add Tailnet accounts and Tailscale login flow
This commit is contained in:
parent
f9062eae33
commit
7670a75840
29 changed files with 3538 additions and 775 deletions
|
|
@ -6,6 +6,8 @@ import SwiftUI
|
||||||
@main
|
@main
|
||||||
@MainActor
|
@MainActor
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var windowController: NSWindowController?
|
||||||
|
|
||||||
private let quitItem: NSMenuItem = {
|
private let quitItem: NSMenuItem = {
|
||||||
let quitItem = NSMenuItem(
|
let quitItem = NSMenuItem(
|
||||||
title: "Quit Burrow",
|
title: "Quit Burrow",
|
||||||
|
|
@ -17,6 +19,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
return quitItem
|
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 = {
|
private let toggleItem: NSMenuItem = {
|
||||||
let toggleView = NSHostingView(rootView: MenuItemToggleView())
|
let toggleView = NSHostingView(rootView: MenuItemToggleView())
|
||||||
toggleView.frame.size = CGSize(width: 300, height: 32)
|
toggleView.frame.size = CGSize(width: 300, height: 32)
|
||||||
|
|
@ -31,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.items = [
|
menu.items = [
|
||||||
toggleItem,
|
toggleItem,
|
||||||
|
openItem,
|
||||||
.separator(),
|
.separator(),
|
||||||
quitItem
|
quitItem
|
||||||
]
|
]
|
||||||
|
|
@ -49,5 +63,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
statusItem.menu = menu
|
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
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
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 */; };
|
D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; };
|
||||||
D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; };
|
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 */; };
|
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; };
|
||||||
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; };
|
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; };
|
||||||
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.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 */; };
|
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; };
|
||||||
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; };
|
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; };
|
||||||
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.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 */; };
|
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; };
|
||||||
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; };
|
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; };
|
||||||
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -340,7 +336,6 @@
|
||||||
D0D4E4A02C8D921A007F820A /* Networks */ = {
|
D0D4E4A02C8D921A007F820A /* Networks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D0D4E49D2C8D921A007F820A /* HackClub.swift */,
|
|
||||||
D0D4E49E2C8D921A007F820A /* Network.swift */,
|
D0D4E49E2C8D921A007F820A /* Network.swift */,
|
||||||
D0D4E49F2C8D921A007F820A /* WireGuard.swift */,
|
D0D4E49F2C8D921A007F820A /* WireGuard.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -358,7 +353,6 @@
|
||||||
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */,
|
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */,
|
||||||
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */,
|
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */,
|
||||||
D0D4E4A82C8D921A007F820A /* NetworkView.swift */,
|
D0D4E4A82C8D921A007F820A /* NetworkView.swift */,
|
||||||
D0D4E4A92C8D921A007F820A /* OAuth2.swift */,
|
|
||||||
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */,
|
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */,
|
||||||
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */,
|
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */,
|
||||||
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */,
|
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */,
|
||||||
|
|
@ -634,7 +628,6 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */,
|
|
||||||
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */,
|
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */,
|
||||||
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */,
|
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */,
|
||||||
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */,
|
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */,
|
||||||
|
|
@ -644,7 +637,6 @@
|
||||||
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */,
|
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */,
|
||||||
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */,
|
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */,
|
||||||
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */,
|
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */,
|
||||||
D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */,
|
|
||||||
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */,
|
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */,
|
||||||
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */,
|
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */,
|
||||||
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */,
|
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "flag-standalone-wtransparent.pdf",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,67 +1,786 @@
|
||||||
import AuthenticationServices
|
import BurrowConfiguration
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
public struct BurrowView: View {
|
public struct BurrowView: View {
|
||||||
@Environment(\.webAuthenticationSession)
|
@State private var networkViewModel: NetworkViewModel
|
||||||
private var webAuthenticationSession
|
@State private var accountStore = NetworkAccountStore()
|
||||||
|
@State private var activeSheet: ConfigurationSheet?
|
||||||
|
@State private var didRunAutomation = false
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack {
|
ScrollView {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
Text("Networks")
|
HStack(alignment: .top) {
|
||||||
.font(.largeTitle)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.fontWeight(.bold)
|
Text("Burrow")
|
||||||
Spacer()
|
.font(.largeTitle)
|
||||||
Menu {
|
.fontWeight(.bold)
|
||||||
Button("Hack Club", action: addHackClubNetwork)
|
Text("Networks and accounts")
|
||||||
Button("WireGuard", action: addWireGuardNetwork)
|
.font(.headline)
|
||||||
} label: {
|
.foregroundStyle(.secondary)
|
||||||
Image(systemName: "plus.circle.fill")
|
}
|
||||||
.font(.title)
|
Spacer()
|
||||||
.accessibilityLabel("Add")
|
Menu {
|
||||||
|
Button("Add WireGuard Network") {
|
||||||
|
activeSheet = .wireGuard
|
||||||
|
}
|
||||||
|
Button("Save Tor Account") {
|
||||||
|
activeSheet = .tor
|
||||||
|
}
|
||||||
|
Button("Add Tailnet Account") {
|
||||||
|
activeSheet = .tailnet
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title)
|
||||||
|
.accessibilityLabel("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
sectionHeader(
|
||||||
|
title: "Networks",
|
||||||
|
detail: "Stored daemon networks and their active account selectors"
|
||||||
|
)
|
||||||
|
if let connectionError = networkViewModel.connectionError {
|
||||||
|
Text(connectionError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
NetworkCarouselView(networks: networkViewModel.cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
sectionHeader(
|
||||||
|
title: "Accounts",
|
||||||
|
detail: "Per-network identities and sign-in state"
|
||||||
|
)
|
||||||
|
if accountStore.accounts.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Accounts Yet",
|
||||||
|
systemImage: "person.crop.circle.badge.plus",
|
||||||
|
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 180)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(accountStore.accounts) { account in
|
||||||
|
AccountRowView(
|
||||||
|
account: account,
|
||||||
|
hasSecret: accountStore.hasStoredSecret(for: account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionHeader(
|
||||||
|
title: "Tunnel",
|
||||||
|
detail: "Current system extension state"
|
||||||
|
)
|
||||||
|
TunnelStatusView()
|
||||||
|
TunnelButton()
|
||||||
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding()
|
||||||
NetworkCarouselView()
|
|
||||||
Spacer()
|
|
||||||
TunnelStatusView()
|
|
||||||
TunnelButton()
|
|
||||||
.padding(.bottom)
|
|
||||||
}
|
}
|
||||||
.padding()
|
}
|
||||||
.handleOAuth2Callback()
|
.sheet(item: $activeSheet) { sheet in
|
||||||
|
ConfigurationSheetView(
|
||||||
|
sheet: sheet,
|
||||||
|
networkViewModel: networkViewModel,
|
||||||
|
accountStore: accountStore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
runAutomationIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
|
_networkViewModel = State(
|
||||||
|
initialValue: NetworkViewModel(
|
||||||
|
socketURLResult: Result { try Constants.socketURL }
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addHackClubNetwork() {
|
private func runAutomationIfNeeded() {
|
||||||
Task {
|
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else {
|
||||||
try await authenticateWithSlack()
|
return
|
||||||
|
}
|
||||||
|
didRunAutomation = true
|
||||||
|
activeSheet = .tailnet
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionHeader(title: String, detail: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Text(detail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ConfigurationSheet: String, Identifiable {
|
||||||
|
case wireGuard
|
||||||
|
case tor
|
||||||
|
case tailnet
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var kind: AccountNetworkKind {
|
||||||
|
switch self {
|
||||||
|
case .wireGuard: .wireGuard
|
||||||
|
case .tor: .tor
|
||||||
|
case .tailnet: .headscale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountDraft {
|
||||||
|
var title = ""
|
||||||
|
var accountName = ""
|
||||||
|
var identityName = ""
|
||||||
|
var wireGuardConfig = ""
|
||||||
|
|
||||||
|
var tailnetProvider: TailnetProvider = .tailscale
|
||||||
|
var authority = ""
|
||||||
|
var tailnet = ""
|
||||||
|
var hostname = ProcessInfo.processInfo.hostName
|
||||||
|
var username = ""
|
||||||
|
var secret = ""
|
||||||
|
var authMode: AccountAuthMode = .web
|
||||||
|
|
||||||
|
var torAddresses = "100.64.0.2/32"
|
||||||
|
var torDNS = "1.1.1.1, 1.0.0.1"
|
||||||
|
var torMTU = "1400"
|
||||||
|
var torListen = "127.0.0.1:9040"
|
||||||
|
|
||||||
|
init(sheet: ConfigurationSheet) {
|
||||||
|
switch sheet {
|
||||||
|
case .wireGuard:
|
||||||
|
break
|
||||||
|
case .tor:
|
||||||
|
title = "Default Tor"
|
||||||
|
accountName = "default"
|
||||||
|
identityName = "apple"
|
||||||
|
case .tailnet:
|
||||||
|
title = "Tailnet"
|
||||||
|
accountName = "default"
|
||||||
|
identityName = "apple"
|
||||||
|
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConfigurationSheetView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
let sheet: ConfigurationSheet
|
||||||
|
let networkViewModel: NetworkViewModel
|
||||||
|
let accountStore: NetworkAccountStore
|
||||||
|
|
||||||
|
@State private var draft: AccountDraft
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var loginSessionID: String?
|
||||||
|
@State private var loginStatus: TailnetLoginStatus?
|
||||||
|
@State private var pollingTask: Task<Void, Never>?
|
||||||
|
@State private var didRunAutomation = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
sheet: ConfigurationSheet,
|
||||||
|
networkViewModel: NetworkViewModel,
|
||||||
|
accountStore: NetworkAccountStore
|
||||||
|
) {
|
||||||
|
self.sheet = sheet
|
||||||
|
self.networkViewModel = networkViewModel
|
||||||
|
self.accountStore = accountStore
|
||||||
|
_draft = State(initialValue: AccountDraft(sheet: sheet))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text(sheet.kind.subtitle)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let availabilityNote = sheet.kind.availabilityNote {
|
||||||
|
Text(availabilityNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Identity") {
|
||||||
|
TextField("Title", text: $draft.title)
|
||||||
|
TextField("Account", text: $draft.accountName)
|
||||||
|
TextField("Identity", text: $draft.identityName)
|
||||||
|
if sheet == .tailnet {
|
||||||
|
TextField("Hostname", text: $draft.hostname)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sheet {
|
||||||
|
case .wireGuard:
|
||||||
|
Section("WireGuard Configuration") {
|
||||||
|
TextEditor(text: $draft.wireGuardConfig)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.frame(minHeight: 220)
|
||||||
|
}
|
||||||
|
case .tor:
|
||||||
|
Section("Tor Preferences") {
|
||||||
|
TextField("Virtual Addresses", text: $draft.torAddresses)
|
||||||
|
TextField("DNS Resolvers", text: $draft.torDNS)
|
||||||
|
TextField("MTU", text: $draft.torMTU)
|
||||||
|
TextField("Transparent Listener", text: $draft.torListen)
|
||||||
|
}
|
||||||
|
case .tailnet:
|
||||||
|
tailnetSections
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(sheet.kind.title)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(confirmationTitle) {
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
.disabled(isSubmitting || submissionDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 520, minHeight: 620)
|
||||||
|
.onAppear {
|
||||||
|
runAutomationIfNeeded()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
pollingTask?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addWireGuardNetwork() {
|
@ViewBuilder
|
||||||
|
private var tailnetSections: some View {
|
||||||
|
Section("Tailnet Provider") {
|
||||||
|
Picker("Provider", selection: $draft.tailnetProvider) {
|
||||||
|
ForEach(TailnetProvider.allCases) { provider in
|
||||||
|
Text(provider.title).tag(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(draft.tailnetProvider.subtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Tailnet") {
|
||||||
|
if draft.tailnetProvider.requiresControlURL {
|
||||||
|
TextField("Server URL", text: $draft.authority)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
TextField("Tailnet", text: $draft.tailnet)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
if draft.tailnetProvider.usesWebLogin {
|
||||||
|
Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
TextField("Username", text: $draft.username)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Picker("Authentication", selection: $draft.authMode) {
|
||||||
|
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
|
||||||
|
Text(mode.title).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if draft.authMode != .none {
|
||||||
|
SecureField(
|
||||||
|
draft.authMode == .password ? "Password" : "Preauth Key",
|
||||||
|
text: $draft.secret
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.tailnetProvider.usesWebLogin {
|
||||||
|
Section("Tailscale Sign-In") {
|
||||||
|
if let loginStatus {
|
||||||
|
labeledValue("State", loginStatus.backendState)
|
||||||
|
if let tailnetName = loginStatus.tailnetName {
|
||||||
|
labeledValue("Tailnet", tailnetName)
|
||||||
|
}
|
||||||
|
if let dnsName = loginStatus.selfDNSName {
|
||||||
|
labeledValue("Device", dnsName)
|
||||||
|
}
|
||||||
|
if !loginStatus.tailscaleIPs.isEmpty {
|
||||||
|
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
if let authURL = loginStatus.authURL {
|
||||||
|
labeledValue("Login URL", authURL)
|
||||||
|
Button("Open Login Page") {
|
||||||
|
if let url = URL(string: authURL) {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !loginStatus.health.isEmpty {
|
||||||
|
Text(loginStatus.health.joined(separator: " • "))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authenticateWithSlack() async throws {
|
private var confirmationTitle: String {
|
||||||
guard
|
switch sheet {
|
||||||
let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"),
|
case .wireGuard:
|
||||||
let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"),
|
return "Add Network"
|
||||||
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
|
case .tor:
|
||||||
let session = OAuth2.Session(
|
return "Save Account"
|
||||||
authorizationEndpoint: authorizationEndpoint,
|
case .tailnet:
|
||||||
tokenEndpoint: tokenEndpoint,
|
if draft.tailnetProvider.usesWebLogin {
|
||||||
redirectURI: redirectURI,
|
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
|
||||||
scopes: ["openid", "profile"],
|
}
|
||||||
clientID: "2210535565.6884042183125",
|
return "Save Account"
|
||||||
clientSecret: "2793c8a5255cae38830934c664eeb62d"
|
}
|
||||||
)
|
|
||||||
let response = try await session.authorize(webAuthenticationSession)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#if DEBUG
|
||||||
|
|
@ -72,4 +791,3 @@ struct NetworkView_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,45 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NetworkCarouselView: View {
|
struct NetworkCarouselView: View {
|
||||||
var networks: [any Network] = [
|
var networks: [NetworkCardModel]
|
||||||
HackClub(id: 1),
|
|
||||||
HackClub(id: 2),
|
|
||||||
WireGuard(id: 4),
|
|
||||||
HackClub(id: 5)
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal) {
|
Group {
|
||||||
LazyHStack {
|
if networks.isEmpty {
|
||||||
ForEach(networks, id: \.id) { network in
|
ContentUnavailableView(
|
||||||
NetworkView(network: network)
|
"No Networks Yet",
|
||||||
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
systemImage: "network.slash",
|
||||||
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.")
|
||||||
content
|
)
|
||||||
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
.frame(maxWidth: .infinity, minHeight: 175)
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
LazyHStack {
|
||||||
|
ForEach(networks) { network in
|
||||||
|
NetworkView(network: network)
|
||||||
|
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
||||||
|
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
||||||
|
content
|
||||||
|
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.scrollTargetLayout()
|
||||||
|
.scrollClipDisabled()
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.defaultScrollAnchor(.center)
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
.containerRelativeFrame(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollTargetLayout()
|
|
||||||
.scrollClipDisabled()
|
|
||||||
.scrollIndicators(.hidden)
|
|
||||||
.defaultScrollAnchor(.center)
|
|
||||||
.scrollTargetBehavior(.viewAligned)
|
|
||||||
.containerRelativeFrame(.horizontal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
struct NetworkCarouselView_Previews: PreviewProvider {
|
struct NetworkCarouselView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NetworkCarouselView()
|
NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel {
|
||||||
|
|
||||||
let proto = NETunnelProviderProtocol()
|
let proto = NETunnelProviderProtocol()
|
||||||
proto.providerBundleIdentifier = bundleIdentifier
|
proto.providerBundleIdentifier = bundleIdentifier
|
||||||
proto.serverAddress = "hackclub.com"
|
proto.serverAddress = "burrow.rs"
|
||||||
|
|
||||||
manager.protocolConfiguration = proto
|
manager.protocolConfiguration = proto
|
||||||
try await manager.save()
|
try await manager.save()
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ struct NetworkView<Content: View>: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NetworkView where Content == AnyView {
|
extension NetworkView where Content == AnyView {
|
||||||
init(network: any Network) {
|
init(network: NetworkCardModel) {
|
||||||
color = network.backgroundColor
|
color = network.backgroundColor
|
||||||
content = { AnyView(network.label) }
|
content = { network.label }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +1,539 @@
|
||||||
import Atomics
|
import BurrowConfiguration
|
||||||
import BurrowCore
|
import BurrowCore
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
import SwiftProtobuf
|
import SwiftProtobuf
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
protocol Network {
|
struct NetworkCardModel: Identifiable {
|
||||||
associatedtype NetworkType: Message
|
let id: Int32
|
||||||
associatedtype Label: View
|
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 }
|
func encoded() throws -> Data {
|
||||||
var backgroundColor: Color { get }
|
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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NetworkViewModel: Sendable {
|
final class NetworkViewModel: Sendable {
|
||||||
private(set) var networks: [Burrow_Network] = []
|
private(set) var networks: [Burrow_Network] = []
|
||||||
|
private(set) var connectionError: String?
|
||||||
|
private let socketURLResult: Result<URL, Error>
|
||||||
|
|
||||||
private var task: Task<Void, Error>!
|
nonisolated(unsafe) private var task: Task<Void, Never>?
|
||||||
|
|
||||||
init(socketURL: URL) {
|
init(socketURLResult: Result<URL, Error>) {
|
||||||
|
self.socketURLResult = socketURLResult
|
||||||
|
startStreaming()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
task?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
var cards: [NetworkCardModel] {
|
||||||
|
networks.map(Self.makeCard(for:))
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextNetworkID: Int32 {
|
||||||
|
(networks.map(\.id).max() ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWireGuardNetwork(configText: String) async throws -> Int32 {
|
||||||
|
try await addNetwork(type: .wireGuard, payload: Data(configText.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 {
|
||||||
|
try await addNetwork(type: .tailnet, payload: payload.encoded())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
|
||||||
|
let socketURL = try socketURLResult.get()
|
||||||
|
let networkID = nextNetworkID
|
||||||
|
let request = Burrow_Network.with {
|
||||||
|
$0.id = networkID
|
||||||
|
$0.type = type
|
||||||
|
$0.payload = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = NetworksClient.unix(socketURL: socketURL)
|
||||||
|
_ = try await client.networkAdd(request)
|
||||||
|
return networkID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startStreaming() {
|
||||||
|
task?.cancel()
|
||||||
|
let socketURLResult = self.socketURLResult
|
||||||
task = Task { [weak self] in
|
task = Task { [weak self] in
|
||||||
let client = NetworksClient.unix(socketURL: socketURL)
|
do {
|
||||||
for try await networks in client.networkList(.init()) {
|
let socketURL = try socketURLResult.get()
|
||||||
guard let viewModel = self else { continue }
|
let client = NetworksClient.unix(socketURL: socketURL)
|
||||||
Task { @MainActor in
|
for try await response in client.networkList(.init()) {
|
||||||
viewModel.networks = networks.network
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,40 @@
|
||||||
import BurrowCore
|
import BurrowCore
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WireGuard: Network {
|
struct WireGuardCard {
|
||||||
typealias NetworkType = Burrow_WireGuardNetwork
|
|
||||||
static let type: BurrowCore.Burrow_NetworkType = .wireGuard
|
|
||||||
|
|
||||||
var id: Int32
|
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
|
GeometryReader { reader in
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -23,12 +49,29 @@ struct WireGuard: Network {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: reader.size.height / 4)
|
.frame(maxWidth: .infinity, maxHeight: reader.size.height / 4)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("@conradev")
|
Text(detail)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
|
.lineLimit(3)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
Tools/tailscale-login-bridge/go.mod
Normal file
66
Tools/tailscale-login-bridge/go.mod
Normal 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
|
||||||
|
)
|
||||||
229
Tools/tailscale-login-bridge/go.sum
Normal file
229
Tools/tailscale-login-bridge/go.sum
Normal 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=
|
||||||
133
Tools/tailscale-login-bridge/main.go
Normal file
133
Tools/tailscale-login-bridge/main.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
pub mod client;
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
|
||||||
|
|
@ -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 static PATH: &str = "./server.sqlite3";
|
||||||
|
|
||||||
pub fn init_db() -> Result<()> {
|
fn apply_map_request(conn: &Connection, user_id: i64, request: &MapRequest) -> Result<()> {
|
||||||
let conn = rusqlite::Connection::open(PATH)?;
|
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(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS user (
|
"UPDATE control_node
|
||||||
id PRIMARY KEY,
|
SET disco_key = COALESCE(?, disco_key),
|
||||||
created_at TEXT NOT NULL
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn store_connection(
|
fn find_existing_node(
|
||||||
openid_user: super::providers::OpenIdUser,
|
conn: &Connection,
|
||||||
openid_provider: &str,
|
user_id: i64,
|
||||||
access_token: &str,
|
request: &RegisterRequest,
|
||||||
refresh_token: Option<&str>,
|
) -> Result<Option<(i64, String, String)>> {
|
||||||
) -> Result<()> {
|
let mut candidates = vec![request.node_key.as_str()];
|
||||||
log::debug!("Storing openid user {:#?}", openid_user);
|
if let Some(old) = request.old_node_key.as_deref() {
|
||||||
let conn = rusqlite::Connection::open(PATH)?;
|
if old != request.node_key {
|
||||||
|
candidates.push(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute(
|
for candidate in candidates {
|
||||||
"INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))",
|
let hit = conn
|
||||||
(&openid_user.sub,),
|
.query_row(
|
||||||
)?;
|
"SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?",
|
||||||
conn.execute(
|
params![user_id, candidate],
|
||||||
"INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES (
|
|row| {
|
||||||
(SELECT id FROM user WHERE id = ?),
|
Ok((
|
||||||
?,
|
row.get::<_, i64>(0)?,
|
||||||
?,
|
row.get::<_, String>(1)?,
|
||||||
?,
|
row.get::<_, String>(2)?,
|
||||||
?,
|
))
|
||||||
?
|
},
|
||||||
)",
|
)
|
||||||
(&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token),
|
.optional()?;
|
||||||
)?;
|
if hit.is_some() {
|
||||||
|
return Ok(hit);
|
||||||
Ok(())
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn store_device(
|
fn load_peers(conn: &Connection, self_id: i64) -> Result<Vec<Node>> {
|
||||||
openid_user: super::providers::OpenIdUser,
|
let mut stmt = conn.prepare(
|
||||||
openid_provider: &str,
|
"SELECT id, stable_id, created_at FROM control_node WHERE id != ? AND machine_authorized = 1 ORDER BY id",
|
||||||
access_token: &str,
|
)?;
|
||||||
refresh_token: Option<&str>,
|
let peers = stmt
|
||||||
) -> Result<()> {
|
.query_map([self_id], |row| {
|
||||||
log::debug!("Storing openid user {:#?}", openid_user);
|
Ok((
|
||||||
let conn = rusqlite::Connection::open(PATH)?;
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
// TODO
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
Ok(())
|
})?
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,277 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod providers;
|
pub mod tailscale;
|
||||||
|
|
||||||
use anyhow::Result;
|
use std::{env, path::Path};
|
||||||
use axum::{http::StatusCode, routing::post, Router};
|
|
||||||
use providers::slack::auth;
|
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 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<()> {
|
pub async fn serve() -> Result<()> {
|
||||||
db::init_db()?;
|
serve_with_config(AuthServerConfig::from_env()).await
|
||||||
|
}
|
||||||
|
|
||||||
let app = Router::new()
|
pub async fn serve_with_config(config: AuthServerConfig) -> Result<()> {
|
||||||
.route("/slack-auth", post(auth))
|
db::init_db(&config.db_path)?;
|
||||||
.route("/device/new", post(device_new));
|
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();
|
let app = build_router(config.clone());
|
||||||
log::info!("Starting auth server on port 8080");
|
let listener = tokio::net::TcpListener::bind(&config.listen).await?;
|
||||||
|
log::info!("Starting auth server on {}", config.listen);
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
Ok(())
|
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
|
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() {
|
async fn shutdown_signal() {
|
||||||
let ctrl_c = async {
|
let ctrl_c = async {
|
||||||
signal::ctrl_c()
|
signal::ctrl_c()
|
||||||
|
|
@ -51,12 +296,102 @@ async fn shutdown_signal() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mod db {
|
#[cfg(test)]
|
||||||
// use rusqlite::{Connection, Result};
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
body::{to_bytes, Body},
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
// #[derive(Debug)]
|
#[tokio::test]
|
||||||
// struct User {
|
async fn login_register_and_map_round_trip() -> Result<()> {
|
||||||
// id: i32,
|
let dir = tempdir()?;
|
||||||
// created_at: String,
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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"))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
320
burrow/src/auth/server/tailscale.rs
Normal file
320
burrow/src/auth/server/tailscale.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
87
burrow/src/control/config.rs
Normal file
87
burrow/src/control/config.rs
Normal 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
253
burrow/src/control/mod.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -63,8 +63,6 @@ mod tests {
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use iroh::PublicKey;
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::time::{timeout, Duration};
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -172,15 +170,15 @@ mod tests {
|
||||||
.networks_client
|
.networks_client
|
||||||
.network_add(Network {
|
.network_add(Network {
|
||||||
id: 2,
|
id: 2,
|
||||||
r#type: NetworkType::HackClub.into(),
|
r#type: NetworkType::WireGuard.into(),
|
||||||
payload: sample_hackclub_payload(),
|
payload: sample_wireguard_payload_with("10.77.0.2/32", 1380),
|
||||||
})
|
})
|
||||||
.await?;
|
.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!(
|
assert_eq!(
|
||||||
network_ids(&networks_after_mesh_add),
|
network_ids(&networks_after_second_add),
|
||||||
vec![(1, NetworkType::WireGuard), (2, NetworkType::HackClub)]
|
vec![(1, NetworkType::WireGuard), (2, NetworkType::WireGuard)]
|
||||||
);
|
);
|
||||||
|
|
||||||
let still_wireguard = next_configuration(&mut config_stream).await?;
|
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?;
|
let networks_after_reorder = next_networks(&mut network_stream).await?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
network_ids(&networks_after_reorder),
|
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?;
|
let second_wireguard_config = next_configuration(&mut config_stream).await?;
|
||||||
assert_eq!(mesh_config.addresses, vec!["10.77.0.2/32"]);
|
assert_eq!(second_wireguard_config.addresses, vec!["10.77.0.2/32"]);
|
||||||
assert_eq!(mesh_config.mtu, 1380);
|
assert_eq!(second_wireguard_config.mtu, 1380);
|
||||||
|
|
||||||
daemon_task.abort();
|
daemon_task.abort();
|
||||||
let _ = daemon_task.await;
|
let _ = daemon_task.await;
|
||||||
|
|
@ -237,16 +235,10 @@ Endpoint = wg.burrow.rs:51820
|
||||||
.to_vec()
|
.to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_hackclub_payload() -> Vec<u8> {
|
fn sample_wireguard_payload_with(address: &str, mtu: u16) -> Vec<u8> {
|
||||||
let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string();
|
format!(
|
||||||
json!({
|
"[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"
|
||||||
"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()
|
|
||||||
.into_bytes()
|
.into_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use super::rpc::{
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
mesh::iroh::{self as mesh_iroh, HackClubNetworkConfig, MeshHandle},
|
control::TailnetConfig,
|
||||||
wireguard::{Config, Interface as WireGuardInterface},
|
wireguard::{Config, Interface as WireGuardInterface},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -28,14 +28,14 @@ pub enum ResolvedTunnel {
|
||||||
Passthrough {
|
Passthrough {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
},
|
},
|
||||||
|
Tailnet {
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
config: TailnetConfig,
|
||||||
|
},
|
||||||
WireGuard {
|
WireGuard {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
config: Config,
|
config: Config,
|
||||||
},
|
},
|
||||||
HackClub {
|
|
||||||
identity: RuntimeIdentity,
|
|
||||||
config: HackClubNetworkConfig,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedTunnel {
|
impl ResolvedTunnel {
|
||||||
|
|
@ -53,24 +53,24 @@ impl ResolvedTunnel {
|
||||||
};
|
};
|
||||||
|
|
||||||
match network.r#type() {
|
match network.r#type() {
|
||||||
|
NetworkType::Tailnet => {
|
||||||
|
let config = TailnetConfig::from_slice(&network.payload)?;
|
||||||
|
Ok(Self::Tailnet { identity, config })
|
||||||
|
}
|
||||||
NetworkType::WireGuard => {
|
NetworkType::WireGuard => {
|
||||||
let payload = String::from_utf8(network.payload.clone())
|
let payload = String::from_utf8(network.payload.clone())
|
||||||
.context("wireguard payload must be valid UTF-8")?;
|
.context("wireguard payload must be valid UTF-8")?;
|
||||||
let config = Config::from_content_fmt(&payload, "ini")?;
|
let config = Config::from_content_fmt(&payload, "ini")?;
|
||||||
Ok(Self::WireGuard { identity, config })
|
Ok(Self::WireGuard { identity, config })
|
||||||
}
|
}
|
||||||
NetworkType::HackClub => {
|
|
||||||
let config = HackClubNetworkConfig::from_payload(&network.payload)?;
|
|
||||||
Ok(Self::HackClub { identity, config })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identity(&self) -> &RuntimeIdentity {
|
pub fn identity(&self) -> &RuntimeIdentity {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity }
|
Self::Passthrough { identity }
|
||||||
| Self::WireGuard { identity, .. }
|
| Self::Tailnet { identity, .. }
|
||||||
| Self::HackClub { identity, .. } => identity,
|
| Self::WireGuard { identity, .. } => identity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,12 +81,12 @@ impl ResolvedTunnel {
|
||||||
name: None,
|
name: None,
|
||||||
mtu: Some(1500),
|
mtu: Some(1500),
|
||||||
}),
|
}),
|
||||||
Self::WireGuard { config, .. } => ServerConfig::try_from(config),
|
Self::Tailnet { .. } => Ok(ServerConfig {
|
||||||
Self::HackClub { config, .. } => Ok(ServerConfig {
|
address: Vec::new(),
|
||||||
address: config.local_addresses.clone(),
|
name: None,
|
||||||
name: config.tun_name.clone(),
|
mtu: Some(1280),
|
||||||
mtu: config.mtu.map(i32::from),
|
|
||||||
}),
|
}),
|
||||||
|
Self::WireGuard { config, .. } => ServerConfig::try_from(config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +96,10 @@ impl ResolvedTunnel {
|
||||||
) -> Result<ActiveTunnel> {
|
) -> Result<ActiveTunnel> {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
|
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 } => {
|
Self::WireGuard { identity, config } => {
|
||||||
let tun = TunOptions::new().open()?;
|
let tun = TunOptions::new().open()?;
|
||||||
tun_interface.write().await.replace(tun);
|
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>>,
|
interface: Arc<RwLock<WireGuardInterface>>,
|
||||||
task: JoinHandle<Result<()>>,
|
task: JoinHandle<Result<()>>,
|
||||||
},
|
},
|
||||||
HackClub {
|
|
||||||
identity: RuntimeIdentity,
|
|
||||||
handle: MeshHandle,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveTunnel {
|
impl ActiveTunnel {
|
||||||
pub fn identity(&self) -> &RuntimeIdentity {
|
pub fn identity(&self) -> &RuntimeIdentity {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity }
|
Self::Passthrough { identity }
|
||||||
| Self::WireGuard { identity, .. }
|
| Self::WireGuard { identity, .. } => identity,
|
||||||
| Self::HackClub { identity, .. } => identity,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,11 +147,6 @@ impl ActiveTunnel {
|
||||||
task_result??;
|
task_result??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Self::HackClub { handle, .. } => {
|
|
||||||
let result = handle.shutdown().await;
|
|
||||||
tun_interface.write().await.take();
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ use anyhow::Result;
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
control::TailnetConfig,
|
||||||
daemon::rpc::grpc_defs::{
|
daemon::rpc::grpc_defs::{
|
||||||
Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType,
|
Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType,
|
||||||
},
|
},
|
||||||
mesh::iroh::HackClubNetworkConfig,
|
|
||||||
wireguard::config::{Config, Interface, Peer},
|
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())?;
|
let payload_str = String::from_utf8(network.payload.clone())?;
|
||||||
Config::from_content_fmt(&payload_str, "ini")?;
|
Config::from_content_fmt(&payload_str, "ini")?;
|
||||||
}
|
}
|
||||||
NetworkType::HackClub => {
|
NetworkType::Tailnet => {
|
||||||
HackClubNetworkConfig::from_payload(&network.payload)?;
|
TailnetConfig::from_slice(&network.payload)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -243,8 +243,6 @@ fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use iroh::PublicKey;
|
|
||||||
use serde_json::json;
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn sample_wireguard_payload() -> Vec<u8> {
|
fn sample_wireguard_payload() -> Vec<u8> {
|
||||||
|
|
@ -262,19 +260,24 @@ Endpoint = wg.burrow.rs:51820
|
||||||
.to_vec()
|
.to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_hackclub_payload(name: &str, address: &str) -> Vec<u8> {
|
fn sample_wireguard_payload_with_address(address: &str, mtu: u16) -> Vec<u8> {
|
||||||
let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string();
|
format!(
|
||||||
json!({
|
"[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"
|
||||||
"endpoint_id": endpoint_id,
|
)
|
||||||
"addresses": ["127.0.0.1:7777"],
|
|
||||||
"local_addresses": [address],
|
|
||||||
"mtu": 1380,
|
|
||||||
"tun_name": name,
|
|
||||||
})
|
|
||||||
.to_string()
|
|
||||||
.into_bytes()
|
.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]
|
#[test]
|
||||||
fn test_db() {
|
fn test_db() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
|
@ -304,8 +307,18 @@ Endpoint = wg.burrow.rs:51820
|
||||||
&conn,
|
&conn,
|
||||||
&RPCNetwork {
|
&RPCNetwork {
|
||||||
id: 2,
|
id: 2,
|
||||||
r#type: NetworkType::HackClub.into(),
|
r#type: NetworkType::Tailnet.into(),
|
||||||
payload: sample_hackclub_payload("burrow-test-0", "10.42.0.2/32"),
|
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();
|
.unwrap();
|
||||||
|
|
@ -313,19 +326,29 @@ Endpoint = wg.burrow.rs:51820
|
||||||
assert!(add_network(
|
assert!(add_network(
|
||||||
&conn,
|
&conn,
|
||||||
&RPCNetwork {
|
&RPCNetwork {
|
||||||
id: 3,
|
id: 4,
|
||||||
r#type: NetworkType::WireGuard.into(),
|
r#type: NetworkType::WireGuard.into(),
|
||||||
payload: b"not-a-config".to_vec(),
|
payload: b"not-a-config".to_vec(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.is_err());
|
.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)
|
let ids: Vec<i32> = list_networks(&conn)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|n| n.id)
|
.map(|n| n.id)
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(ids, vec![1, 2]);
|
assert_eq!(ids, vec![1, 2, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -333,17 +356,17 @@ Endpoint = wg.burrow.rs:51820
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
initialize_tables(&conn).unwrap();
|
initialize_tables(&conn).unwrap();
|
||||||
|
|
||||||
for (id, name, address) in [
|
for (id, address, mtu) in [
|
||||||
(1, "burrow-test-1", "10.42.0.2/32"),
|
(1, "10.42.0.2/32", 1380),
|
||||||
(2, "burrow-test-2", "10.42.0.3/32"),
|
(2, "10.42.0.3/32", 1381),
|
||||||
(3, "burrow-test-3", "10.42.0.4/32"),
|
(3, "10.42.0.4/32", 1382),
|
||||||
] {
|
] {
|
||||||
add_network(
|
add_network(
|
||||||
&conn,
|
&conn,
|
||||||
&RPCNetwork {
|
&RPCNetwork {
|
||||||
id,
|
id,
|
||||||
r#type: NetworkType::HackClub.into(),
|
r#type: NetworkType::WireGuard.into(),
|
||||||
payload: sample_hackclub_payload(name, address),
|
payload: sample_wireguard_payload_with_address(address, mtu),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ message Network {
|
||||||
|
|
||||||
enum NetworkType {
|
enum NetworkType {
|
||||||
WireGuard = 0;
|
WireGuard = 0;
|
||||||
HackClub = 1;
|
Tailnet = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NetworkListResponse {
|
message NetworkListResponse {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue