diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 0ea93f4..12fe52c 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -6,6 +6,8 @@ import SwiftUI @main @MainActor class AppDelegate: NSObject, NSApplicationDelegate { + private var windowController: NSWindowController? + private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( title: "Quit Burrow", @@ -17,6 +19,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { return quitItem }() + private lazy var openItem: NSMenuItem = { + let item = NSMenuItem( + title: "Open Burrow", + action: #selector(openWindow), + keyEquivalent: "o" + ) + item.target = self + item.keyEquivalentModifierMask = .command + return item + }() + private let toggleItem: NSMenuItem = { let toggleView = NSHostingView(rootView: MenuItemToggleView()) toggleView.frame.size = CGSize(width: 300, height: 32) @@ -31,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let menu = NSMenu() menu.items = [ toggleItem, + openItem, .separator(), quitItem ] @@ -49,5 +63,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { statusItem.menu = menu } + + @objc + private func openWindow() { + if let window = windowController?.window { + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return + } + + let contentView = BurrowView() + let hostingController = NSHostingController(rootView: contentView) + let window = NSWindow(contentViewController: hostingController) + window.title = "Burrow" + window.setContentSize(NSSize(width: 820, height: 720)) + window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) + window.center() + + let controller = NSWindowController(window: window) + controller.shouldCascadeWindows = true + controller.showWindow(nil) + windowController = controller + NSApplication.shared.activate(ignoringOtherApps: true) + } } #endif diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 617b88f..995af28 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -23,7 +23,6 @@ D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; }; D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; - D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; }; D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; }; D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; }; D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; }; @@ -33,7 +32,6 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; }; D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; }; D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; }; - D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; }; D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; }; @@ -160,7 +158,6 @@ D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = ""; }; D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; - D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -171,7 +168,6 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; - D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; @@ -340,7 +336,6 @@ D0D4E4A02C8D921A007F820A /* Networks */ = { isa = PBXGroup; children = ( - D0D4E49D2C8D921A007F820A /* HackClub.swift */, D0D4E49E2C8D921A007F820A /* Network.swift */, D0D4E49F2C8D921A007F820A /* WireGuard.swift */, ); @@ -358,7 +353,6 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */, D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */, D0D4E4A82C8D921A007F820A /* NetworkView.swift */, - D0D4E4A92C8D921A007F820A /* OAuth2.swift */, D0D4E4AA2C8D921A007F820A /* Tunnel.swift */, D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */, D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */, @@ -634,7 +628,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */, D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */, D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */, D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */, @@ -644,7 +637,6 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */, D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */, D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */, - D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */, D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */, D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */, D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */, diff --git a/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json deleted file mode 100644 index 911b4b1..0000000 --- a/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json deleted file mode 100644 index ddd0664..0000000 --- a/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "flag-standalone-wtransparent.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf b/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf deleted file mode 100644 index 1506fe9..0000000 Binary files a/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf and /dev/null differ diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 96467c7..66f42d0 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,67 +1,786 @@ -import AuthenticationServices +import BurrowConfiguration +import Foundation import SwiftUI -#if !os(macOS) public struct BurrowView: View { - @Environment(\.webAuthenticationSession) - private var webAuthenticationSession + @State private var networkViewModel: NetworkViewModel + @State private var accountStore = NetworkAccountStore() + @State private var activeSheet: ConfigurationSheet? + @State private var didRunAutomation = false public var body: some View { NavigationStack { - VStack { - HStack { - Text("Networks") - .font(.largeTitle) - .fontWeight(.bold) - Spacer() - Menu { - Button("Hack Club", action: addHackClubNetwork) - Button("WireGuard", action: addWireGuardNetwork) - } label: { - Image(systemName: "plus.circle.fill") - .font(.title) - .accessibilityLabel("Add") + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("Burrow") + .font(.largeTitle) + .fontWeight(.bold) + Text("Networks and accounts") + .font(.headline) + .foregroundStyle(.secondary) + } + Spacer() + Menu { + Button("Add WireGuard Network") { + activeSheet = .wireGuard + } + Button("Save Tor Account") { + activeSheet = .tor + } + Button("Add Tailnet Account") { + activeSheet = .tailnet + } + } label: { + Image(systemName: "plus.circle.fill") + .font(.title) + .accessibilityLabel("Add") + } + } + .padding(.top) + + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + title: "Networks", + detail: "Stored daemon networks and their active account selectors" + ) + if let connectionError = networkViewModel.connectionError { + Text(connectionError) + .font(.footnote) + .foregroundStyle(.secondary) + } + NetworkCarouselView(networks: networkViewModel.cards) + } + + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + title: "Accounts", + detail: "Per-network identities and sign-in state" + ) + if accountStore.accounts.isEmpty { + ContentUnavailableView( + "No Accounts Yet", + systemImage: "person.crop.circle.badge.plus", + description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.") + ) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVStack(spacing: 12) { + ForEach(accountStore.accounts) { account in + AccountRowView( + account: account, + hasSecret: accountStore.hasStoredSecret(for: account) + ) + } + } + } + } + + VStack(alignment: .leading, spacing: 8) { + sectionHeader( + title: "Tunnel", + detail: "Current system extension state" + ) + TunnelStatusView() + TunnelButton() + .padding(.bottom) } } - .padding(.top) - NetworkCarouselView() - Spacer() - TunnelStatusView() - TunnelButton() - .padding(.bottom) + .padding() } - .padding() - .handleOAuth2Callback() + } + .sheet(item: $activeSheet) { sheet in + ConfigurationSheetView( + sheet: sheet, + networkViewModel: networkViewModel, + accountStore: accountStore + ) + } + .onAppear { + runAutomationIfNeeded() } } public init() { + _networkViewModel = State( + initialValue: NetworkViewModel( + socketURLResult: Result { try Constants.socketURL } + ) + ) } - private func addHackClubNetwork() { - Task { - try await authenticateWithSlack() + private func runAutomationIfNeeded() { + guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else { + return + } + didRunAutomation = true + activeSheet = .tailnet + } + + @ViewBuilder + private func sectionHeader(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title2.weight(.semibold)) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } +} + +private enum ConfigurationSheet: String, Identifiable { + case wireGuard + case tor + case tailnet + + var id: String { rawValue } + + var kind: AccountNetworkKind { + switch self { + case .wireGuard: .wireGuard + case .tor: .tor + case .tailnet: .headscale + } + } +} + +private struct AccountDraft { + var title = "" + var accountName = "" + var identityName = "" + var wireGuardConfig = "" + + var tailnetProvider: TailnetProvider = .tailscale + var authority = "" + var tailnet = "" + var hostname = ProcessInfo.processInfo.hostName + var username = "" + var secret = "" + var authMode: AccountAuthMode = .web + + var torAddresses = "100.64.0.2/32" + var torDNS = "1.1.1.1, 1.0.0.1" + var torMTU = "1400" + var torListen = "127.0.0.1:9040" + + init(sheet: ConfigurationSheet) { + switch sheet { + case .wireGuard: + break + case .tor: + title = "Default Tor" + accountName = "default" + identityName = "apple" + case .tailnet: + title = "Tailnet" + accountName = "default" + identityName = "apple" + authority = TailnetProvider.tailscale.defaultAuthority ?? "" + } + } +} + +private struct ConfigurationSheetView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + let sheet: ConfigurationSheet + let networkViewModel: NetworkViewModel + let accountStore: NetworkAccountStore + + @State private var draft: AccountDraft + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var loginSessionID: String? + @State private var loginStatus: TailnetLoginStatus? + @State private var pollingTask: Task? + @State private var didRunAutomation = false + + init( + sheet: ConfigurationSheet, + networkViewModel: NetworkViewModel, + accountStore: NetworkAccountStore + ) { + self.sheet = sheet + self.networkViewModel = networkViewModel + self.accountStore = accountStore + _draft = State(initialValue: AccountDraft(sheet: sheet)) + } + + var body: some View { + NavigationStack { + Form { + Section { + Text(sheet.kind.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + if let availabilityNote = sheet.kind.availabilityNote { + Text(availabilityNote) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section("Identity") { + TextField("Title", text: $draft.title) + TextField("Account", text: $draft.accountName) + TextField("Identity", text: $draft.identityName) + if sheet == .tailnet { + TextField("Hostname", text: $draft.hostname) + .burrowLoginField() + .autocorrectionDisabled() + } + } + + switch sheet { + case .wireGuard: + Section("WireGuard Configuration") { + TextEditor(text: $draft.wireGuardConfig) + .font(.body.monospaced()) + .frame(minHeight: 220) + } + case .tor: + Section("Tor Preferences") { + TextField("Virtual Addresses", text: $draft.torAddresses) + TextField("DNS Resolvers", text: $draft.torDNS) + TextField("MTU", text: $draft.torMTU) + TextField("Transparent Listener", text: $draft.torListen) + } + case .tailnet: + tailnetSections + } + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle(sheet.kind.title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(confirmationTitle) { + submit() + } + .disabled(isSubmitting || submissionDisabled) + } + } + } + .frame(minWidth: 520, minHeight: 620) + .onAppear { + runAutomationIfNeeded() + } + .onDisappear { + pollingTask?.cancel() } } - private func addWireGuardNetwork() { + @ViewBuilder + private var tailnetSections: some View { + Section("Tailnet Provider") { + Picker("Provider", selection: $draft.tailnetProvider) { + ForEach(TailnetProvider.allCases) { provider in + Text(provider.title).tag(provider) + } + } + Text(draft.tailnetProvider.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Tailnet") { + if draft.tailnetProvider.requiresControlURL { + TextField("Server URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + } + TextField("Tailnet", text: $draft.tailnet) + .burrowLoginField() + .autocorrectionDisabled() + + if draft.tailnetProvider.usesWebLogin { + Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + Picker("Authentication", selection: $draft.authMode) { + ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in + Text(mode.title).tag(mode) + } + } + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } + } + } + + if draft.tailnetProvider.usesWebLogin { + Section("Tailscale Sign-In") { + if let loginStatus { + labeledValue("State", loginStatus.backendState) + if let tailnetName = loginStatus.tailnetName { + labeledValue("Tailnet", tailnetName) + } + if let dnsName = loginStatus.selfDNSName { + labeledValue("Device", dnsName) + } + if !loginStatus.tailscaleIPs.isEmpty { + labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) + } + if let authURL = loginStatus.authURL { + labeledValue("Login URL", authURL) + Button("Open Login Page") { + if let url = URL(string: authURL) { + openURL(url) + } + } + } + if !loginStatus.health.isEmpty { + Text(loginStatus.health.joined(separator: " • ")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } } - private func authenticateWithSlack() async throws { - guard - let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"), - let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"), - let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return } - let session = OAuth2.Session( - authorizationEndpoint: authorizationEndpoint, - tokenEndpoint: tokenEndpoint, - redirectURI: redirectURI, - scopes: ["openid", "profile"], - clientID: "2210535565.6884042183125", - clientSecret: "2793c8a5255cae38830934c664eeb62d" - ) - let response = try await session.authorize(webAuthenticationSession) + private var confirmationTitle: String { + switch sheet { + case .wireGuard: + return "Add Network" + case .tor: + return "Save Account" + case .tailnet: + if draft.tailnetProvider.usesWebLogin { + return loginStatus?.running == true ? "Save Account" : "Start Sign-In" + } + return "Save Account" + } } + + private var submissionDisabled: Bool { + switch sheet { + case .wireGuard: + return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .tor: + return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil + case .tailnet: + if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { + return true + } + if draft.tailnetProvider.usesWebLogin { + return false + } + if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { + return true + } + if draft.authMode != .none && normalizedOptional(draft.secret) == nil { + return true + } + return false + } + } + + private func submit() { + isSubmitting = true + errorMessage = nil + + Task { @MainActor in + defer { isSubmitting = false } + do { + switch sheet { + case .wireGuard: + try await submitWireGuard() + dismiss() + case .tor: + try submitTor() + dismiss() + case .tailnet: + try await submitTailnet() + } + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func submitWireGuard() async throws { + let networkID = try await networkViewModel.addWireGuardNetwork( + configText: draft.wireGuardConfig + ) + + let title = titleOrFallback("WireGuard \(networkID)") + let record = NetworkAccountRecord( + id: UUID(), + kind: .wireGuard, + title: title, + authority: nil, + provider: nil, + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "network-\(networkID)"), + hostname: nil, + username: nil, + tailnet: nil, + authMode: .none, + note: "Linked to daemon network #\(networkID).", + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: nil) + } + + private func submitTor() throws { + let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") + let note = [ + "Addresses: \(csvSummary(draft.torAddresses))", + "DNS: \(csvSummary(draft.torDNS))", + "MTU: \(normalized(draft.torMTU, fallback: "1400"))", + "Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))", + ].joined(separator: " • ") + + let record = NetworkAccountRecord( + id: UUID(), + kind: .tor, + title: title, + authority: "arti://local", + provider: nil, + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: nil, + username: nil, + tailnet: nil, + authMode: .none, + note: note, + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: nil) + } + + private func submitTailnet() async throws { + if draft.tailnetProvider.usesWebLogin { + if loginStatus?.running == true { + try await saveTailnetAccount(secret: nil, username: nil) + dismiss() + } else { + try await startTailscaleLogin() + } + return + } + + let secret = draft.authMode == .none ? nil : draft.secret + let username = normalizedOptional(draft.username) + try await saveTailnetAccount(secret: secret, username: username) + dismiss() + } + + private func startTailscaleLogin() async throws { + let response = try await TailnetBridgeClient.startLogin( + TailnetLoginStartRequest( + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: normalizedOptional(draft.hostname), + controlURL: draft.tailnetProvider.defaultAuthority + ) + ) + loginSessionID = response.sessionID + loginStatus = response.status + if let authURL = response.status.authURL, let url = URL(string: authURL) { + openLoginURL(url) + } + startPollingTailscaleLogin() + } + + private func runAutomationIfNeeded() { + guard !didRunAutomation, + sheet == .tailnet, + let automation = BurrowAutomationConfig.current, + automation.action == .tailnetLogin + else { + return + } + + didRunAutomation = true + draft.tailnetProvider = .tailscale + draft.title = automation.title ?? draft.title + draft.accountName = automation.accountName ?? draft.accountName + draft.identityName = automation.identityName ?? draft.identityName + draft.hostname = automation.hostname ?? draft.hostname + + Task { @MainActor in + do { + try await startTailscaleLogin() + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func startPollingTailscaleLogin() { + pollingTask?.cancel() + guard let loginSessionID else { return } + pollingTask = Task { @MainActor in + while !Task.isCancelled { + do { + let status = try await TailnetBridgeClient.status(sessionID: loginSessionID) + let previousAuthURL = loginStatus?.authURL + loginStatus = status + if previousAuthURL == nil, + let authURL = status.authURL, + let url = URL(string: authURL) + { + openLoginURL(url) + } + if status.running { + return + } + } catch { + errorMessage = error.localizedDescription + return + } + try? await Task.sleep(for: .seconds(2)) + } + } + } + + private func openLoginURL(_ url: URL) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + openURL(url) { accepted in + guard !accepted else { return } + errorMessage = "Burrow got a Tailscale login URL, but iOS did not open it automatically." + } + } + } + + private func saveTailnetAccount(secret: String?, username: String?) async throws { + let provider = draft.tailnetProvider + let title = titleOrFallback( + hostnameFallback( + from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, + fallback: provider.title + ) + ) + + let payload = TailnetNetworkPayload( + provider: provider, + authority: normalizedOptional(provider.defaultAuthority ?? draft.authority), + account: normalized(draft.accountName, fallback: "default"), + identity: normalized(draft.identityName, fallback: "apple"), + tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), + hostname: normalizedOptional(draft.hostname) + ) + + var noteParts: [String] = [ + provider.title, + provider.usesWebLogin + ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" + : "Auth: \(draft.authMode.title)", + ] + if let dnsName = loginStatus?.selfDNSName { + noteParts.append("Device: \(dnsName)") + } + if let magicDNSSuffix = loginStatus?.magicDNSSuffix { + noteParts.append("MagicDNS: \(magicDNSSuffix)") + } + + do { + let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) + noteParts.append("Linked to daemon network #\(networkID)") + } catch { + noteParts.append("Daemon network add pending") + } + + let record = NetworkAccountRecord( + id: UUID(), + kind: .headscale, + title: title, + authority: payload.authority, + provider: provider, + accountName: payload.account, + identityName: payload.identity, + hostname: payload.hostname, + username: username, + tailnet: payload.tailnet, + authMode: provider.usesWebLogin ? .web : draft.authMode, + note: noteParts.joined(separator: " • "), + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: secret) + } + + private func normalized(_ value: String, fallback: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private func normalizedOptional(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func titleOrFallback(_ fallback: String) -> String { + normalized(draft.title, fallback: fallback) + } + + private func csvSummary(_ value: String) -> String { + value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + private func hostnameFallback(from value: String, fallback: String) -> String { + guard let url = URL(string: value), let host = url.host, !host.isEmpty else { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + return host + } + + @ViewBuilder + private func labeledValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.monospaced()) + } + } +} + +private struct AccountRowView: View { + let account: NetworkAccountRecord + let hasSecret: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(account.title) + .font(.headline) + HStack(spacing: 8) { + Text(account.kind.title) + if let provider = account.provider { + Text(provider.title) + } + } + .font(.subheadline) + .foregroundStyle(account.kind.accentColor) + } + Spacer() + if hasSecret { + Label("Credential stored", systemImage: "key.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let authority = account.authority { + labeledValue("Authority", authority) + } + + labeledValue("Account", account.accountName) + labeledValue("Identity", account.identityName) + + if let hostname = account.hostname { + labeledValue("Hostname", hostname) + } + + if let username = account.username { + labeledValue("Username", username) + } + + if let tailnet = account.tailnet { + labeledValue("Tailnet", tailnet) + } + + if let note = account.note { + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + + @ViewBuilder + private func labeledValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.monospaced()) + } + } +} + +private extension View { + @ViewBuilder + func burrowLoginField() -> some View { + #if os(iOS) + textInputAutocapitalization(.never) + #else + self + #endif + } +} + +private struct BurrowAutomationConfig { + enum Action: String { + case tailnetLogin = "tailnet-login" + } + + let action: Action + let title: String? + let accountName: String? + let identityName: String? + let hostname: String? + + static let current: BurrowAutomationConfig? = { + let environment = ProcessInfo.processInfo.environment + guard let rawAction = environment["BURROW_UI_AUTOMATION"], + let action = Action(rawValue: rawAction) + else { + return nil + } + + return BurrowAutomationConfig( + action: action, + title: environment["BURROW_UI_AUTOMATION_TITLE"], + accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"], + identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"], + hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"] + ) + }() } #if DEBUG @@ -72,4 +791,3 @@ struct NetworkView_Previews: PreviewProvider { } } #endif -#endif diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index f969356..4bbf12f 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -1,39 +1,45 @@ import SwiftUI struct NetworkCarouselView: View { - var networks: [any Network] = [ - HackClub(id: 1), - HackClub(id: 2), - WireGuard(id: 4), - HackClub(id: 5) - ] + var networks: [NetworkCardModel] var body: some View { - ScrollView(.horizontal) { - LazyHStack { - ForEach(networks, id: \.id) { network in - NetworkView(network: network) - .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) - .scrollTransition(.interactive, axis: .horizontal) { content, phase in - content - .scaleEffect(1.0 - abs(phase.value) * 0.1) + Group { + if networks.isEmpty { + ContentUnavailableView( + "No Networks Yet", + systemImage: "network.slash", + description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.") + ) + .frame(maxWidth: .infinity, minHeight: 175) + } else { + ScrollView(.horizontal) { + LazyHStack { + ForEach(networks) { network in + NetworkView(network: network) + .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) + .scrollTransition(.interactive, axis: .horizontal) { content, phase in + content + .scaleEffect(1.0 - abs(phase.value) * 0.1) + } } + } } + .scrollTargetLayout() + .scrollClipDisabled() + .scrollIndicators(.hidden) + .defaultScrollAnchor(.center) + .scrollTargetBehavior(.viewAligned) + .containerRelativeFrame(.horizontal) } } - .scrollTargetLayout() - .scrollClipDisabled() - .scrollIndicators(.hidden) - .defaultScrollAnchor(.center) - .scrollTargetBehavior(.viewAligned) - .containerRelativeFrame(.horizontal) } } #if DEBUG struct NetworkCarouselView_Previews: PreviewProvider { static var previews: some View { - NetworkCarouselView() + NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card]) } } #endif diff --git a/Apple/UI/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift index 7aaa3b1..23559f3 100644 --- a/Apple/UI/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel { let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = bundleIdentifier - proto.serverAddress = "hackclub.com" + proto.serverAddress = "burrow.rs" manager.protocolConfiguration = proto try await manager.save() diff --git a/Apple/UI/NetworkView.swift b/Apple/UI/NetworkView.swift index b839d65..437adce 100644 --- a/Apple/UI/NetworkView.swift +++ b/Apple/UI/NetworkView.swift @@ -31,8 +31,8 @@ struct NetworkView: View { } extension NetworkView where Content == AnyView { - init(network: any Network) { + init(network: NetworkCardModel) { color = network.backgroundColor - content = { AnyView(network.label) } + content = { network.label } } } diff --git a/Apple/UI/Networks/HackClub.swift b/Apple/UI/Networks/HackClub.swift deleted file mode 100644 index b1c2023..0000000 --- a/Apple/UI/Networks/HackClub.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index c6d5fba..f38ab26 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -1,36 +1,539 @@ -import Atomics +import BurrowConfiguration import BurrowCore +import Foundation +import Security import SwiftProtobuf import SwiftUI -protocol Network { - associatedtype NetworkType: Message - associatedtype Label: View +struct NetworkCardModel: Identifiable { + let id: Int32 + let backgroundColor: Color + let label: AnyView +} - static var type: Burrow_NetworkType { get } +struct TailnetNetworkPayload: Codable, Sendable { + var provider: TailnetProvider + var authority: String? + var account: String + var identity: String + var tailnet: String? + var hostname: String? - var id: Int32 { get } - var backgroundColor: Color { get } + func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(self) + } +} - @MainActor var label: Label { get } +struct TailnetLoginStartRequest: Codable, Sendable { + var accountName: String + var identityName: String + var hostname: String? + var controlURL: String? +} + +struct TailnetLoginStatus: Codable, Sendable { + var backendState: String + var authURL: String? + var running: Bool + var needsLogin: Bool + var tailnetName: String? + var magicDNSSuffix: String? + var selfDNSName: String? + var tailscaleIPs: [String] + var health: [String] +} + +struct TailnetLoginStartResponse: Codable, Sendable { + var sessionID: String + var status: TailnetLoginStatus +} + +enum TailnetBridgeClient { + private static let baseURL = URL(string: "http://127.0.0.1:8080")! + + static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse { + var urlRequest = URLRequest( + url: baseURL.appendingPathComponent("v1/tailscale/login/start") + ) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + urlRequest.httpBody = try encoder.encode(request) + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + try validate(response: response, data: data) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TailnetLoginStartResponse.self, from: data) + } + + static func status(sessionID: String) async throws -> TailnetLoginStatus { + let url = baseURL + .appendingPathComponent("v1/tailscale/login") + .appendingPathComponent(sessionID) + let (data, response) = try await URLSession.shared.data(from: url) + try validate(response: response, data: data) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TailnetLoginStatus.self, from: data) + } + + private static func validate(response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8)?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") + } + } +} + +enum TailnetBridgeError: LocalizedError { + case server(String) + + var errorDescription: String? { + switch self { + case .server(let message): + message + } + } } @Observable @MainActor final class NetworkViewModel: Sendable { private(set) var networks: [Burrow_Network] = [] + private(set) var connectionError: String? + private let socketURLResult: Result - private var task: Task! + nonisolated(unsafe) private var task: Task? - init(socketURL: URL) { + init(socketURLResult: Result) { + self.socketURLResult = socketURLResult + startStreaming() + } + + deinit { + task?.cancel() + } + + var cards: [NetworkCardModel] { + networks.map(Self.makeCard(for:)) + } + + var nextNetworkID: Int32 { + (networks.map(\.id).max() ?? 0) + 1 + } + + func addWireGuardNetwork(configText: String) async throws -> Int32 { + try await addNetwork(type: .wireGuard, payload: Data(configText.utf8)) + } + + func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 { + try await addNetwork(type: .tailnet, payload: payload.encoded()) + } + + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { + let socketURL = try socketURLResult.get() + let networkID = nextNetworkID + let request = Burrow_Network.with { + $0.id = networkID + $0.type = type + $0.payload = payload + } + + let client = NetworksClient.unix(socketURL: socketURL) + _ = try await client.networkAdd(request) + return networkID + } + + private func startStreaming() { + task?.cancel() + let socketURLResult = self.socketURLResult task = Task { [weak self] in - let client = NetworksClient.unix(socketURL: socketURL) - for try await networks in client.networkList(.init()) { - guard let viewModel = self else { continue } - Task { @MainActor in - viewModel.networks = networks.network + do { + let socketURL = try socketURLResult.get() + let client = NetworksClient.unix(socketURL: socketURL) + for try await response in client.networkList(.init()) { + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.networks = response.network + self.connectionError = nil + } + } + } catch { + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.connectionError = error.localizedDescription } } } } + + private static func makeCard(for network: Burrow_Network) -> NetworkCardModel { + switch network.type { + case .wireGuard: + WireGuardCard(network: network).card + case .tailnet: + TailnetCard(network: network).card + case .UNRECOGNIZED(let rawValue): + unsupportedCard( + id: network.id, + title: "Unknown Network", + detail: "Type \(rawValue) is not recognized by this build." + ) + @unknown default: + unsupportedCard( + id: network.id, + title: "Unsupported Network", + detail: "Update Burrow to view this network." + ) + } + } + + private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .gray.opacity(0.85), + label: AnyView( + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + Text(detail) + .font(.body) + .foregroundStyle(.white.opacity(0.9)) + Spacer() + Text("Network #\(id)") + .font(.footnote.monospaced()) + .foregroundStyle(.white.opacity(0.8)) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + ) + ) + } +} + +enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { + case tailscale + case headscale + case burrow + + var id: String { rawValue } + + var title: String { + switch self { + case .tailscale: "Tailscale" + case .headscale: "Headscale" + case .burrow: "Burrow" + } + } + + var usesWebLogin: Bool { + self == .tailscale + } + + var requiresControlURL: Bool { + self != .tailscale + } + + var defaultAuthority: String? { + switch self { + case .tailscale: + "https://controlplane.tailscale.com" + case .headscale, .burrow: + nil + } + } + + var subtitle: String { + switch self { + case .tailscale: + "Use Tailscale's real browser login flow." + case .headscale: + "Store a Headscale control-plane endpoint and credentials." + case .burrow: + "Store Burrow control-plane credentials." + } + } +} + +enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { + case wireGuard + case tor + case headscale + + var id: String { rawValue } + + var title: String { + switch self { + case .wireGuard: "WireGuard" + case .tor: "Tor" + case .headscale: "Tailnet" + } + } + + var subtitle: String { + switch self { + case .wireGuard: "Import a tunnel and optional account metadata." + case .tor: "Store Arti account and identity preferences." + case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities." + } + } + + var accentColor: Color { + switch self { + case .wireGuard: .init("WireGuard") + case .tor: .orange + case .headscale: .mint + } + } + + var actionTitle: String { + switch self { + case .wireGuard: "Add Network" + case .tor: "Save Account" + case .headscale: "Save Account" + } + } + + var availabilityNote: String? { + switch self { + case .wireGuard: + nil + case .tor: + "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." + case .headscale: + "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." + } + } +} + +enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { + case none + case web + case password + case preauthKey + + var id: String { rawValue } + + var title: String { + switch self { + case .none: "None" + case .web: "Web Login" + case .password: "Password" + case .preauthKey: "Preauth Key" + } + } +} + +struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { + let id: UUID + var kind: AccountNetworkKind + var title: String + var authority: String? + var provider: TailnetProvider? + var accountName: String + var identityName: String + var hostname: String? + var username: String? + var tailnet: String? + var authMode: AccountAuthMode + var note: String? + var createdAt: Date + var updatedAt: Date +} + +struct TailnetCard { + var id: Int32 + var provider: String + var title: String + var detail: String + + init(network: Burrow_Network) { + let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) + id = network.id + provider = payload?.provider.title ?? "Tailnet" + title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" + detail = [ + payload?.provider.title, + payload?.authority, + payload.map { "Account: \($0.account)" }, + ] + .compactMap { $0 } + .joined(separator: " · ") + .ifEmpty("Stored Tailnet configuration") + } + + var card: NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .mint, + label: AnyView( + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(provider) + .font(.headline) + .foregroundStyle(.white.opacity(0.85)) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + } + Spacer() + } + Spacer() + Text(detail) + .font(.body.monospaced()) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(4) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + ) + ) + } +} + +@Observable +@MainActor +final class NetworkAccountStore { + private static let storageKey = "burrow.network-accounts" + + private let defaults: UserDefaults + private(set) var accounts: [NetworkAccountRecord] = [] + + init(defaults: UserDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) ?? .standard) { + self.defaults = defaults + load() + } + + func upsert(_ record: NetworkAccountRecord, secret: String?) throws { + if let index = accounts.firstIndex(where: { $0.id == record.id }) { + accounts[index] = record + } else { + accounts.append(record) + } + accounts.sort { + if $0.kind == $1.kind { + return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + return $0.kind.rawValue < $1.kind.rawValue + } + try persist() + if let secret, !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try AccountSecretStore.store(secret, for: record.id) + } else { + try AccountSecretStore.removeSecret(for: record.id) + } + } + + func delete(_ record: NetworkAccountRecord) throws { + accounts.removeAll { $0.id == record.id } + try persist() + try AccountSecretStore.removeSecret(for: record.id) + } + + func hasStoredSecret(for record: NetworkAccountRecord) -> Bool { + AccountSecretStore.hasSecret(for: record.id) + } + + private func load() { + guard let data = defaults.data(forKey: Self.storageKey) else { + accounts = [] + return + } + + do { + accounts = try JSONDecoder().decode([NetworkAccountRecord].self, from: data) + } catch { + accounts = [] + } + } + + private func persist() throws { + let data = try JSONEncoder().encode(accounts) + defaults.set(data, forKey: Self.storageKey) + } +} + +private enum AccountSecretStore { + private static let service = "\(Constants.bundleIdentifier).accounts" + + static func hasSecret(for accountID: UUID) -> Bool { + let query = baseQuery(for: accountID) + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } + + static func store(_ secret: String, for accountID: UUID) throws { + let data = Data(secret.utf8) + let query = baseQuery(for: accountID) + let status = SecItemCopyMatching(query as CFDictionary, nil) + + if status == errSecSuccess { + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw AccountSecretStoreError.osStatus(updateStatus) + } + return + } + + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AccountSecretStoreError.osStatus(addStatus) + } + } + + static func removeSecret(for accountID: UUID) throws { + let status = SecItemDelete(baseQuery(for: accountID) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AccountSecretStoreError.osStatus(status) + } + } + + private static func baseQuery(for accountID: UUID) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountID.uuidString, + ] + } +} + +private enum AccountSecretStoreError: LocalizedError { + case osStatus(OSStatus) + + var errorDescription: String? { + switch self { + case .osStatus(let status): + if let message = SecCopyErrorMessageString(status, nil) as String? { + return message + } + return "Keychain error \(status)" + } + } +} + +private extension String { + func ifEmpty(_ fallback: @autoclosure () -> String) -> String { + isEmpty ? fallback() : self + } } diff --git a/Apple/UI/Networks/WireGuard.swift b/Apple/UI/Networks/WireGuard.swift index cba67ef..c0426cd 100644 --- a/Apple/UI/Networks/WireGuard.swift +++ b/Apple/UI/Networks/WireGuard.swift @@ -1,14 +1,40 @@ import BurrowCore +import Foundation import SwiftUI -struct WireGuard: Network { - typealias NetworkType = Burrow_WireGuardNetwork - static let type: BurrowCore.Burrow_NetworkType = .wireGuard - +struct WireGuardCard { var id: Int32 - var backgroundColor: Color { .init("WireGuard") } + var title: String + var detail: String - @MainActor var label: some View { + init(id: Int32, title: String = "WireGuard", detail: String = "Stored configuration") { + self.id = id + self.title = title + self.detail = detail + } + + init(network: Burrow_Network) { + let payload = String(data: network.payload, encoding: .utf8) ?? "" + let address = Self.firstValue(for: "Address", in: payload) + let endpoint = Self.firstValue(for: "Endpoint", in: payload) + self.id = network.id + self.title = "WireGuard" + self.detail = [address, endpoint] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " · ") + .ifEmpty("Stored configuration") + } + + var card: NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .init("WireGuard"), + label: AnyView(label) + ) + } + + private var label: some View { GeometryReader { reader in VStack(alignment: .leading) { HStack { @@ -23,12 +49,29 @@ struct WireGuard: Network { } .frame(maxWidth: .infinity, maxHeight: reader.size.height / 4) Spacer() - Text("@conradev") + Text(detail) .foregroundStyle(.white) .font(.body.monospaced()) + .lineLimit(3) } .padding() .frame(maxWidth: .infinity) } } + + private static func firstValue(for key: String, in config: String) -> String? { + config + .split(whereSeparator: \.isNewline) + .map(String.init) + .first(where: { $0.hasPrefix("\(key) = ") })? + .split(separator: "=", maxSplits: 1) + .last + .map { $0.trimmingCharacters(in: .whitespaces) } + } +} + +private extension String { + func ifEmpty(_ fallback: @autoclosure () -> String) -> String { + isEmpty ? fallback() : self + } } diff --git a/Apple/UI/OAuth2.swift b/Apple/UI/OAuth2.swift deleted file mode 100644 index 0fafc8d..0000000 --- a/Apple/UI/OAuth2.swift +++ /dev/null @@ -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 - var clientID: String - var clientSecret: String - - fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation]> = { - .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, - 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.. some View { - onOpenURL { url in OAuth2.Session.handle(url: url) } - } -} - -extension URLComponents { - fileprivate func decode(_ 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")) - } - } - } -} diff --git a/Tools/tailscale-login-bridge/go.mod b/Tools/tailscale-login-bridge/go.mod new file mode 100644 index 0000000..0e19f33 --- /dev/null +++ b/Tools/tailscale-login-bridge/go.mod @@ -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 +) diff --git a/Tools/tailscale-login-bridge/go.sum b/Tools/tailscale-login-bridge/go.sum new file mode 100644 index 0000000..5393a62 --- /dev/null +++ b/Tools/tailscale-login-bridge/go.sum @@ -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= diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go new file mode 100644 index 0000000..82ca9b0 --- /dev/null +++ b/Tools/tailscale-login-bridge/main.go @@ -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 +} diff --git a/burrow/src/auth/client.rs b/burrow/src/auth/client.rs deleted file mode 100644 index e9721f3..0000000 --- a/burrow/src/auth/client.rs +++ /dev/null @@ -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(()) -} diff --git a/burrow/src/auth/mod.rs b/burrow/src/auth/mod.rs index c07f47e..74f47ad 100644 --- a/burrow/src/auth/mod.rs +++ b/burrow/src/auth/mod.rs @@ -1,2 +1 @@ -pub mod client; pub mod server; diff --git a/burrow/src/auth/server/db.rs b/burrow/src/auth/server/db.rs index 995e64b..c31c473 100644 --- a/burrow/src/auth/server/db.rs +++ b/burrow/src/auth/server/db.rs @@ -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 { + 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 = 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> { + 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>(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> { + 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 { + 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 { + let conn = Connection::open(path)?; + apply_map_request(&conn, user.profile.id, request)?; + let self_row = conn + .query_row( + "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", + params![user.profile.id, request.node_key], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()? + .ok_or_else(|| anyhow!("node not registered"))?; + + let node = load_node(&conn, self_row.0, self_row.1, Some(self_row.2))?; + let peers = load_peers(&conn, node.id)?; + Ok(MapResponse { + map_session_handle: Some(format!("map-{}", node.stable_id)), + seq: Some(request.map_session_seq.unwrap_or(0) + 1), + node, + peers, + domain: domain.to_owned(), + dns: Some(DnsConfig { + resolvers: vec!["1.1.1.1".to_owned(), "1.0.0.1".to_owned()], + search_domains: vec![domain.to_owned()], + magic_dns: true, + }), + packet_filters: vec![PacketFilter::default()], + }) +} pub static PATH: &str = "./server.sqlite3"; -pub fn init_db() -> Result<()> { - let conn = rusqlite::Connection::open(PATH)?; +fn apply_map_request(conn: &Connection, user_id: i64, request: &MapRequest) -> Result<()> { + let current = conn + .query_row( + "SELECT id FROM control_node WHERE user_id = ? AND node_key = ?", + params![user_id, request.node_key], + |row| row.get::<_, i64>(0), + ) + .optional()?; + let Some(node_id) = current else { + return Ok(()); + }; + + let hostinfo_json = optional_json(&request.hostinfo)?; + let endpoints_json = to_json(&request.endpoints)?; conn.execute( - "CREATE TABLE IF NOT EXISTS user ( - id PRIMARY KEY, - created_at TEXT NOT NULL - )", - (), + "UPDATE control_node + SET disco_key = COALESCE(?, disco_key), + hostinfo_json = CASE WHEN ? IS NULL THEN hostinfo_json ELSE ? END, + endpoints_json = CASE WHEN ? = '[]' THEN endpoints_json ELSE ? END, + updated_at = datetime('now'), + last_seen = datetime('now'), + online = 1 + WHERE id = ?", + params![ + request.disco_key, + hostinfo_json, + hostinfo_json, + endpoints_json, + endpoints_json, + node_id, + ], )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS user_connection ( - user_id INTEGER REFERENCES user(id) ON DELETE CASCADE, - openid_provider TEXT NOT NULL, - openid_user_id TEXT NOT NULL, - openid_user_name TEXT NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT, - PRIMARY KEY (openid_provider, openid_user_id) - )", - (), - )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS device ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - public_key TEXT NOT NULL, - apns_token TEXT UNIQUE, - user_id INT REFERENCES user(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)), - ipv4 TEXT NOT NULL UNIQUE, - ipv6 TEXT NOT NULL UNIQUE, - access_token TEXT NOT NULL UNIQUE, - refresh_token TEXT NOT NULL UNIQUE, - expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at)) - )", - () - ).unwrap(); - Ok(()) } -pub fn store_connection( - openid_user: super::providers::OpenIdUser, - openid_provider: &str, - access_token: &str, - refresh_token: Option<&str>, -) -> Result<()> { - log::debug!("Storing openid user {:#?}", openid_user); - let conn = rusqlite::Connection::open(PATH)?; +fn find_existing_node( + conn: &Connection, + user_id: i64, + request: &RegisterRequest, +) -> Result> { + let mut candidates = vec![request.node_key.as_str()]; + if let Some(old) = request.old_node_key.as_deref() { + if old != request.node_key { + candidates.push(old); + } + } - conn.execute( - "INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))", - (&openid_user.sub,), - )?; - conn.execute( - "INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES ( - (SELECT id FROM user WHERE id = ?), - ?, - ?, - ?, - ?, - ? - )", - (&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token), - )?; - - Ok(()) + for candidate in candidates { + let hit = conn + .query_row( + "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", + params![user_id, candidate], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + if hit.is_some() { + return Ok(hit); + } + } + Ok(None) } -pub fn store_device( - openid_user: super::providers::OpenIdUser, - openid_provider: &str, - access_token: &str, - refresh_token: Option<&str>, -) -> Result<()> { - log::debug!("Storing openid user {:#?}", openid_user); - let conn = rusqlite::Connection::open(PATH)?; - - // TODO - - Ok(()) +fn load_peers(conn: &Connection, self_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, stable_id, created_at FROM control_node WHERE id != ? AND machine_authorized = 1 ORDER BY id", + )?; + let peers = stmt + .query_map([self_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + 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, +) -> Result { + 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>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(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>(19)?, + row.get::<_, Option>(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::(&raw)).transpose()?, + tags: parse_json(&row.10)?, + primary_routes: parse_json(&row.11)?, + cap_version: row.12, + cap_map: parse_json::(&row.13)?, + peer_cap_map: parse_json::(&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 { + let profile = load_user_profile(conn, user_id)?; + Ok(StoredUser { profile }) +} + +fn load_user_profile(conn: &Connection, user_id: i64) -> Result { + 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>(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 { + 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(value: &T) -> Result { + serde_json::to_string(value).context("failed to serialize json") +} + +fn optional_json(value: &Option) -> Result> { + value.as_ref().map(to_json).transpose() +} + +fn parse_json(value: &str) -> Result { + 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(()) + } } diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index 88b3ff3..b0c0522 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -1,32 +1,277 @@ pub mod db; -pub mod providers; +pub mod tailscale; -use anyhow::Result; -use axum::{http::StatusCode, routing::post, Router}; -use providers::slack::auth; +use std::{env, path::Path}; + +use anyhow::{Context, Result}; +use axum::{ + extract::{Json, Path as AxumPath, State}, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; use tokio::signal; +use crate::control::{ + LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest, + RegisterResponse, BURROW_TAILNET_DOMAIN, +}; + +#[derive(Clone, Debug)] +pub struct BootstrapIdentity { + pub username: String, + pub email: String, + pub display_name: String, + pub password_file: String, +} + +impl Default for BootstrapIdentity { + fn default() -> Self { + Self { + username: "contact".to_owned(), + email: "contact@burrow.net".to_owned(), + display_name: "Burrow Contact".to_owned(), + password_file: "intake/forgejo_pass_contact_at_burrow_net.txt".to_owned(), + } + } +} + +#[derive(Clone, Debug)] +pub struct AuthServerConfig { + pub listen: String, + pub db_path: String, + pub tailnet_domain: String, + pub bootstrap: BootstrapIdentity, +} + +impl Default for AuthServerConfig { + fn default() -> Self { + Self { + listen: "0.0.0.0:8080".to_owned(), + db_path: db::PATH.to_owned(), + tailnet_domain: BURROW_TAILNET_DOMAIN.to_owned(), + bootstrap: BootstrapIdentity::default(), + } + } +} + +impl AuthServerConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Ok(value) = env::var("BURROW_AUTH_LISTEN") { + config.listen = value; + } + if let Ok(value) = env::var("BURROW_AUTH_DB_PATH") { + config.db_path = value; + } + if let Ok(value) = env::var("BURROW_AUTH_TAILNET_DOMAIN") { + config.tailnet_domain = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_USERNAME") { + config.bootstrap.username = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_EMAIL") { + config.bootstrap.email = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_DISPLAY_NAME") { + config.bootstrap.display_name = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_PASSWORD_FILE") { + config.bootstrap.password_file = value; + } + config + } + + fn bootstrap_password(&self) -> Result> { + 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 = Result; + pub async fn serve() -> Result<()> { - db::init_db()?; + serve_with_config(AuthServerConfig::from_env()).await +} - let app = Router::new() - .route("/slack-auth", post(auth)) - .route("/device/new", post(device_new)); +pub async fn serve_with_config(config: AuthServerConfig) -> Result<()> { + db::init_db(&config.db_path)?; + if let Some(password) = config.bootstrap_password()? { + db::ensure_local_identity( + &config.db_path, + &config.bootstrap.username, + &config.bootstrap.email, + &config.bootstrap.display_name, + &password, + )?; + } - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); - log::info!("Starting auth server on port 8080"); + let app = build_router(config.clone()); + let listener = tokio::net::TcpListener::bind(&config.listen).await?; + log::info!("Starting auth server on {}", config.listen); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) - .await - .unwrap(); - + .await?; Ok(()) } -async fn device_new() -> StatusCode { +pub fn build_router(config: AuthServerConfig) -> Router { + Router::new() + .route("/healthz", get(healthz)) + .route("/device/new", post(device_new)) + .route("/v1/auth/login", post(login_local)) + .route("/v1/control/register", post(control_register)) + .route("/v1/control/map", post(control_map)) + .route("/v1/tailscale/login/start", post(tailscale_login_start)) + .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) + .with_state(AppState { + config, + tailscale: tailscale::TailscaleBridgeManager::default(), + }) +} + +async fn login_local( + State(state): State, + Json(request): Json, +) -> AppResult> { + 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, + Json(request): Json, +) -> AppResult> { + 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, + Json(request): Json, +) -> AppResult> { + 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, + Json(request): Json, +) -> AppResult> { + let response = state + .tailscale + .start_login(request) + .await + .map_err(internal_error)?; + Ok(Json(response)) +} + +async fn tailscale_login_status( + AxumPath(session_id): AxumPath, + State(state): State, +) -> AppResult> { + state + .tailscale + .status(&session_id) + .await + .map_err(internal_error)? + .map(Json) + .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned())) +} + +async fn healthz() -> impl IntoResponse { StatusCode::OK } +async fn device_new() -> impl IntoResponse { + StatusCode::OK +} + +async fn blocking(work: F) -> AppResult +where + F: FnOnce() -> Result + 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 { + let value = headers.get(AUTHORIZATION).ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "missing authorization header".to_owned(), + ) + })?; + let value = value.to_str().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "invalid authorization header".to_owned(), + ) + })?; + value + .strip_prefix("Bearer ") + .map(ToOwned::to_owned) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "expected bearer token".to_owned())) +} + async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() @@ -51,12 +296,102 @@ async fn shutdown_signal() { } } -// mod db { -// use rusqlite::{Connection, Result}; +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + }; + use tempfile::tempdir; + use tower::ServiceExt; -// #[derive(Debug)] -// struct User { -// id: i32, -// created_at: String, -// } -// } + #[tokio::test] + async fn login_register_and_map_round_trip() -> Result<()> { + let dir = tempdir()?; + let password_file = dir.path().join("bootstrap-password.txt"); + std::fs::write(&password_file, "bootstrap-pass\n")?; + let db_path = dir.path().join("server.sqlite3"); + let config = AuthServerConfig { + listen: "127.0.0.1:0".to_owned(), + db_path: db_path.to_string_lossy().to_string(), + tailnet_domain: "burrow.net".to_owned(), + bootstrap: BootstrapIdentity { + password_file: password_file.to_string_lossy().to_string(), + ..BootstrapIdentity::default() + }, + }; + + db::init_db(&config.db_path)?; + let password = config.bootstrap_password()?.expect("bootstrap password"); + db::ensure_local_identity( + &config.db_path, + &config.bootstrap.username, + &config.bootstrap.email, + &config.bootstrap.display_name, + &password, + )?; + + let app = build_router(config); + + let response = app + .clone() + .oneshot( + Request::post("/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&LocalAuthRequest { + identifier: "contact".to_owned(), + password: "bootstrap-pass".to_owned(), + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let login: LocalAuthResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; + + let response = app + .clone() + .oneshot( + Request::post("/v1/control/register") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", login.access_token)) + .body(Body::from(serde_json::to_vec(&RegisterRequest { + node_key: "nodekey:1234".to_owned(), + machine_key: Some("machinekey:1234".to_owned()), + addresses: vec!["100.64.0.10/32".to_owned()], + endpoints: vec!["198.51.100.10:41641".to_owned()], + hostinfo: Some(crate::control::Hostinfo { + hostname: Some("devbox".to_owned()), + os: Some("linux".to_owned()), + os_version: Some("6.13".to_owned()), + services: vec!["ssh".to_owned()], + request_tags: vec!["tag:dev".to_owned()], + }), + ..RegisterRequest::default() + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::post("/v1/control/map") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", login.access_token)) + .body(Body::from(serde_json::to_vec(&MapRequest { + node_key: "nodekey:1234".to_owned(), + stream: true, + endpoints: vec!["198.51.100.10:41641".to_owned()], + ..MapRequest::default() + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let map: MapResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; + assert_eq!(map.domain, "burrow.net"); + assert_eq!(map.node.name, "devbox"); + assert!(map.dns.expect("dns").magic_dns); + Ok(()) + } +} diff --git a/burrow/src/auth/server/providers/mod.rs b/burrow/src/auth/server/providers/mod.rs deleted file mode 100644 index 36ff0bd..0000000 --- a/burrow/src/auth/server/providers/mod.rs +++ /dev/null @@ -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, -} diff --git a/burrow/src/auth/server/providers/slack.rs b/burrow/src/auth/server/providers/slack.rs deleted file mode 100644 index 581cd1e..0000000 --- a/burrow/src/auth/server/providers/slack.rs +++ /dev/null @@ -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) -> (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 { - 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::() - .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) -> 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::() -// .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")) -// } -// } diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs new file mode 100644 index 0000000..fbe1980 --- /dev/null +++ b/burrow/src/auth/server/tailscale.rs @@ -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, + #[serde(default)] + pub control_url: Option, +} + +#[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, + #[serde(default)] + pub running: bool, + #[serde(default)] + pub needs_login: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub magic_dns_suffix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub self_dns_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tailscale_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub health: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TailscaleLoginStartResponse { + pub session_id: String, + pub status: TailscaleLoginStatus, +} + +#[derive(Clone, Default)] +pub struct TailscaleBridgeManager { + client: Client, + sessions: Arc>>>, +} + +struct ManagedSession { + session_id: String, + listen_url: String, + state_dir: PathBuf, + child: Arc>, + _stderr_task: JoinHandle<()>, +} + +#[derive(Debug, Deserialize)] +struct HelperHello { + listen_addr: String, +} + +impl TailscaleBridgeManager { + pub async fn start_login( + &self, + request: TailscaleLoginStartRequest, + ) -> Result { + 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> { + 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 { + 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 { + 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::() + .await + .context("invalid tailscale helper status response") + } +} + +fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { + 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"); + } +} diff --git a/burrow/src/control/config.rs b/burrow/src/control/config.rs new file mode 100644 index 0000000..3862bcd --- /dev/null +++ b/burrow/src/control/config.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, +} + +impl TailnetConfig { + pub fn from_slice(bytes: &[u8]) -> Result { + 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 { + 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") + ); + } +} diff --git a/burrow/src/control/mod.rs b/burrow/src/control/mod.rs new file mode 100644 index 0000000..331a7d2 --- /dev/null +++ b/burrow/src/control/mod.rs @@ -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>; +pub type PeerCapMap = BTreeMap>; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Hostinfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub services: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub request_tags: Vec, +} + +#[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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegisterAuth { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_access_token: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_derp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub primary_routes: Vec, + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub online: Option, +} + +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 { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub followup: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default)] + pub ephemeral: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_derp: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub primary_routes: Vec, + #[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, + pub machine_authorized: bool, + pub node_key_expired: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[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, + #[serde(default)] + pub keep_alive: bool, + pub node_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default)] + pub stream: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_seq: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub debug_flags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_handle: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DnsConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resolvers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub search_domains: Vec, + #[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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub destinations: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub protocols: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct MapResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + pub node: Node, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub peers: Vec, + pub domain: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub packet_filters: Vec, +} + +#[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() +} diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 8fe3d41..a016788 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -63,8 +63,6 @@ mod tests { }; use anyhow::{anyhow, Result}; - use iroh::PublicKey; - use serde_json::json; use tokio::time::{timeout, Duration}; use super::*; @@ -172,15 +170,15 @@ mod tests { .networks_client .network_add(Network { id: 2, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload(), + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with("10.77.0.2/32", 1380), }) .await?; - let networks_after_mesh_add = next_networks(&mut network_stream).await?; + let networks_after_second_add = next_networks(&mut network_stream).await?; assert_eq!( - network_ids(&networks_after_mesh_add), - vec![(1, NetworkType::WireGuard), (2, NetworkType::HackClub)] + network_ids(&networks_after_second_add), + vec![(1, NetworkType::WireGuard), (2, NetworkType::WireGuard)] ); let still_wireguard = next_configuration(&mut config_stream).await?; @@ -194,12 +192,12 @@ mod tests { let networks_after_reorder = next_networks(&mut network_stream).await?; assert_eq!( network_ids(&networks_after_reorder), - vec![(2, NetworkType::HackClub), (1, NetworkType::WireGuard)] + vec![(2, NetworkType::WireGuard), (1, NetworkType::WireGuard)] ); - let mesh_config = next_configuration(&mut config_stream).await?; - assert_eq!(mesh_config.addresses, vec!["10.77.0.2/32"]); - assert_eq!(mesh_config.mtu, 1380); + let second_wireguard_config = next_configuration(&mut config_stream).await?; + assert_eq!(second_wireguard_config.addresses, vec!["10.77.0.2/32"]); + assert_eq!(second_wireguard_config.mtu, 1380); daemon_task.abort(); let _ = daemon_task.await; @@ -237,16 +235,10 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } - fn sample_hackclub_payload() -> Vec { - let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); - json!({ - "endpoint_id": endpoint_id, - "addresses": ["127.0.0.1:7777"], - "local_addresses": ["10.77.0.2/32"], - "mtu": 1380, - "tun_name": "burrow-test-mesh", - }) - .to_string() + fn sample_wireguard_payload_with(address: &str, mtu: u16) -> Vec { + format!( + "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = wg.burrow.rs:51820\n" + ) .into_bytes() } diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 7fea964..84dfd2b 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -9,7 +9,7 @@ use super::rpc::{ ServerConfig, }; use crate::{ - mesh::iroh::{self as mesh_iroh, HackClubNetworkConfig, MeshHandle}, + control::TailnetConfig, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -28,14 +28,14 @@ pub enum ResolvedTunnel { Passthrough { identity: RuntimeIdentity, }, + Tailnet { + identity: RuntimeIdentity, + config: TailnetConfig, + }, WireGuard { identity: RuntimeIdentity, config: Config, }, - HackClub { - identity: RuntimeIdentity, - config: HackClubNetworkConfig, - }, } impl ResolvedTunnel { @@ -53,24 +53,24 @@ impl ResolvedTunnel { }; match network.r#type() { + NetworkType::Tailnet => { + let config = TailnetConfig::from_slice(&network.payload)?; + Ok(Self::Tailnet { identity, config }) + } NetworkType::WireGuard => { let payload = String::from_utf8(network.payload.clone()) .context("wireguard payload must be valid UTF-8")?; let config = Config::from_content_fmt(&payload, "ini")?; Ok(Self::WireGuard { identity, config }) } - NetworkType::HackClub => { - let config = HackClubNetworkConfig::from_payload(&network.payload)?; - Ok(Self::HackClub { identity, config }) - } } } pub fn identity(&self) -> &RuntimeIdentity { match self { Self::Passthrough { identity } - | Self::WireGuard { identity, .. } - | Self::HackClub { identity, .. } => identity, + | Self::Tailnet { identity, .. } + | Self::WireGuard { identity, .. } => identity, } } @@ -81,12 +81,12 @@ impl ResolvedTunnel { name: None, mtu: Some(1500), }), - Self::WireGuard { config, .. } => ServerConfig::try_from(config), - Self::HackClub { config, .. } => Ok(ServerConfig { - address: config.local_addresses.clone(), - name: config.tun_name.clone(), - mtu: config.mtu.map(i32::from), + Self::Tailnet { .. } => Ok(ServerConfig { + address: Vec::new(), + name: None, + mtu: Some(1280), }), + Self::WireGuard { config, .. } => ServerConfig::try_from(config), } } @@ -96,6 +96,10 @@ impl ResolvedTunnel { ) -> Result { match self { Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }), + Self::Tailnet { config, .. } => Err(anyhow::anyhow!( + "tailnet runtime is not wired in this checkout yet ({:?})", + config.provider + )), Self::WireGuard { identity, config } => { let tun = TunOptions::new().open()?; tun_interface.write().await.replace(tun); @@ -110,23 +114,6 @@ impl ResolvedTunnel { } } } - Self::HackClub { identity, config } => { - let mut tun_opts = TunOptions::new(); - if let Some(name) = config.tun_name.as_deref() { - tun_opts = tun_opts.name(name); - } - - let tun = tun_opts.open()?; - tun_interface.write().await.replace(tun); - - match mesh_iroh::spawn_hackclub_tunnel(config, tun_interface.clone()).await { - Ok(handle) => Ok(ActiveTunnel::HackClub { identity, handle }), - Err(err) => { - tun_interface.write().await.take(); - Err(err) - } - } - } } } } @@ -140,18 +127,13 @@ pub enum ActiveTunnel { interface: Arc>, task: JoinHandle>, }, - HackClub { - identity: RuntimeIdentity, - handle: MeshHandle, - }, } impl ActiveTunnel { pub fn identity(&self) -> &RuntimeIdentity { match self { Self::Passthrough { identity } - | Self::WireGuard { identity, .. } - | Self::HackClub { identity, .. } => identity, + | Self::WireGuard { identity, .. } => identity, } } @@ -165,11 +147,6 @@ impl ActiveTunnel { task_result??; Ok(()) } - Self::HackClub { handle, .. } => { - let result = handle.shutdown().await; - tun_interface.write().await.take(); - result - } } } } diff --git a/burrow/src/database.rs b/burrow/src/database.rs index 5039e03..fe9a3c7 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -4,10 +4,10 @@ use anyhow::Result; use rusqlite::{params, Connection}; use crate::{ + control::TailnetConfig, daemon::rpc::grpc_defs::{ Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, }, - mesh::iroh::HackClubNetworkConfig, wireguard::config::{Config, Interface, Peer}, }; @@ -203,8 +203,8 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> { let payload_str = String::from_utf8(network.payload.clone())?; Config::from_content_fmt(&payload_str, "ini")?; } - NetworkType::HackClub => { - HackClubNetworkConfig::from_payload(&network.payload)?; + NetworkType::Tailnet => { + TailnetConfig::from_slice(&network.payload)?; } } Ok(()) @@ -243,8 +243,6 @@ fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use iroh::PublicKey; - use serde_json::json; use tempfile::tempdir; fn sample_wireguard_payload() -> Vec { @@ -262,19 +260,24 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } - fn sample_hackclub_payload(name: &str, address: &str) -> Vec { - let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); - json!({ - "endpoint_id": endpoint_id, - "addresses": ["127.0.0.1:7777"], - "local_addresses": [address], - "mtu": 1380, - "tun_name": name, - }) - .to_string() + fn sample_wireguard_payload_with_address(address: &str, mtu: u16) -> Vec { + format!( + "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0\nEndpoint = wg.burrow.rs:51820\n" + ) .into_bytes() } + fn sample_tailnet_payload() -> Vec { + br#"{ + "provider":"tailscale", + "account":"default", + "identity":"apple", + "tailnet":"example.ts.net", + "hostname":"burrow-phone" +}"# + .to_vec() + } + #[test] fn test_db() { let conn = Connection::open_in_memory().unwrap(); @@ -304,8 +307,18 @@ Endpoint = wg.burrow.rs:51820 &conn, &RPCNetwork { id: 2, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload("burrow-test-0", "10.42.0.2/32"), + r#type: NetworkType::Tailnet.into(), + payload: sample_tailnet_payload(), + }, + ) + .unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 3, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), }, ) .unwrap(); @@ -313,19 +326,29 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 3, + id: 4, r#type: NetworkType::WireGuard.into(), payload: b"not-a-config".to_vec(), }, ) .is_err()); + assert!(add_network( + &conn, + &RPCNetwork { + id: 5, + r#type: NetworkType::Tailnet.into(), + payload: b"not-a-tailnet-config".to_vec(), + }, + ) + .is_err()); + let ids: Vec = list_networks(&conn) .unwrap() .into_iter() .map(|n| n.id) .collect(); - assert_eq!(ids, vec![1, 2]); + assert_eq!(ids, vec![1, 2, 3]); } #[test] @@ -333,17 +356,17 @@ Endpoint = wg.burrow.rs:51820 let conn = Connection::open_in_memory().unwrap(); initialize_tables(&conn).unwrap(); - for (id, name, address) in [ - (1, "burrow-test-1", "10.42.0.2/32"), - (2, "burrow-test-2", "10.42.0.3/32"), - (3, "burrow-test-3", "10.42.0.4/32"), + for (id, address, mtu) in [ + (1, "10.42.0.2/32", 1380), + (2, "10.42.0.3/32", 1381), + (3, "10.42.0.4/32", 1382), ] { add_network( &conn, &RPCNetwork { id, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload(name, address), + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address(address, mtu), }, ) .unwrap(); diff --git a/proto/burrow.proto b/proto/burrow.proto index 2355b8d..5b5a30b 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -45,7 +45,7 @@ message Network { enum NetworkType { WireGuard = 0; - HackClub = 1; + Tailnet = 1; } message NetworkListResponse {