From 64103abbea58979a360c5be7976be313a8c0d1e4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 02:10:49 -0700 Subject: [PATCH 1/3] Refocus Tailnet flow on Tailscale --- Apple/App/AppDelegate.swift | 2 +- Apple/AppUITests/BurrowUITests.swift | 317 +++++++++--- Apple/Configuration/Constants/Constants.swift | 10 +- Apple/Core/Client.swift | 50 ++ Apple/Core/Client/Generated/burrow.pb.swift | 32 ++ .../PacketTunnelProvider.swift | 250 +++++++++- Apple/UI/BurrowView.swift | 393 ++++++++++----- Apple/UI/Networks/Network.swift | 6 +- Scripts/run-ios-tailnet-ui-tests.sh | 116 ++++- burrow/src/auth/server/tailscale.rs | 338 +++++++++---- burrow/src/control/discovery.rs | 13 + burrow/src/daemon/instance.rs | 164 ++++++- burrow/src/daemon/rpc/response.rs | 20 + burrow/src/daemon/runtime.rs | 464 +++++++++++++++++- burrow/src/tracing.rs | 14 +- proto/burrow.proto | 9 + 16 files changed, 1856 insertions(+), 342 deletions(-) diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 12fe52c..c3cb4cb 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -55,7 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let statusBar = NSStatusBar.system let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil) + button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil) } return statusItem }() diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift index f9dbeae..b7d8111 100644 --- a/Apple/AppUITests/BurrowUITests.swift +++ b/Apple/AppUITests/BurrowUITests.swift @@ -1,15 +1,31 @@ import XCTest +import UIKit @MainActor final class BurrowTailnetLoginUITests: XCTestCase { + private enum TailnetLoginMode: String, Decodable { + case tailscale + case discovered + } + + private struct TestConfig: Decodable { + let email: String + let username: String + let password: String + let mode: TailnetLoginMode? + } + override func setUpWithError() throws { continueAfterFailure = false } func testTailnetLoginThroughAuthentikWebSession() throws { - let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") - let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email - let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") + let config = try loadTestConfig() + let email = config.email + let username = config.username + let password = config.password + let mode = config.mode ?? .tailscale + let browserIdentity = mode == .tailscale ? email : username let app = XCUIApplication() app.launch() @@ -18,51 +34,90 @@ final class BurrowTailnetLoginUITests: XCTestCase { XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") tailnetButton.tap() + configureTailnetIfNeeded(in: app, mode: mode) + let discoveryField = app.textFields["tailnet-discovery-email"] XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") replaceText(in: discoveryField, with: email) - let findServerButton = app.buttons["tailnet-find-server"] - XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") - findServerButton.tap() - - let discoveryCard = app.otherElements["tailnet-discovery-card"] - XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear") - - let authorityField = app.textFields["tailnet-authority"] - XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") - XCTAssertTrue( - waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20), - "Tailnet authority was not populated from discovery" - ) - - let probeButton = app.buttons["tailnet-check-connection"] - XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear") - probeButton.tap() - - let probeCard = app.otherElements["tailnet-authority-probe-card"] - XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete") + let serverCard = app.descendants(matching: .any) + .matching(identifier: "tailnet-server-card") + .firstMatch + XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear") let signInButton = app.buttons["tailnet-start-sign-in"] XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") signInButton.tap() - acceptAuthenticationPromptIfNeeded(in: app) + acceptAuthenticationPromptIfNeeded(in: app, timeout: 20) let webSession = webAuthenticationSession() XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") - signIntoAuthentik(in: webSession, username: username, password: password) + signIntoAuthentik(in: webSession, username: browserIdentity, password: password) app.activate() XCTAssertTrue( - waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60), + waitForTailnetSignedIn(in: app, timeout: 60), "Tailnet sign-in never reached the running state" ) } - private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) { + private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) { + guard mode == .discovered else { return } + + openTailnetMenu(in: app) + tapMenuButton(named: "Edit Custom Server", in: app) + + openTailnetMenu(in: app) + tapMenuButton(named: "Show Advanced Settings", in: app) + + let authorityField = app.textFields["tailnet-authority"] + XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") + replaceText(in: authorityField, with: "") + } + + private func openTailnetMenu(in app: XCUIApplication) { + let moreButton = app.buttons["More"] + XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear") + moreButton.tap() + } + + private func tapMenuButton(named title: String, in app: XCUIApplication) { + let menuButton = firstExistingElement( + from: [ + app.buttons[title], + app.descendants(matching: .button)[title], + ], + timeout: 5 + ) + XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear") + menuButton.tap() + } + + private func acceptAuthenticationPromptIfNeeded( + in app: XCUIApplication, + timeout: TimeInterval + ) { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let deadline = Date().addingTimeInterval(timeout) + + repeat { + let promptCandidates = [ + springboard.buttons["Continue"], + springboard.buttons["Allow"], + app.buttons["Continue"], + app.buttons["Allow"], + ] + + for button in promptCandidates where button.exists && button.isHittable { + button.tap() + return + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } while Date() < deadline + let promptCandidates = [ springboard.buttons["Continue"], springboard.buttons["Allow"], @@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase { app.buttons["Allow"], ] - for button in promptCandidates where button.waitForExistence(timeout: 3) { + for button in promptCandidates where button.exists { button.tap() return } @@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase { } private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { + followTailnetRedirectIfNeeded(in: webSession) + + if !webSession.exists { + return + } + + let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) + if immediatePasswordField.exists { + replaceSecureText(in: immediatePasswordField, within: webSession, with: password) + submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField) + return + } + let usernameField = firstExistingElement( in: webSession, queries: [ @@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase { { $0.webViews.textFields["Email or Username"] }, { $0.descendants(matching: .textField).firstMatch }, ], - timeout: 25 + timeout: 12 ) - XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") - replaceText(in: usernameField, with: username) - - let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) - if immediatePasswordField.exists { - replaceSecureText(in: immediatePasswordField, with: password) - tapFirstExistingButton( - in: webSession, - titles: ["Continue", "Sign In", "Log in", "Login"], - timeout: 5 - ) + if !usernameField.exists { return } + replaceText(in: usernameField, with: username) tapFirstExistingButton( in: webSession, @@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase { let passwordField = firstExistingSecureField(in: webSession, timeout: 20) XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") - replaceSecureText(in: passwordField, with: password) - tapFirstExistingButton( - in: webSession, - titles: ["Continue", "Sign In", "Log in", "Login"], - timeout: 5 - ) + replaceSecureText(in: passwordField, within: webSession, with: password) + submitAuthenticationForm(in: webSession, focusedField: passwordField) + } + + private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) { + let redirectCandidates = [ + webSession.links["Found"], + webSession.webViews.links["Found"], + webSession.buttons["Found"], + webSession.webViews.buttons["Found"], + ] + + let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8) + if redirectLink.exists { + redirectLink.tap() + } } private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { let candidates = [ + app.descendants(matching: .secureTextField).firstMatch, app.secureTextFields["Password"], app.secureTextFields["Password or Token"], app.webViews.secureTextFields["Password"], app.webViews.secureTextFields["Password or Token"], - app.descendants(matching: .secureTextField).firstMatch, ] return firstExistingElement(from: candidates, timeout: timeout) @@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase { button.tap() } - private func requiredEnvironment(_ key: String) throws -> String { - guard let value = ProcessInfo.processInfo.environment[key], - !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) { + focus(focusedField) + focusedField.typeText("\n") + if waitForAny( + [ + { !focusedField.exists }, + { !app.staticTexts["Burrow Tailnet Authentication"].exists }, + ], + timeout: 1.5 + ) { + return + } + + let keyboard = app.keyboards.firstMatch + if keyboard.waitForExistence(timeout: 2) { + let keyboardCandidates = [ + "Return", + "return", + "Go", + "go", + "Continue", + "continue", + "Done", + "done", + "Join", + "join", + "Sign In", + "Log In", + "Login", + ] + for title in keyboardCandidates { + let key = keyboard.buttons[title] + if key.exists && key.isHittable { + key.tap() + return + } + } + + if let lastKey = keyboard.buttons.allElementsBoundByIndex.last, + lastKey.exists, + lastKey.isHittable + { + lastKey.tap() + return + } + } + + tapFirstExistingButton( + in: app, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + } + + private func loadTestConfig() throws -> TestConfig { + let environment = ProcessInfo.processInfo.environment + if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"), + let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD") + { + return TestConfig( + email: email, + username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email, + password: password, + mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE") + .flatMap(TailnetLoginMode.init(rawValue:)) + ) + } + + let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json" + let configURL = URL(fileURLWithPath: configPath) + guard FileManager.default.fileExists(atPath: configURL.path) else { + throw XCTSkip( + "Missing UI test configuration. Expected env vars or config file at \(configURL.path)" + ) + } + + let data = try Data(contentsOf: configURL) + return try JSONDecoder().decode(TestConfig.self, from: data) + } + + private func nonEmptyEnvironment(_ key: String) -> String? { + guard let value = ProcessInfo.processInfo.environment[key]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { - throw XCTSkip("Missing required UI test environment variable \(key)") + return nil } return value } @@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase { return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed } + private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool { + let button = app.buttons["tailnet-start-sign-in"] + let deadline = Date().addingTimeInterval(timeout) + + repeat { + acceptAuthenticationPromptIfNeeded(in: app, timeout: 1) + if button.exists, button.label == "Signed In" { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } while Date() < deadline + + return button.exists && button.label == "Signed In" + } + + private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if conditions.contains(where: { $0() }) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + return conditions.contains(where: { $0() }) + } + private func firstExistingElement( in app: XCUIApplication, queries: [(XCUIApplication) -> XCUIElement], @@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase { } private func replaceText(in element: XCUIElement, with value: String) { - element.tap() + focus(element) clearText(in: element) element.typeText(value) } - private func replaceSecureText(in element: XCUIElement, with value: String) { - element.tap() - clearText(in: element) + private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) { + UIPasteboard.general.string = value + focus(element) + for revealMenu in [ + { element.doubleTap() }, + { element.press(forDuration: 1.2) }, + ] { + revealMenu() + let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3) + if pasteButton.exists { + pasteButton.tap() + return + } + } + + focus(element) element.typeText(value) } @@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase { let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) element.typeText(deleteSequence) } + + private func focus(_ element: XCUIElement) { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + + private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] { + let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"] + return pasteLabels.flatMap { label in + [ + app.menuItems[label], + app.buttons[label], + app.webViews.buttons[label], + app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch, + app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch, + ] + } + } } diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 8844564..95d8c78 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -36,13 +36,9 @@ public enum Constants { private static func fallbackContainerURL() -> Result { #if targetEnvironment(simulator) Result { - let baseURL = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let url = baseURL + // The simulator app's Application Support path lives inside its sandbox container, + // so the host daemon cannot reach it. Use a shared host temp location instead. + let url = URL(filePath: "/tmp", directoryHint: .isDirectory) .appending(component: bundleIdentifier, directoryHint: .isDirectory) .appending(component: "SimulatorFallback", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index e44ebcd..7d4cfc7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable { public init() {} } +public struct Burrow_TunnelPacket: Sendable { + public var payload = Data() + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -387,6 +394,29 @@ extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobu } } +extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TunnelPacket" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "payload") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularBytesField(value: &self.payload) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.payload.isEmpty { + try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -456,3 +486,23 @@ public struct TailnetClient: Client, GRPCClient { ) } } + +public struct TunnelPacketClient: Client, GRPCClient { + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions + + public init(channel: any GRPCChannel) { + self.channel = channel + self.defaultCallOptions = .init() + } + + public func makeTunnelPacketsCall( + callOptions: CallOptions? = nil + ) -> GRPCAsyncBidirectionalStreamingCall { + self.makeAsyncBidirectionalStreamingCall( + path: "/burrow.Tunnel/TunnelPackets", + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift index bba0f16..fccd769 100644 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable { public var mtu: Int32 = 0 + public var routes: [String] = [] + + public var dnsServers: [String] = [] + + public var searchDomains: [String] = [] + + public var includeDefaultRoute: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "addresses"), 2: .same(proto: "mtu"), + 3: .same(proto: "routes"), + 4: .standard(proto: "dns_servers"), + 5: .standard(proto: "search_domains"), + 6: .standard(proto: "include_default_route"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob switch fieldNumber { case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }() case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }() + case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }() + case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }() default: break } } @@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if self.mtu != 0 { try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2) } + if !self.routes.isEmpty { + try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) + } + if !self.dnsServers.isEmpty { + try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) + } + if !self.searchDomains.isEmpty { + try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5) + } + if self.includeDefaultRoute { + try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6) + } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool { if lhs.addresses != rhs.addresses {return false} if lhs.mtu != rhs.mtu {return false} + if lhs.routes != rhs.routes {return false} + if lhs.dnsServers != rhs.dnsServers {return false} + if lhs.searchDomains != rhs.searchDomains {return false} + if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 4f29543..3f3d8b4 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -1,6 +1,7 @@ import AsyncAlgorithms import BurrowConfiguration import BurrowCore +import GRPC import libburrow import NetworkExtension import os @@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } private let logger = Logger.logger(for: PacketTunnelProvider.self) + private var packetCall: GRPCAsyncBidirectionalStreamingCall? + private var inboundPacketTask: Task? + private var outboundPacketTask: Task? private var client: TunnelClient { get throws { try _client.get() } @@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { let completion = SendableCallbackBox(completionHandler) Task { do { + _ = try await client.tunnelStart(.init()) let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first guard let settings = configuration?.settings else { throw Error.missingTunnelConfiguration } try await setTunnelNetworkSettings(settings) - _ = try await client.tunnelStart(.init()) + try startPacketBridge() logger.log("Started tunnel with network settings: \(settings)") completion.callback(nil) } catch { logger.error("Failed to start tunnel: \(error)") + stopPacketBridge() completion.callback(error) } } @@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) { let completion = SendableCallbackBox(completionHandler) Task { + stopPacketBridge() do { _ = try await client.tunnelStop(.init()) logger.log("Stopped client") @@ -77,20 +84,243 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } } +extension PacketTunnelProvider { + private func startPacketBridge() throws { + stopPacketBridge() + + let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL) + let call = packetClient.makeTunnelPacketsCall() + self.packetCall = call + + inboundPacketTask = Task { [weak self] in + guard let self else { return } + do { + for try await packet in call.responseStream { + let payload = packet.payload + self.packetFlow.writePackets( + [payload], + withProtocols: [Self.protocolNumber(for: payload)] + ) + } + } catch { + guard !Task.isCancelled else { return } + self.logger.error("Tunnel packet receive loop failed: \(error)") + } + } + + outboundPacketTask = Task { [weak self] in + guard let self else { return } + defer { call.requestStream.finish() } + do { + while !Task.isCancelled { + let packets = await self.readPacketsBatch() + for (payload, _) in packets { + var packet = Burrow_TunnelPacket() + packet.payload = payload + try await call.requestStream.send(packet) + } + } + } catch { + guard !Task.isCancelled else { return } + self.logger.error("Tunnel packet send loop failed: \(error)") + } + } + } + + private func stopPacketBridge() { + inboundPacketTask?.cancel() + inboundPacketTask = nil + outboundPacketTask?.cancel() + outboundPacketTask = nil + packetCall?.cancel() + packetCall = nil + } + + private func readPacketsBatch() async -> [(Data, NSNumber)] { + await withCheckedContinuation { continuation in + packetFlow.readPackets { packets, protocols in + continuation.resume(returning: Array(zip(packets, protocols))) + } + } + } + + private static func protocolNumber(for payload: Data) -> NSNumber { + guard let version = payload.first.map({ $0 >> 4 }) else { + return NSNumber(value: AF_INET) + } + switch version { + case 6: + return NSNumber(value: AF_INET6) + default: + return NSNumber(value: AF_INET) + } + } +} + extension Burrow_TunnelConfigurationResponse { fileprivate var settings: NEPacketTunnelNetworkSettings { - let ipv6Addresses = addresses.filter { IPv6Address($0) != nil } + let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:)) + let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address) + let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address) + let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) + var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) + var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) + if includeDefaultRoute { + ipv4Routes.append(.default()) + ipv6Routes.append(.default()) + } let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") settings.mtu = NSNumber(value: mtu) - settings.ipv4Settings = NEIPv4Settings( - addresses: addresses.filter { IPv4Address($0) != nil }, - subnetMasks: ["255.255.255.0"] - ) - settings.ipv6Settings = NEIPv6Settings( - addresses: ipv6Addresses, - networkPrefixLengths: ipv6Addresses.map { _ in 64 } - ) + if !ipv4Addresses.isEmpty { + let ipv4Settings = NEIPv4Settings( + addresses: ipv4Addresses.map(\.address), + subnetMasks: ipv4Addresses.map(\.subnetMask) + ) + if !ipv4Routes.isEmpty { + ipv4Settings.includedRoutes = ipv4Routes + } + settings.ipv4Settings = ipv4Settings + } + if !ipv6Addresses.isEmpty { + let ipv6Settings = NEIPv6Settings( + addresses: ipv6Addresses.map(\.address), + networkPrefixLengths: ipv6Addresses.map(\.prefixLength) + ) + if !ipv6Routes.isEmpty { + ipv6Settings.includedRoutes = ipv6Routes + } + settings.ipv6Settings = ipv6Settings + } + if !dnsServers.isEmpty { + let dnsSettings = NEDNSSettings(servers: dnsServers) + if !searchDomains.isEmpty { + dnsSettings.matchDomains = searchDomains + } + settings.dnsSettings = dnsSettings + } return settings } } + +private struct ParsedTunnelAddress { + struct IPv4AddressSetting { + let address: String + let subnetMask: String + } + + struct IPv6AddressSetting { + let address: String + let prefixLength: NSNumber + } + + let ipv4Address: IPv4AddressSetting? + let ipv6Address: IPv6AddressSetting? + + init?(rawValue: String) { + let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) + let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !address.isEmpty else { + return nil + } + + let prefix = components.count == 2 ? Int(components[1]) : nil + if IPv4Address(address) != nil { + let prefixLength = prefix ?? 32 + guard (0 ... 32).contains(prefixLength) else { + return nil + } + ipv4Address = IPv4AddressSetting( + address: address, + subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) + ) + ipv6Address = nil + return + } + + if IPv6Address(address) != nil { + let prefixLength = prefix ?? 128 + guard (0 ... 128).contains(prefixLength) else { + return nil + } + ipv4Address = nil + ipv6Address = IPv6AddressSetting( + address: address, + prefixLength: NSNumber(value: prefixLength) + ) + return + } + + return nil + } + + private static func ipv4SubnetMask(prefixLength: Int) -> String { + guard prefixLength > 0 else { + return "0.0.0.0" + } + let mask = UInt32.max << (32 - prefixLength) + let octets = [ + (mask >> 24) & 0xff, + (mask >> 16) & 0xff, + (mask >> 8) & 0xff, + mask & 0xff, + ] + return octets.map(String.init).joined(separator: ".") + } +} + +private struct ParsedTunnelRoute { + let ipv4Route: NEIPv4Route? + let ipv6Route: NEIPv6Route? + + init?(rawValue: String) { + let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) + let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !address.isEmpty else { + return nil + } + + let prefix = components.count == 2 ? Int(components[1]) : nil + if IPv4Address(address) != nil { + let prefixLength = prefix ?? 32 + guard (0 ... 32).contains(prefixLength) else { + return nil + } + ipv4Route = NEIPv4Route( + destinationAddress: address, + subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) + ) + ipv6Route = nil + return + } + + if IPv6Address(address) != nil { + let prefixLength = prefix ?? 128 + guard (0 ... 128).contains(prefixLength) else { + return nil + } + ipv4Route = nil + ipv6Route = NEIPv6Route( + destinationAddress: address, + networkPrefixLength: NSNumber(value: prefixLength) + ) + return + } + + return nil + } + + private static func ipv4SubnetMask(prefixLength: Int) -> String { + var mask = UInt32.max << (32 - prefixLength) + if prefixLength == 0 { + mask = 0 + } + let octets = [ + String((mask >> 24) & 0xff), + String((mask >> 16) & 0xff), + String((mask >> 8) & 0xff), + String(mask & 0xff), + ] + return octets.joined(separator: ".") + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 2128ec3..e15d3f7 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -83,7 +83,7 @@ public struct BurrowView: View { 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.") + description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.") ) .frame(maxWidth: .infinity, minHeight: 180) } else { @@ -135,7 +135,7 @@ public struct BurrowView: View { private func runAutomationIfNeeded() { guard !didRunAutomation, let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .headscaleProbe + automation.action == .tailnetLogin || automation.action == .tailnetProbe else { return } @@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View { @State private var isStartingTailnetLogin = false @State private var tailnetPresentedAuthURL: URL? @State private var preserveTailnetLoginSession = false + @State private var usesCustomTailnetAuthority = false + @State private var showsAdvancedTailnetSettings = false @State private var browserAuthenticator = TailnetBrowserAuthenticator() @State private var tailnetLoginPollTask: Task? + @State private var tailnetDiscoveryTask: Task? + @State private var tailnetProbeTask: Task? @State private var didRunAutomation = false init( @@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View { .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) .listRowBackground(Color.clear) - 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() + if showsIdentitySection { + Section("Identity") { + identityFields } } @@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View { } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() + if sheet == .tailnet, usesCustomTailnetAuthority { + scheduleTailnetAuthorityProbe() + } } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() + if sheet == .tailnet, !usesCustomTailnetAuthority { + scheduleTailnetDiscovery() + } } .onChange(of: draft.authMode) { _, newMode in guard newMode != .web else { return } @@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View { } .onDisappear { tailnetLoginPollTask?.cancel() + tailnetDiscoveryTask?.cancel() + tailnetProbeTask?.cancel() browserAuthenticator.cancel() if !preserveTailnetLoginSession { Task { @MainActor in @@ -479,6 +486,18 @@ private struct ConfigurationSheetView: View { } } + @ViewBuilder + private var identityFields: some View { + 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() + } + } + @ViewBuilder private var tailnetSections: some View { Section("Connection") { @@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View { .burrowLoginField() .autocorrectionDisabled() .accessibilityIdentifier("tailnet-discovery-email") - - Button { - discoverTailnetAuthority() - } label: { - Label { - Text(isDiscoveringTailnet ? "Finding Server" : "Find Server") - } icon: { - Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle") + .submitLabel(.continue) + .onSubmit { + if !usesCustomTailnetAuthority { + scheduleTailnetDiscovery(immediate: true) } } - .buttonStyle(.borderless) - .disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil) - .accessibilityIdentifier("tailnet-find-server") - if let discoveryStatus { - tailnetDiscoveryCard(status: discoveryStatus, failure: nil) - } else if let discoveryError { - tailnetDiscoveryCard(status: nil, failure: discoveryError) - } + tailnetServerCard - TextField("Authority URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-authority") - - Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") - .font(.footnote) - .foregroundStyle(.secondary) - - Button { - probeTailnetAuthority() - } label: { - Label { - Text(isProbingAuthority ? "Checking Connection" : "Check Connection") - } icon: { - Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") + if showsAdvancedTailnetSettings { + if usesCustomTailnetAuthority { + TextField("Server URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + .accessibilityIdentifier("tailnet-authority") + } else { + TextField("Tailnet", text: $draft.tailnet) + .burrowLoginField() + .autocorrectionDisabled() + .accessibilityIdentifier("tailnet-name") } } - .buttonStyle(.borderless) - .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - .accessibilityIdentifier("tailnet-check-connection") - - if let authorityProbeStatus { - tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) - } else if let authorityProbeError { - tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) - } - - TextField("Tailnet", text: $draft.tailnet) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-name") } Section("Authentication") { - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) + if showsAdvancedTailnetSettings { + Picker("Authentication", selection: $draft.authMode) { + ForEach(availableTailnetAuthModes) { mode in + Text(mode.title).tag(mode) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) if draft.authMode == .web { Button { @@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View { } } .buttonStyle(.borderless) - .disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil) + .disabled(isStartingTailnetLogin || tailnetLoginActionDisabled) .accessibilityIdentifier("tailnet-start-sign-in") if let tailnetLoginStatus { @@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View { } if sheet == .tailnet { - if let authorityProbeStatus { - Text(authorityProbeStatus.summary) + labeledValue("Server", tailnetServerDisplayLabel) + if let connectionSummary = tailnetConnectionSummary { + Text(connectionSummary) .font(.footnote.weight(.medium)) - .foregroundStyle(.primary) - if let detail = authorityProbeStatus.detail { - Text(detail) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) - } - } else if let authorityProbeError { - Text("Connection failed") - .font(.footnote.weight(.medium)) - .foregroundStyle(.red) - Text(authorityProbeError) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) + .foregroundStyle(tailnetConnectionSummaryColor) } - } - - if sheet == .tailnet { - HStack(spacing: 8) { - summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") - summaryBadge(draft.authMode.title) - if tailnetLoginStatus?.running == true { + if tailnetLoginStatus?.running == true { + HStack(spacing: 8) { summaryBadge("Signed In") } } @@ -654,6 +627,44 @@ private struct ConfigurationSheetView: View { ) } + private var tailnetServerCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(usesCustomTailnetAuthority ? "Custom Server" : "Server") + .font(.subheadline.weight(.medium)) + Text(tailnetServerDisplayLabel) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Spacer() + + if isDiscoveringTailnet || isProbingAuthority { + ProgressView() + .controlSize(.small) + } else if let summary = tailnetConnectionSummary { + Text(summary) + .font(.caption.weight(.medium)) + .foregroundStyle(tailnetConnectionSummaryColor) + } + } + + if let detail = tailnetServerDetail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + .accessibilityIdentifier("tailnet-server-card") + } + private func tailnetAuthorityProbeCard( status: TailnetAuthorityProbeStatus?, failure: String? @@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Button("Use Tailscale Managed Server") { - applyTailnetDefaults(for: .tailscale) + Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") { + toggleTailnetAuthorityMode() } - if availableTailnetAuthModes.count > 1 { + Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") { + showsAdvancedTailnetSettings.toggle() + } + + if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 { Menu("Authentication") { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { @@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View { } } - Button("Clear Discovery Result") { - resetTailnetDiscoveryFeedback() + Button("Refresh Server Lookup") { + scheduleTailnetDiscovery(immediate: true) } + .disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil) } } @@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View { private var showsBottomActionButton: Bool { #if os(iOS) - true + return true #else - false + return false #endif } + private var showsIdentitySection: Bool { + switch sheet { + case .wireGuard, .tor: + return true + case .tailnet: + return showsAdvancedTailnetSettings + } + } + private var wireGuardEditorHeight: CGFloat { #if os(iOS) 180 @@ -910,6 +935,18 @@ private struct ConfigurationSheetView: View { } } + private var tailnetLoginActionDisabled: Bool { + switch sheet { + case .tailnet: + if usesCustomTailnetAuthority { + return normalizedOptional(draft.authority) == nil + } + return false + case .wireGuard, .tor: + return true + } + } + private var submissionDisabled: Bool { switch sheet { case .wireGuard: @@ -933,6 +970,50 @@ private struct ConfigurationSheetView: View { } } + private var tailnetServerDisplayLabel: String { + if usesCustomTailnetAuthority { + return normalizedOptional(draft.authority) + ?? "Enter a custom Tailnet server" + } + return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed" + } + + private var tailnetServerDetail: String? { + if usesCustomTailnetAuthority { + if let discovery = discoveryStatus { + return "Discovered from \(discovery.domain)." + } + if let discoveryError { + return discoveryError + } + return "Use a custom Tailnet authority when your domain does not advertise one." + } + return "Continue with Tailscale, or open advanced settings to use a custom server." + } + + private var tailnetConnectionSummary: String? { + if isDiscoveringTailnet { + return "Finding server" + } + if isProbingAuthority { + return "Checking" + } + if let authorityProbeStatus { + return authorityProbeStatus.summary + } + if authorityProbeError != nil { + return "Unavailable" + } + return nil + } + + private var tailnetConnectionSummaryColor: Color { + if authorityProbeError != nil { + return .red + } + return .secondary + } + private func submit() { isSubmitting = true errorMessage = nil @@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View { guard !didRunAutomation, sheet == .tailnet, let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .headscaleProbe + automation.action == .tailnetLogin || automation.action == .tailnetProbe else { return } @@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View { case .tailnetLogin: applyTailnetDefaults(for: .tailscale) startTailnetLogin() - case .headscaleProbe: + case .tailnetProbe: + usesCustomTailnetAuthority = true + showsAdvancedTailnetSettings = true draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() } @@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View { ) var noteParts: [String] = [ - isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", - "Auth: \(draft.authMode.title)", + "Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))", ] + if showsAdvancedTailnetSettings || draft.authMode != .web { + noteParts.append("Auth: \(draft.authMode.title)") + } + if draft.authMode == .web, tailnetLoginStatus?.running == true { noteParts.append("Browser sign-in complete") } @@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View { private func applyTailnetDefaults(for provider: TailnetProvider) { resetTailnetDiscoveryFeedback() + usesCustomTailnetAuthority = provider != .tailscale draft.authority = provider.defaultAuthority ?? "" if !availableTailnetAuthModes.contains(draft.authMode) { draft.authMode = .web @@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View { } private func startTailnetLogin() { - guard let authority = normalizedOptional(draft.authority) else { - tailnetLoginStatus = nil - tailnetLoginError = "Enter a server URL first." - return - } - isStartingTailnetLogin = true tailnetLoginError = nil preserveTailnetLoginSession = false @@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isStartingTailnetLogin = false } do { + let authority = try await resolveTailnetAuthorityForLogin() let status = try await networkViewModel.startTailnetLogin( accountName: normalized(draft.accountName, fallback: "default"), identityName: normalized(draft.identityName, fallback: "apple"), @@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View { } private func resetAuthorityProbe() { + tailnetProbeTask?.cancel() authorityProbeStatus = nil authorityProbeError = nil tailnetLoginError = nil } private func resetTailnetDiscoveryFeedback() { + tailnetDiscoveryTask?.cancel() discoveryStatus = nil discoveryError = nil } @@ -1210,6 +1294,83 @@ private struct ConfigurationSheetView: View { } } + private func scheduleTailnetDiscovery(immediate: Bool = false) { + guard sheet == .tailnet else { return } + tailnetDiscoveryTask?.cancel() + + guard !usesCustomTailnetAuthority else { + discoveryStatus = nil + discoveryError = nil + return + } + + guard normalizedOptional(draft.discoveryEmail) != nil else { + discoveryStatus = nil + discoveryError = nil + draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" + return + } + + tailnetDiscoveryTask = Task { @MainActor in + if !immediate { + try? await Task.sleep(for: .milliseconds(450)) + } + guard !Task.isCancelled else { return } + discoverTailnetAuthority() + } + } + + private func scheduleTailnetAuthorityProbe() { + guard sheet == .tailnet else { return } + tailnetProbeTask?.cancel() + guard normalizedOptional(draft.authority) != nil else { return } + + tailnetProbeTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + probeTailnetAuthority() + } + } + + private func toggleTailnetAuthorityMode() { + let discoveredAuthority = discoveryStatus?.authority + usesCustomTailnetAuthority.toggle() + resetTailnetDiscoveryFeedback() + resetAuthorityProbe() + if usesCustomTailnetAuthority { + draft.authority = discoveredAuthority ?? draft.authority + } else { + draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" + scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil) + } + } + + private func resolveTailnetAuthorityForLogin() async throws -> String { + if !usesCustomTailnetAuthority { + let authority = TailnetProvider.tailscale.defaultAuthority ?? "" + draft.authority = authority + scheduleTailnetAuthorityProbe() + return authority + } + + if let authority = normalizedOptional(draft.authority) { + return authority + } + + if let email = normalizedOptional(draft.discoveryEmail) { + let discovery = try await networkViewModel.discoverTailnet(email: email) + discoveryStatus = discovery + discoveryError = nil + draft.authority = discovery.authority + scheduleTailnetAuthorityProbe() + return discovery.authority + } + + throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first." + ]) + } + private func beginTailnetLoginPolling(sessionID: String) { tailnetLoginPollTask?.cancel() tailnetLoginPollTask = Task { @MainActor in @@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View { if tailnetLoginSessionID != nil { return "Resume Sign-In" } - return "Start Sign-In" + return "Continue with Tailscale" } private var tailnetAuthenticationFootnote: String { switch draft.authMode { case .web: - return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running." + if usesCustomTailnetAuthority { + return "Burrow signs in through the daemon using your custom Tailnet server." + } + return "Burrow signs in through the daemon using Tailscale's managed browser flow." case .none: return "Save the authority only. Useful when the control plane handles authentication elsewhere." case .password, .preauthKey: @@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View { ) } - private var isManagedTailnetAuthority: Bool { - TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority)) - } - @ViewBuilder private func labeledValue(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -1383,12 +1543,7 @@ private struct AccountRowView: View { 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) - } - } + Text(account.kind.title) .font(.subheadline) .foregroundStyle(account.kind.accentColor) } @@ -1470,6 +1625,12 @@ private extension View { @MainActor private final class TailnetBrowserAuthenticator: NSObject { private var session: ASWebAuthenticationSession? + private static var prefersEphemeralSessionForCurrentProcess: Bool { + let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return rawValue == "1" || rawValue == "true" || rawValue == "yes" + } func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { cancel() @@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject { onDismiss() } session.presentationContextProvider = self - session.prefersEphemeralWebBrowserSession = false + session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess self.session = session _ = session.start() } @@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator { private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" - case headscaleProbe = "headscale-probe" + case tailnetProbe = "tailnet-probe" } let action: Action diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 32f0b8c..35bd0e1 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .tailscale: "Tailscale" - case .headscale: "Headscale" + case .headscale: "Custom Tailnet" case .burrow: "Burrow" } } @@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." case .tor: "Store Arti account and identity preferences." - case .tailnet: "Save Tailnet authority, identity, and login material." + case .tailnet: "Save Tailnet authority, identity defaults, and login material." } } @@ -402,7 +402,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case .tor: "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." case .tailnet: - "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." + "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon." } } } diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh index 5086bd1..5170a1e 100755 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -5,13 +5,18 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" +simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}" derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" -fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback" +fallback_dir="/tmp/${bundle_id}/SimulatorFallback" socket_path="${fallback_dir}/burrow.sock" +tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" +ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}" +ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner" ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" +ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}" password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" @@ -25,10 +30,60 @@ if [[ -z "$ui_test_password" ]]; then fi fi -mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path" +rm -rf "$fallback_dir" "$tailnet_state_root" +mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path" rm -f "$socket_path" +resolve_simulator_id() { + xcrun simctl list devices available -j | python3 -c ' +import json +import os +import sys + +target_name = sys.argv[1] +target_os = sys.argv[2] +target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-") +devices = json.load(sys.stdin).get("devices", {}) +healthy = [] +for runtime, entries in devices.items(): + if runtime != target_runtime: + continue + for entry in entries: + if not entry.get("isAvailable", False): + continue + if not os.path.isdir(entry.get("dataPath", "")): + continue + healthy.append(entry) +for entry in healthy: + if entry.get("name") == target_name: + print(entry["udid"]) + raise SystemExit(0) +for entry in healthy: + if target_name in entry.get("name", ""): + print(entry["udid"]) + raise SystemExit(0) +raise SystemExit(1) +' "$simulator_name" "$simulator_os" +} + +if [[ -z "$simulator_id" ]]; then + simulator_id="$(resolve_simulator_id || true)" +fi + +if [[ -n "$simulator_id" ]]; then + xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$simulator_id" -b + xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true + xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true + destination="id=${simulator_id}" +else + destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" +fi + cleanup() { + rm -f "$ui_test_config_path" if [[ -n "${daemon_pid:-}" ]]; then kill "$daemon_pid" >/dev/null 2>&1 || true wait "$daemon_pid" >/dev/null 2>&1 || true @@ -36,11 +91,33 @@ cleanup() { } trap cleanup EXIT +umask 077 +python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode" +import json +import pathlib +import sys + +config_path = pathlib.Path(sys.argv[1]) +config_path.write_text( + json.dumps( + { + "email": sys.argv[2], + "username": sys.argv[3], + "password": sys.argv[4], + "mode": sys.argv[5], + } + ), + encoding="utf-8", +) +PY + cargo build -p burrow --bin burrow ( cd "$fallback_dir" + RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \ BURROW_SOCKET_PATH="burrow.sock" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 ) & daemon_pid=$! @@ -56,18 +133,31 @@ if [[ ! -S "$socket_path" ]]; then exit 1 fi +common_xcodebuild_args=( + -quiet + -skipPackagePluginValidation + -project "${repo_root}/Apple/Burrow.xcodeproj" + -scheme App + -configuration Debug + -destination "$destination" + -derivedDataPath "$derived_data_path" + -clonedSourcePackagesDirPath "$source_packages_path" + -only-testing:BurrowUITests + -parallel-testing-enabled NO + -maximum-concurrent-test-simulator-destinations 1 + -maximum-parallel-testing-workers 1 + CODE_SIGNING_ALLOWED=NO +) + +xcodebuild \ + "${common_xcodebuild_args[@]}" \ + build-for-testing + BURROW_UI_TEST_EMAIL="$ui_test_email" \ BURROW_UI_TEST_USERNAME="$ui_test_username" \ BURROW_UI_TEST_PASSWORD="$ui_test_password" \ +BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \ +BURROW_UI_TEST_EPHEMERAL_AUTH=1 \ xcodebuild \ - -quiet \ - -skipPackagePluginValidation \ - -project "${repo_root}/Apple/Burrow.xcodeproj" \ - -scheme App \ - -configuration Debug \ - -destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \ - -derivedDataPath "$derived_data_path" \ - -clonedSourcePackagesDirPath "$source_packages_path" \ - -only-testing:BurrowUITests \ - CODE_SIGNING_ALLOWED=NO \ - test + "${common_xcodebuild_args[@]}" \ + test-without-building diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index 55516e1..d08c807 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest { pub hostname: Option, #[serde(default)] pub control_url: Option, + #[serde(default)] + pub packet_socket: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default)] @@ -55,23 +57,35 @@ pub struct TailscaleLoginStartResponse { pub status: TailscaleLoginStatus, } +pub struct TailscaleLoginSession { + pub session_id: String, + pub helper: Arc, + pub status: TailscaleLoginStatus, +} + #[derive(Clone, Default)] pub struct TailscaleBridgeManager { client: Client, sessions: Arc>>>, } -struct ManagedSession { +pub struct TailscaleHelperProcess { session_id: String, listen_url: String, + packet_socket: Option, + control_url: Option, state_dir: PathBuf, child: Arc>, _stderr_task: JoinHandle<()>, } +type ManagedSession = TailscaleHelperProcess; + #[derive(Debug, Deserialize)] struct HelperHello { listen_addr: String, + #[serde(default)] + packet_socket: Option, } impl TailscaleBridgeManager { @@ -79,76 +93,71 @@ impl TailscaleBridgeManager { &self, request: TailscaleLoginStartRequest, ) -> Result { - let key = session_key(&request.account_name, &request.identity_name); + let session = self.ensure_session(request).await?; + Ok(TailscaleLoginStartResponse { + session_id: session.session_id, + status: session.status, + }) + } + + pub async fn ensure_session( + &self, + request: TailscaleLoginStartRequest, + ) -> Result { + let key = session_key_for_request(&request); + let requested_packet_socket = request + .packet_socket + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let requested_control_url = request + .control_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - match self.fetch_status(existing.as_ref()).await { - Ok(status) => { - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); - } - Err(err) => { - log::warn!( - "tailscale login session {} is stale, restarting: {err}", - existing.session_id - ); - self.sessions.lock().await.remove(&key); - let _ = self.shutdown_session(existing.as_ref()).await; + let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket()) + { + (Some(requested), Some(current)) => current != Path::new(requested), + (Some(_), None) => true, + _ => false, + }; + let needs_restart_for_control_url = + requested_control_url != existing.control_url().map(|value| value.trim()); + + if !needs_restart_for_socket && !needs_restart_for_control_url { + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginSession { + session_id: existing.session_id.clone(), + helper: existing, + status, + }); + } + Err(err) => { + log::warn!( + "tailscale login session {} is stale, restarting: {err}", + existing.session_id + ); + } } + } else { + log::info!( + "tailscale login session {} no longer matches requested transport, restarting", + existing.session_id + ); } + + self.sessions.lock().await.remove(&key); + let _ = self.shutdown_session(existing.as_ref()).await; } - 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 session = Arc::new(spawn_tailscale_helper(&request).await?); let status = self.wait_for_status(session.as_ref()).await?; - let response = TailscaleLoginStartResponse { + let response = TailscaleLoginSession { session_id: session.session_id.clone(), + helper: session.clone(), status, }; @@ -192,7 +201,7 @@ impl TailscaleBridgeManager { let mut last_error = None; let mut last_status = None; for _ in 0..40 { - match self.fetch_status(session).await { + match session.status_with_client(&self.client).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), @@ -206,28 +215,7 @@ impl TailscaleBridgeManager { } 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") + session.status_with_client(&self.client).await } async fn remove_session_by_id(&self, session_id: &str) -> Option> { @@ -239,14 +227,74 @@ impl TailscaleBridgeManager { } async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { - let _ = self - .client - .post(format!("{}/shutdown", session.listen_url)) + session.shutdown_with_client(&self.client).await + } +} + +impl TailscaleHelperProcess { + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn packet_socket(&self) -> Option<&Path> { + self.packet_socket.as_deref() + } + + pub fn control_url(&self) -> Option<&str> { + self.control_url.as_deref() + } + + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + pub async fn status(&self) -> Result { + self.status_with_client(&Client::new()).await + } + + pub async fn shutdown(&self) -> Result<()> { + self.shutdown_with_client(&Client::new()).await + } + + async fn status_with_client(&self, client: &Client) -> Result { + let mut child = self.child.lock().await; + if let Some(status) = child.try_wait()? { + return Err(anyhow!( + "tailscale helper exited with status {status} for {}", + self.state_dir.display() + )); + } + drop(child); + + let response = client + .get(format!("{}/status", self.listen_url)) .send() - .await; + .await + .context("failed to query tailscale helper status")? + .error_for_status() + .context("tailscale helper status request failed")?; + + let status = response + .json::() + .await + .context("invalid tailscale helper status response")?; + + log::info!( + "tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}", + self.session_id, + status.backend_state, + status.running, + status.needs_login, + status.auth_url + ); + Ok(status) + } + + async fn shutdown_with_client(&self, client: &Client) -> Result<()> { + let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await; for _ in 0..10 { - let mut child = session.child.lock().await; + let mut child = self.child.lock().await; if child.try_wait()?.is_some() { return Ok(()); } @@ -254,7 +302,7 @@ impl TailscaleBridgeManager { tokio::time::sleep(Duration::from_millis(100)).await; } - let mut child = session.child.lock().await; + let mut child = self.child.lock().await; child .start_kill() .context("failed to kill tailscale helper")?; @@ -263,6 +311,58 @@ impl TailscaleBridgeManager { } } +pub async fn spawn_tailscale_helper( + request: &TailscaleLoginStartRequest, +) -> Result { + 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}"); + } + }); + + Ok(TailscaleHelperProcess { + session_id: random_session_id(), + listen_url: format!("http://{}", hello.listen_addr), + packet_socket: hello.packet_socket.map(PathBuf::from), + control_url: request.control_url.clone(), + state_dir, + child: Arc::new(Mutex::new(child)), + _stderr_task: stderr_task, + }) +} + fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") { Command::new(path) @@ -291,10 +391,21 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res } } + if let Some(packet_socket) = request.packet_socket.as_deref() { + let trimmed = packet_socket.trim(); + if !trimmed.is_empty() { + command.arg("--packet-socket").arg(trimmed); + } + } + Ok(command) } -fn state_root() -> PathBuf { +pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { + state_root().join(session_dir_name(request)).join("packet.sock") +} + +pub(crate) fn state_root() -> PathBuf { if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") { return PathBuf::from(path); } @@ -315,19 +426,34 @@ fn state_root() -> PathBuf { .join("tailscale") } -fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { +pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { format!( - "{}-{}", + "{}-{}-{}", slug(&request.account_name), - slug(&request.identity_name) + slug(&request.identity_name), + slug(control_scope(request)) ) } -fn session_key(account_name: &str, identity_name: &str) -> String { - format!("{account_name}:{identity_name}") +fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String { + format!( + "{}:{}:{}", + request.account_name, + request.identity_name, + control_scope(request) + ) } -fn default_hostname(request: &TailscaleLoginStartRequest) -> String { +fn control_scope(request: &TailscaleLoginStartRequest) -> &str { + request + .control_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("tailscale-managed") +} + +pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String { request .hostname .as_deref() @@ -370,14 +496,24 @@ mod tests { } #[test] - fn state_dir_is_stable_by_account_and_identity() { + fn state_dir_is_scoped_by_account_identity_and_control_plane() { let request = TailscaleLoginStartRequest { account_name: "default".to_owned(), identity_name: "apple".to_owned(), hostname: None, control_url: None, + packet_socket: None, }; - assert_eq!(session_dir_name(&request), "default-apple"); + assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed"); assert_eq!(default_hostname(&request), "burrow-apple"); + + let custom_request = TailscaleLoginStartRequest { + control_url: Some("https://ts.burrow.net".to_owned()), + ..request + }; + assert_eq!( + session_dir_name(&custom_request), + "default-apple-httpstsburrownet" + ); } } diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 5fc7add..d044a62 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use reqwest::{Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; use super::TailnetProvider; @@ -43,6 +44,7 @@ struct WebFingerLink { pub async fn discover_tailnet(email: &str) -> Result { let domain = email_domain(email)?; + info!(%email, %domain, "tailnet discovery requested"); let base_url = Url::parse(&format!("https://{domain}")) .with_context(|| format!("invalid discovery domain {domain}"))?; let client = Client::builder() @@ -116,12 +118,21 @@ pub async fn discover_tailnet_at( base_url: &Url, ) -> Result { let domain = email_domain(email)?; + debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery"); if let Some(discovery) = discover_well_known(client, base_url).await? { + info!( + %email, + %domain, + authority = %discovery.authority, + provider = ?discovery.provider, + "resolved tailnet discovery from well-known document" + ); return Ok(TailnetDiscovery { domain, ..discovery }); } if let Some(authority) = discover_webfinger(client, email, base_url).await? { + info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger"); return Ok(TailnetDiscovery { domain, provider: inferred_provider(Some(&authority), None), @@ -162,6 +173,7 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result Res url.query_pairs_mut() .append_pair("resource", &format!("acct:{email}")) .append_pair("rel", TAILNET_DISCOVERY_REL); + debug!(%email, url = %url, "requesting tailnet webfinger document"); let response = client .get(url) diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 0a23ddc..9b2e138 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -8,7 +8,7 @@ use rusqlite::Connection; use tokio::sync::{mpsc, watch, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::warn; +use tracing::{debug, info, warn}; use tun::tokio::TunInterface; use super::{ @@ -16,15 +16,15 @@ use super::{ networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, - TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, + TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket, TunnelStatusResponse, }, - runtime::{ActiveTunnel, ResolvedTunnel}, + runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, }; use crate::{ auth::server::tailscale::{ - TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, - TailscaleLoginStatus, + packet_socket_path, TailscaleBridgeManager, + TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus, }, control::discovery, daemon::rpc::ServerConfig, @@ -87,11 +87,20 @@ impl DaemonRPCServer { } async fn current_tunnel_configuration(&self) -> Result { - let config = self - .resolve_tunnel() - .await? - .server_config() - .map_err(proc_err)?; + let config = { + let active = self.active_tunnel.read().await; + active + .as_ref() + .map(|tunnel| tunnel.server_config().clone()) + }; + let config = match config { + Some(config) => config, + None => self + .resolve_tunnel() + .await? + .server_config() + .map_err(proc_err)?, + }; Ok(configuration_rsp(config)) } @@ -111,8 +120,18 @@ impl DaemonRPCServer { async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> { let _ = self.stop_active_tunnel().await?; + let tailnet_helper = match &desired { + ResolvedTunnel::Tailnet { identity, config } => Some( + self.tailnet_login + .ensure_session(tailnet_helper_request(identity, config)) + .await + .map_err(proc_err)? + .helper, + ), + _ => None, + }; let active = desired - .start(self.tun_interface.clone()) + .start(self.tun_interface.clone(), tailnet_helper) .await .map_err(proc_err)?; self.active_tunnel.write().await.replace(active); @@ -137,6 +156,23 @@ impl DaemonRPCServer { Ok(()) } + fn tailnet_bridge_request( + account_name: String, + identity_name: String, + hostname: String, + authority: String, + ) -> BridgeLoginStartRequest { + let mut request = BridgeLoginStartRequest { + account_name, + identity_name, + hostname: (!hostname.trim().is_empty()).then_some(hostname), + control_url: Self::tailnet_control_url(&authority), + packet_socket: None, + }; + request.packet_socket = Some(packet_socket_path(&request).display().to_string()); + request + } + fn tailnet_control_url(authority: &str) -> Option { let authority = discovery::normalize_authority(authority); (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) @@ -146,6 +182,7 @@ impl DaemonRPCServer { #[tonic::async_trait] impl Tunnel for DaemonRPCServer { type TunnelConfigurationStream = ReceiverStream>; + type TunnelPacketsStream = ReceiverStream>; type TunnelStatusStream = ReceiverStream>; async fn tunnel_configuration( @@ -171,6 +208,62 @@ impl Tunnel for DaemonRPCServer { Ok(Response::new(ReceiverStream::new(rx))) } + async fn tunnel_packets( + &self, + request: Request>, + ) -> Result, RspStatus> { + let (packet_tx, mut packet_rx) = { + let guard = self.active_tunnel.read().await; + let Some(active) = guard.as_ref() else { + return Err(RspStatus::failed_precondition("no active tunnel")); + }; + active.packet_stream().ok_or_else(|| { + RspStatus::failed_precondition( + "active tunnel does not support packet streaming", + ) + })? + }; + + let (tx, rx) = mpsc::channel(128); + tokio::spawn(async move { + loop { + match packet_rx.recv().await { + Ok(payload) => { + if tx.send(Ok(TunnelPacket { payload })).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + let mut inbound = request.into_inner(); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(packet)) => { + debug!( + "daemon tunnel packet stream received {} bytes from client", + packet.payload.len() + ); + if packet_tx.send(packet.payload).await.is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + warn!("tailnet packet stream receive error: {error}"); + break; + } + } + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { let desired = self.resolve_tunnel().await?; let already_running = { @@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!(email = %request.email, "daemon tailnet discover RPC received"); let discovery = discovery::discover_tailnet(&request.email) .await .map_err(proc_err)?; + info!( + email = %request.email, + authority = %discovery.authority, + provider = ?discovery.provider, + "daemon tailnet discover RPC resolved" + ); Ok(Response::new(TailnetDiscoverResponse { domain: discovery.domain, @@ -325,17 +425,32 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!( + account = %request.account_name, + identity = %request.identity_name, + authority = %request.authority, + "daemon tailnet login start RPC received" + ); let response = self .tailnet_login - .start_login(BridgeLoginStartRequest { - account_name: request.account_name, - identity_name: request.identity_name, - hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname), - control_url: Self::tailnet_control_url(&request.authority), - }) + .start_login(Self::tailnet_bridge_request( + request.account_name, + request.identity_name, + request.hostname, + request.authority, + )) .await .map_err(proc_err)?; + info!( + session_id = %response.session_id, + backend_state = %response.status.backend_state, + running = response.status.running, + needs_login = response.status.needs_login, + auth_url = ?response.status.auth_url, + "daemon tailnet login start RPC resolved" + ); + Ok(Response::new(tailnet_login_rsp( response.session_id, response.status, @@ -347,6 +462,7 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!(session_id = %request.session_id, "daemon tailnet login status RPC received"); let status = self .tailnet_login .status(&request.session_id) @@ -355,6 +471,14 @@ impl TailnetControl for DaemonRPCServer { let Some(status) = status else { return Err(RspStatus::not_found("tailnet login session not found")); }; + info!( + session_id = %request.session_id, + backend_state = %status.backend_state, + running = status.running, + needs_login = status.needs_login, + auth_url = ?status.auth_url, + "daemon tailnet login status RPC resolved" + ); Ok(Response::new(tailnet_login_rsp(request.session_id, status))) } @@ -381,8 +505,12 @@ fn proc_err(err: impl ToString) -> RspStatus { fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { TunnelConfigurationResponse { - mtu: config.mtu.unwrap_or(1000), addresses: config.address, + mtu: config.mtu.unwrap_or(1000), + routes: config.routes, + dns_servers: config.dns_servers, + search_domains: config.search_domains, + include_default_route: config.include_default_route, } } diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 8948ca4..6d03581 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { pub address: Vec, + #[serde(default)] + pub routes: Vec, + #[serde(default)] + pub dns_servers: Vec, + #[serde(default)] + pub search_domains: Vec, + #[serde(default)] + pub include_default_route: bool, pub name: Option, pub mtu: Option, } @@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig { fn try_from(config: &Config) -> anyhow::Result { Ok(ServerConfig { address: config.interface.address.clone(), + routes: config + .peers + .iter() + .flat_map(|peer| peer.allowed_ips.iter().cloned()) + .collect(), + dns_servers: config.interface.dns.clone(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: config.interface.mtu.map(|mtu| mtu as i32), }) @@ -88,6 +104,10 @@ impl Default for ServerConfig { fn default() -> Self { Self { address: vec!["10.13.13.2".to_string()], // Dummy remote address + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: None, } diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 84dfd2b..31821a2 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -1,7 +1,13 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; -use anyhow::{Context, Result}; -use tokio::{sync::RwLock, task::JoinHandle}; +use anyhow::{bail, Context, Result}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::UnixStream, + sync::{broadcast, mpsc, RwLock}, + task::JoinHandle, + time::{sleep, Duration}, +}; use tun::{tokio::TunInterface, TunOptions}; use super::rpc::{ @@ -9,7 +15,11 @@ use super::rpc::{ ServerConfig, }; use crate::{ - control::TailnetConfig, + auth::server::tailscale::{ + default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess, + TailscaleLoginStartRequest, TailscaleLoginStatus, + }, + control::{discovery, TailnetConfig}, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -78,11 +88,19 @@ impl ResolvedTunnel { match self { Self::Passthrough { .. } => Ok(ServerConfig { address: Vec::new(), + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: Some(1500), }), Self::Tailnet { .. } => Ok(ServerConfig { address: Vec::new(), + routes: tailnet_routes(), + dns_servers: tailnet_dns_servers(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: Some(1280), }), @@ -93,21 +111,71 @@ impl ResolvedTunnel { pub async fn start( self, tun_interface: Arc>>, + tailnet_helper: Option>, ) -> 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::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { + identity, + server_config: ServerConfig { + address: Vec::new(), + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, + name: None, + mtu: Some(1500), + }, + }), + Self::Tailnet { identity, config } => { + let (helper, shutdown_helper_on_stop) = match tailnet_helper { + Some(helper) => (helper, false), + None => { + let helper_request = tailnet_helper_request(&identity, &config); + let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?); + (helper, true) + } + }; + let status = wait_for_tailnet_ready(helper.as_ref()).await?; + let server_config = tailnet_server_config(&status); + let packet_socket = helper + .packet_socket() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?; + let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?; + #[cfg(target_vendor = "apple")] + let tun_task = None; + #[cfg(not(target_vendor = "apple"))] + let tun_task = { + let tun = TunOptions::new().open()?; + tun_interface.write().await.replace(tun); + Some(tokio::spawn(run_tailnet_tun_bridge( + tun_interface.clone(), + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + ))) + }; + + Ok(ActiveTunnel::Tailnet { + identity, + server_config, + helper, + shutdown_helper_on_stop, + packet_bridge, + tun_task, + }) + } Self::WireGuard { identity, config } => { + let server_config = ServerConfig::try_from(&config)?; let tun = TunOptions::new().open()?; tun_interface.write().await.replace(tun); match start_wireguard_runtime(config, tun_interface.clone()).await { - Ok((interface, task)) => { - Ok(ActiveTunnel::WireGuard { identity, interface, task }) - } + Ok((interface, task)) => Ok(ActiveTunnel::WireGuard { + identity, + server_config, + interface, + task, + }), Err(err) => { tun_interface.write().await.take(); Err(err) @@ -121,9 +189,19 @@ impl ResolvedTunnel { pub enum ActiveTunnel { Passthrough { identity: RuntimeIdentity, + server_config: ServerConfig, + }, + Tailnet { + identity: RuntimeIdentity, + server_config: ServerConfig, + helper: Arc, + shutdown_helper_on_stop: bool, + packet_bridge: TailnetPacketBridge, + tun_task: Option>>, }, WireGuard { identity: RuntimeIdentity, + server_config: ServerConfig, interface: Arc>, task: JoinHandle>, }, @@ -132,15 +210,69 @@ pub enum ActiveTunnel { impl ActiveTunnel { pub fn identity(&self) -> &RuntimeIdentity { match self { - Self::Passthrough { identity } + Self::Passthrough { identity, .. } + | Self::Tailnet { identity, .. } | Self::WireGuard { identity, .. } => identity, } } + pub fn server_config(&self) -> &ServerConfig { + match self { + Self::Passthrough { server_config, .. } + | Self::Tailnet { server_config, .. } + | Self::WireGuard { server_config, .. } => server_config, + } + } + + pub fn packet_stream( + &self, + ) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { + match self { + Self::Tailnet { packet_bridge, .. } => Some(( + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + )), + _ => None, + } + } + pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { match self { Self::Passthrough { .. } => Ok(()), - Self::WireGuard { interface, task, .. } => { + Self::Tailnet { + helper, + shutdown_helper_on_stop, + packet_bridge, + tun_task, + .. + } => { + if let Some(tun_task) = tun_task { + tun_task.abort(); + match tun_task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + } + packet_bridge.task.abort(); + match packet_bridge.task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + tun_interface.write().await.take(); + if shutdown_helper_on_stop { + helper.shutdown().await?; + } + Ok(()) + } + Self::WireGuard { + interface, + task, + .. + } => { interface.read().await.remove_tun().await; let task_result = task.await; tun_interface.write().await.take(); @@ -151,6 +283,22 @@ impl ActiveTunnel { } } +pub struct TailnetPacketBridge { + outbound: mpsc::Sender>, + inbound: broadcast::Sender>, + task: JoinHandle>, +} + +impl TailnetPacketBridge { + fn outbound_sender(&self) -> mpsc::Sender> { + self.outbound.clone() + } + + fn subscribe(&self) -> broadcast::Receiver> { + self.inbound.subscribe() + } +} + async fn start_wireguard_runtime( config: Config, tun_interface: Arc>>, @@ -166,6 +314,279 @@ async fn start_wireguard_runtime( Ok((interface, task)) } +pub(crate) fn tailnet_helper_request( + identity: &RuntimeIdentity, + config: &TailnetConfig, +) -> TailscaleLoginStartRequest { + let account_name = config + .account + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("default") + .to_owned(); + let identity_name = config + .identity + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| match identity { + RuntimeIdentity::Network { id, .. } => format!("network-{id}"), + RuntimeIdentity::Passthrough => "apple".to_owned(), + }); + let control_url = config.authority.as_deref().and_then(|authority| { + let authority = discovery::normalize_authority(authority); + (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) + }); + + let mut request = TailscaleLoginStartRequest { + account_name, + identity_name, + hostname: config.hostname.clone(), + control_url, + packet_socket: None, + }; + request.packet_socket = Some(packet_socket_path(&request).display().to_string()); + if request + .hostname + .as_deref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + request.hostname = Some(default_hostname(&request)); + } + request +} + +async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result { + let mut last_status = None; + for _ in 0..120 { + let status = helper.status().await?; + if status.running && !status.tailscale_ips.is_empty() { + return Ok(status); + } + if status.needs_login || status.auth_url.is_some() { + bail!("tailnet runtime requires a completed login before the tunnel can start"); + } + last_status = Some(status); + sleep(Duration::from_millis(250)).await; + } + + if let Some(status) = last_status { + bail!( + "tailnet helper never became ready (backend_state={})", + status.backend_state + ); + } + bail!("tailnet helper never produced a status update") +} + +fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig { + let mut search_domains = Vec::new(); + if let Some(suffix) = status.magic_dns_suffix.as_deref() { + let suffix = suffix.trim().trim_end_matches('.'); + if !suffix.is_empty() { + search_domains.push(suffix.to_owned()); + } + } + + ServerConfig { + address: status + .tailscale_ips + .iter() + .map(|ip| tailnet_cidr(ip)) + .collect(), + routes: tailnet_routes(), + dns_servers: tailnet_dns_servers(), + search_domains, + include_default_route: false, + name: status.self_dns_name.clone(), + mtu: Some(1280), + } +} + +fn tailnet_routes() -> Vec { + vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()] +} + +fn tailnet_dns_servers() -> Vec { + vec!["100.100.100.100".to_owned()] +} + +fn tailnet_cidr(ip: &str) -> String { + if ip.contains('/') { + return ip.to_owned(); + } + if ip.contains(':') { + format!("{ip}/128") + } else { + format!("{ip}/32") + } +} + +async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result { + let mut last_error = None; + let mut stream = None; + for _ in 0..50 { + match UnixStream::connect(&packet_socket).await { + Ok(connected) => { + stream = Some(connected); + break; + } + Err(err) => { + last_error = Some(err); + sleep(Duration::from_millis(100)).await; + } + } + } + let stream = if let Some(stream) = stream { + stream + } else { + return Err(last_error + .context("failed to connect to tailnet helper packet socket")? + .into()); + }; + + let (outbound_tx, outbound_rx) = mpsc::channel(128); + let (inbound_tx, _) = broadcast::channel(128); + let task = tokio::spawn(run_tailnet_socket_bridge( + stream, + outbound_rx, + inbound_tx.clone(), + )); + + Ok(TailnetPacketBridge { + outbound: outbound_tx, + inbound: inbound_tx, + task, + }) +} + +async fn run_tailnet_socket_bridge( + stream: UnixStream, + mut outbound_rx: mpsc::Receiver>, + inbound_tx: broadcast::Sender>, +) -> Result<()> { + let (mut reader, mut writer) = stream.into_split(); + + let inbound = tokio::spawn(async move { + loop { + let packet = read_packet_frame(&mut reader).await?; + tracing::debug!( + "tailnet packet bridge received {} bytes from helper socket", + packet.len() + ); + let _ = inbound_tx.send(packet); + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + let outbound = tokio::spawn(async move { + while let Some(packet) = outbound_rx.recv().await { + tracing::debug!( + "tailnet packet bridge writing {} bytes to helper socket", + packet.len() + ); + write_packet_frame(&mut writer, &packet).await?; + } + Result::<()>::Ok(()) + }); + + let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; + inbound_result?; + outbound_result?; + Ok(()) +} + +#[cfg(not(target_vendor = "apple"))] +async fn run_tailnet_tun_bridge( + tun_interface: Arc>>, + outbound_tx: mpsc::Sender>, + mut inbound_rx: broadcast::Receiver>, +) -> Result<()> { + let inbound_tun = tun_interface.clone(); + let inbound = tokio::spawn(async move { + loop { + let packet = match inbound_rx.recv().await { + Ok(packet) => packet, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let guard = inbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("tailnet tun interface unavailable"); + }; + tun.send(&packet) + .await + .context("failed to write tailnet packet to tun")?; + } + Result::<()>::Ok(()) + }); + + let outbound_tun = tun_interface.clone(); + let outbound = tokio::spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + let len = { + let guard = outbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("tailnet tun interface unavailable"); + }; + tun.recv(&mut buf) + .await + .context("failed to read packet from tailnet tun")? + }; + outbound_tx + .send(buf[..len].to_vec()) + .await + .context("failed to forward packet to tailnet helper")?; + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; + inbound_result?; + outbound_result?; + Ok(()) +} + +async fn read_packet_frame(reader: &mut R) -> Result> +where + R: AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + reader + .read_exact(&mut len_buf) + .await + .context("failed to read tailnet packet frame length")?; + let len = u32::from_be_bytes(len_buf) as usize; + let mut packet = vec![0u8; len]; + reader + .read_exact(&mut packet) + .await + .context("failed to read tailnet packet frame payload")?; + Ok(packet) +} + +async fn write_packet_frame(writer: &mut W, packet: &[u8]) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer + .write_all(&(packet.len() as u32).to_be_bytes()) + .await + .context("failed to write tailnet packet frame length")?; + writer + .write_all(packet) + .await + .context("failed to write tailnet packet frame payload")?; + writer + .flush() + .await + .context("failed to flush tailnet packet frame") +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +600,19 @@ mod tests { Vec::::new() ); } + + #[test] + fn tailnet_server_config_uses_host_prefixes() { + let status = TailscaleLoginStatus { + running: true, + tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()], + ..Default::default() + }; + let config = tailnet_server_config(&status); + assert_eq!( + config.address, + vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"] + ); + assert_eq!(config.mtu, Some(1280)); + } } diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 21e16ae..8a245ef 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -47,10 +47,16 @@ pub fn initialize() { #[cfg(target_os = "macos")] let subscriber = { - let system_log = Some(tracing_oslog::OsLogger::new( - "com.hackclub.burrow", - "tracing", - )); + // `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on + // current macOS. Keep logging on stderr by default and allow opt-in OSLog + // only when explicitly requested for local debugging. + let enable_oslog = matches!( + std::env::var("BURROW_ENABLE_OSLOG").as_deref(), + Ok("1" | "true" | "TRUE" | "yes" | "YES") + ); + let system_log = enable_oslog.then(|| { + tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing") + }); let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); Registry::default().with(stderr).with(system_log) }; diff --git a/proto/burrow.proto b/proto/burrow.proto index a590cb1..ed1f89e 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -5,6 +5,7 @@ import "google/protobuf/timestamp.proto"; service Tunnel { rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse); + rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket); rpc TunnelStart (Empty) returns (Empty); rpc TunnelStop (Empty) returns (Empty); rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); @@ -128,4 +129,12 @@ message TunnelStatusResponse { message TunnelConfigurationResponse { repeated string addresses = 1; int32 mtu = 2; + repeated string routes = 3; + repeated string dns_servers = 4; + repeated string search_domains = 5; + bool include_default_route = 6; +} + +message TunnelPacket { + bytes payload = 1; } From e40a947223e8dce37ca20665262d1d239d010301 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 20:52:52 -0700 Subject: [PATCH 2/3] Add forge-owned Namespace auth portal --- .../authentik-sync-namespace-portal-oidc.sh | 246 +++++ Scripts/check-forge-host.sh | 4 + ...c__response__response_serialization-4.snap | 2 +- burrow/src/main.rs | 38 +- burrow/src/namespace_portal.rs | 880 ++++++++++++++++++ flake.nix | 32 + nixos/README.md | 11 +- nixos/hosts/burrow-forge/default.nix | 12 +- nixos/modules/burrow-authentik.nix | 75 ++ nixos/modules/burrow-namespace-portal.nix | 126 +++ 10 files changed, 1403 insertions(+), 23 deletions(-) create mode 100644 Scripts/authentik-sync-namespace-portal-oidc.sh create mode 100644 burrow/src/namespace_portal.rs create mode 100644 nixos/modules/burrow-namespace-portal.nix diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh new file mode 100644 index 0000000..a62b0cf --- /dev/null +++ b/Scripts/authentik-sync-namespace-portal-oidc.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}" +application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}" +provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}" +template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}" +client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}" +redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[ + \"https://nsc.burrow.net/oauth/callback\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-namespace-portal-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME + AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME + AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET + AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL + AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: (if $client_secret == "" then "public" else "confidential" end), + client_id: $client_id, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + } + + (if $client_secret == "" then {} else {client_secret: $client_secret} end)' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Namespace portal OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: false, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Namespace portal OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index f4d646d..d824f6d 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,6 +84,7 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service + burrow-namespace-portal.service ) tailnet_services=( @@ -173,5 +174,8 @@ if command -v curl >/dev/null 2>&1; then curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true fi + if [[ "${EXPECT_NSC}" == "1" ]]; then + curl -fsS -o /dev/null -H 'Host: nsc.burrow.net' -w 'namespace_portal %{http_code}\n' http://127.0.0.1/ + fi fi EOF diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap index c40db25..68b4195 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/rpc/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 4ab7700..01591e7 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,6 +5,8 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +#[cfg(target_os = "linux")] +mod namespace_portal; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -60,6 +62,12 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, + #[cfg(target_os = "linux")] + /// Admin portal for forge-owned Namespace authentication and NSC token minting + NamespacePortal, + #[cfg(target_os = "linux")] + /// Refresh the forge-owned Namespace dev token once + NamespaceRefreshToken, /// Server Status ServerStatus, /// Tunnel Config @@ -283,9 +291,7 @@ async fn try_tailnet_discover(email: &str) -> Result<()> { let mut client = BurrowClient::from_uds().await?; let response = client .tailnet_client - .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { - email: email.to_owned(), - }) + .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() }) .await? .into_inner(); println!("Tailnet Discover Response: {:?}", response); @@ -370,13 +376,9 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul "tailnet ping received {} bytes from daemon packet stream", packet.payload.len() ); - if let Some(reply) = parse_icmp_echo_reply( - &packet.payload, - local_ip, - remote_ip, - identifier, - sequence, - )? { + if let Some(reply) = + parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)? + { break Ok::<_, anyhow::Error>(reply); } } @@ -464,8 +466,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R let egress_task = tokio::spawn(async move { while let Some(packet) = stack_stream.next().await { - let payload = - packet.context("failed to read outbound packet from userspace stack")?; + let payload = packet.context("failed to read outbound packet from userspace stack")?; log::debug!( "tailnet udp echo sending {} bytes into daemon packet stream", payload.len() @@ -484,9 +485,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R .send((message.as_bytes().to_vec(), local_addr, remote_addr)) .await .context("failed to send UDP echo probe into userspace stack")?; - log::debug!( - "tailnet udp echo probe queued from {local_addr} to {remote_addr}" - ); + log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}"); let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) .await @@ -516,7 +515,10 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R } #[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result { +fn select_tailnet_local_ip( + addresses: &[String], + remote_ip: std::net::IpAddr, +) -> Result { use anyhow::Context; let family_is_v4 = remote_ip.is_ipv4(); @@ -765,6 +767,10 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespacePortal => crate::namespace_portal::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespaceRefreshToken => crate::namespace_portal::refresh_token_once().await?, Commands::ServerStatus => try_serverstatus().await?, Commands::TunnelConfig => try_tun_config().await?, Commands::NetworkAdd(args) => { diff --git a/burrow/src/namespace_portal.rs b/burrow/src/namespace_portal.rs new file mode 100644 index 0000000..eb20775 --- /dev/null +++ b/burrow/src/namespace_portal.rs @@ -0,0 +1,880 @@ +#![cfg(target_os = "linux")] + +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use axum::{ + extract::{Query, State}, + http::{ + header::{COOKIE, LOCATION, SET_COOKIE}, + HeaderMap, HeaderValue, StatusCode, + }, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use reqwest::Url; +use ring::digest::{digest, SHA256}; +use serde::Deserialize; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::Mutex, +}; + +const SESSION_COOKIE: &str = "burrow_namespace_portal_session"; +const OIDC_TIMEOUT: Duration = Duration::from_secs(600); +const AUTH_CHECK_DURATION: &str = "10m"; + +#[derive(Clone, Debug)] +pub struct NamespacePortalConfig { + pub listen: String, + pub public_base_url: String, + pub oidc_discovery_url: String, + pub oidc_client_id: String, + pub oidc_client_secret: Option, + pub allowed_group: String, + pub nsc_bin: String, + pub nsc_state_dir: PathBuf, + pub token_output_path: PathBuf, +} + +impl Default for NamespacePortalConfig { + fn default() -> Self { + Self { + listen: "127.0.0.1:9080".to_owned(), + public_base_url: "https://nsc.burrow.net".to_owned(), + oidc_discovery_url: + "https://auth.burrow.net/application/o/namespace/.well-known/openid-configuration" + .to_owned(), + oidc_client_id: "nsc.burrow.net".to_owned(), + oidc_client_secret: None, + allowed_group: "burrow-admins".to_owned(), + nsc_bin: "nsc".to_owned(), + nsc_state_dir: PathBuf::from("/var/lib/burrow/namespace-portal/nsc"), + token_output_path: PathBuf::from("/var/lib/burrow/intake/forgejo_nsc_token.txt"), + } + } +} + +impl NamespacePortalConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_LISTEN") { + config.listen = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_BASE_URL") { + config.public_base_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL") { + config.oidc_discovery_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID") { + config.oidc_client_id = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET") { + let value = value.trim().to_owned(); + if !value.is_empty() { + config.oidc_client_secret = Some(value); + } + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP") { + config.allowed_group = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_BIN") { + config.nsc_bin = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR") { + config.nsc_state_dir = PathBuf::from(value); + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH") { + config.token_output_path = PathBuf::from(value); + } + config + } + + fn callback_url(&self) -> Result { + let mut url = Url::parse(&self.public_base_url) + .with_context(|| format!("invalid public base url {}", self.public_base_url))?; + url.set_path("/oauth/callback"); + url.set_query(None); + Ok(url.to_string()) + } + + fn ensure_paths(&self) -> Result<()> { + fs::create_dir_all(&self.nsc_state_dir).with_context(|| { + format!( + "failed to create namespace portal state dir {}", + self.nsc_state_dir.display() + ) + })?; + if let Some(parent) = self.token_output_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create token output dir {}", parent.display()) + })?; + } + Ok(()) + } +} + +#[derive(Clone)] +struct AppState { + config: NamespacePortalConfig, + client: reqwest::Client, + oidc: OidcDiscovery, + pending_logins: Arc>>, + sessions: Arc>>, + namespace: NamespaceSessionManager, +} + +#[derive(Clone, Debug, Deserialize)] +struct OidcDiscovery { + authorization_endpoint: String, + token_endpoint: String, + userinfo_endpoint: String, +} + +#[derive(Clone, Debug)] +struct PendingOidcLogin { + verifier: String, + expires_at: Instant, +} + +#[derive(Clone, Debug)] +struct PortalSession { + email: String, + display_name: String, + groups: Vec, + issued_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct OidcCallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Debug, Deserialize)] +struct UserInfo { + #[serde(default)] + email: String, + #[serde(default)] + name: String, + #[serde(default)] + preferred_username: String, + #[serde(default)] + groups: Vec, +} + +#[derive(Clone)] +struct NamespaceSessionManager { + config: NamespacePortalConfig, + state: Arc>, +} + +#[derive(Clone, Debug, Default)] +struct NamespacePortalState { + active_login: Option, + last_error: Option, +} + +#[derive(Clone, Debug)] +struct ActiveNamespaceLogin { + login_url: String, +} + +#[derive(Clone, Debug)] +struct NamespaceStatus { + linked: bool, + login_url: Option, + last_error: Option, + token_present: bool, +} + +pub async fn serve() -> Result<()> { + serve_with_config(NamespacePortalConfig::from_env()).await +} + +pub async fn refresh_token_once() -> Result<()> { + let config = NamespacePortalConfig::from_env(); + config.ensure_paths()?; + NamespaceSessionManager::new(config).refresh_token().await +} + +pub async fn serve_with_config(config: NamespacePortalConfig) -> Result<()> { + config.ensure_paths()?; + let oidc = fetch_oidc_discovery(&config.oidc_discovery_url).await?; + let listen = config.listen.clone(); + let app = Router::new() + .route("/", get(index)) + .route("/healthz", get(healthz)) + .route("/login", get(oidc_login)) + .route("/logout", post(logout)) + .route("/oauth/callback", get(oidc_callback)) + .route("/namespace/link/start", post(namespace_link_start)) + .route("/namespace/token/refresh", post(namespace_token_refresh)) + .with_state(AppState { + config: config.clone(), + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, + oidc, + pending_logins: Arc::new(Mutex::new(HashMap::new())), + sessions: Arc::new(Mutex::new(HashMap::new())), + namespace: NamespaceSessionManager::new(config), + }); + + let listener = tokio::net::TcpListener::bind(&listen).await?; + log::info!("Starting Namespace portal on {}", listen); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn fetch_oidc_discovery(discovery_url: &str) -> Result { + reqwest::Client::new() + .get(discovery_url) + .send() + .await + .with_context(|| format!("failed to fetch oidc discovery {}", discovery_url))? + .error_for_status() + .with_context(|| format!("oidc discovery returned non-success {}", discovery_url))? + .json() + .await + .context("failed to decode oidc discovery document") +} + +async fn healthz() -> impl IntoResponse { + StatusCode::OK +} + +async fn index(State(state): State, headers: HeaderMap) -> Response { + match current_session(&state, &headers).await { + Ok(Some(session)) => { + let namespace_status = match state.namespace.status().await { + Ok(status) => status, + Err(err) => NamespaceStatus { + linked: false, + login_url: None, + last_error: Some(err.to_string()), + token_present: false, + }, + }; + Html(render_dashboard(&state.config, &session, &namespace_status)).into_response() + } + Ok(None) => Html(render_login_page()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(render_error_page(&format!("session lookup failed: {err}"))), + ) + .into_response(), + } +} + +async fn oidc_login(State(state): State) -> Result { + prune_pending(&state).await; + let state_token = random_url_token(32); + let verifier = random_url_token(48); + let challenge = pkce_challenge(&verifier); + let callback_url = state.config.callback_url().map_err(internal_error)?; + + state.pending_logins.lock().await.insert( + state_token.clone(), + PendingOidcLogin { + verifier, + expires_at: Instant::now() + OIDC_TIMEOUT, + }, + ); + + let mut url = Url::parse(&state.oidc.authorization_endpoint).map_err(internal_error)?; + url.query_pairs_mut() + .append_pair("client_id", &state.config.oidc_client_id) + .append_pair("response_type", "code") + .append_pair("scope", "openid profile email groups") + .append_pair("redirect_uri", &callback_url) + .append_pair("state", &state_token) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); + Ok(Redirect::to(url.as_str())) +} + +async fn oidc_callback( + State(state): State, + Query(query): Query, +) -> Result { + if let Some(error) = query.error { + let description = query.error_description.unwrap_or_default(); + return Err(( + StatusCode::BAD_GATEWAY, + format!("oidc login failed: {error} {description}") + .trim() + .to_owned(), + )); + } + + let code = query + .code + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc code".to_owned()))?; + let state_token = query + .state + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc state".to_owned()))?; + + let verifier = { + let mut pending = state.pending_logins.lock().await; + let Some(login) = pending.remove(&state_token) else { + return Err((StatusCode::BAD_REQUEST, "unknown oidc state".to_owned())); + }; + if login.expires_at <= Instant::now() { + return Err((StatusCode::BAD_REQUEST, "expired oidc state".to_owned())); + } + login.verifier + }; + + let callback_url = state.config.callback_url().map_err(internal_error)?; + + let mut params = vec![ + ("grant_type", "authorization_code".to_owned()), + ("code", code), + ("client_id", state.config.oidc_client_id.clone()), + ("redirect_uri", callback_url), + ("code_verifier", verifier), + ]; + if let Some(secret) = &state.config.oidc_client_secret { + params.push(("client_secret", secret.clone())); + } + + let token = state + .client + .post(&state.oidc.token_endpoint) + .form(¶ms) + .send() + .await + .context("failed to exchange oidc code") + .map_err(internal_error)? + .error_for_status() + .context("oidc token endpoint returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc token response") + .map_err(internal_error)?; + + let userinfo = state + .client + .get(&state.oidc.userinfo_endpoint) + .bearer_auth(&token.access_token) + .send() + .await + .context("failed to fetch oidc userinfo") + .map_err(internal_error)? + .error_for_status() + .context("oidc userinfo returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc userinfo") + .map_err(internal_error)?; + + if !userinfo + .groups + .iter() + .any(|group| group == &state.config.allowed_group) + { + return Err(( + StatusCode::FORBIDDEN, + format!( + "authenticated user is not in required group {}", + state.config.allowed_group + ), + )); + } + + let session_id = random_url_token(32); + state.sessions.lock().await.insert( + session_id.clone(), + PortalSession { + email: userinfo.email.clone(), + display_name: display_name(&userinfo), + groups: userinfo.groups, + issued_at: Instant::now(), + }, + ); + + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&session_cookie_value(&session_id)).map_err(internal_error)?, + ); + Ok(response) +} + +async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + if let Some(session_id) = session_cookie(&headers) { + state.sessions.lock().await.remove(&session_id); + } + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_static( + "burrow_namespace_portal_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", + ), + ); + Ok(response) +} + +async fn namespace_link_start( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .start_login() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +async fn namespace_token_refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .refresh_token() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +fn render_login_page() -> String { + r#" + + + + + Burrow Namespace Portal + + + +
+

Burrow Namespace Portal

+

Authenticate with burrow.net to manage the dedicated Namespace session that backs Forgejo NSC automation.

+ Sign in with burrow.net +
+ +"# + .to_owned() +} + +fn render_dashboard( + config: &NamespacePortalConfig, + session: &PortalSession, + status: &NamespaceStatus, +) -> String { + let refresh = if status.login_url.is_some() { + r#""# + } else { + "" + }; + let login_action = if let Some(url) = &status.login_url { + format!( + "

Namespace Login In Progress

Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.

Open Namespace Login

", + escape_html(url) + ) + } else if status.linked { + "

Namespace Linked

The forge-owned NSC session is authenticated and ready to mint runner tokens.

".to_owned() + } else { + "

Namespace Not Linked

Start a server-side Namespace login. The portal will produce a Namespace URL, and completing that browser flow will authenticate the forge-owned NSC state directory.

".to_owned() + }; + let error = status + .last_error + .as_ref() + .map(|error| format!("

{}

", escape_html(error))) + .unwrap_or_default(); + let token_state = if status.token_present { + "present" + } else { + "missing" + }; + format!( + r#" + + + + + Burrow Namespace Portal + {refresh} + + + +
+
+
+

Burrow Namespace Portal

+

Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.

+
+
+
+ +
+
+
burrow.net identity
{identity}
+
required group
{group}
+
NSC token file
{token_path}
+
current token
{token_state}
+
+
+ + {login_action} + {error} + +
+

Actions

+
+
+
+
+
+
+ +"#, + refresh = refresh, + email = escape_html(&session.email), + identity = escape_html(&session.display_name), + group = escape_html(&config.allowed_group), + token_path = escape_html(&config.token_output_path.display().to_string()), + token_state = token_state, + login_action = login_action, + error = error, + ) +} + +fn render_error_page(message: &str) -> String { + format!( + r#"

Namespace Portal Error

{}

"#, + escape_html(message) + ) +} + +fn display_name(userinfo: &UserInfo) -> String { + if !userinfo.name.trim().is_empty() { + return userinfo.name.trim().to_owned(); + } + if !userinfo.preferred_username.trim().is_empty() { + return userinfo.preferred_username.trim().to_owned(); + } + userinfo.email.clone() +} + +async fn current_session(state: &AppState, headers: &HeaderMap) -> Result> { + let Some(session_id) = session_cookie(headers) else { + return Ok(None); + }; + Ok(state.sessions.lock().await.get(&session_id).cloned()) +} + +async fn require_session( + state: &AppState, + headers: &HeaderMap, +) -> Result { + current_session(state, headers) + .await + .map_err(internal_error)? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign-in required".to_owned())) +} + +async fn prune_pending(state: &AppState) { + state + .pending_logins + .lock() + .await + .retain(|_, login| login.expires_at > Instant::now()); +} + +fn session_cookie(headers: &HeaderMap) -> Option { + let cookie_header = headers.get(COOKIE)?.to_str().ok()?; + for pair in cookie_header.split(';') { + let mut parts = pair.trim().splitn(2, '='); + let name = parts.next()?.trim(); + let value = parts.next()?.trim(); + if name == SESSION_COOKIE && !value.is_empty() { + return Some(value.to_owned()); + } + } + None +} + +fn session_cookie_value(session_id: &str) -> String { + format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax") +} + +fn random_url_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = digest(&SHA256, verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.as_ref()) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} + +impl NamespaceSessionManager { + fn new(config: NamespacePortalConfig) -> Self { + Self { + config, + state: Arc::new(Mutex::new(NamespacePortalState::default())), + } + } + + async fn status(&self) -> Result { + let linked = self.check_login().await.is_ok(); + let state = self.state.lock().await.clone(); + let token_present = tokio::fs::metadata(&self.config.token_output_path) + .await + .is_ok(); + Ok(NamespaceStatus { + linked, + login_url: state.active_login.map(|login| login.login_url), + last_error: state.last_error, + token_present, + }) + } + + async fn start_login(&self) -> Result { + if self.check_login().await.is_ok() { + self.refresh_token().await?; + return Ok("already linked".to_owned()); + } + + { + let state = self.state.lock().await; + if let Some(active) = &state.active_login { + return Ok(active.login_url.clone()); + } + } + + self.config.ensure_paths()?; + let mut command = self.base_command(); + command + .args(["auth", "login", "--browser=false"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().context("failed to spawn nsc auth login")?; + let stdout = child + .stdout + .take() + .context("nsc auth login stdout was not piped")?; + let mut lines = BufReader::new(stdout).lines(); + let mut login_url = None; + while let Some(line) = lines.next_line().await? { + if let Some(candidate) = extract_namespace_login_url(&line) { + login_url = Some(candidate); + break; + } + } + + let login_url = login_url + .ok_or_else(|| anyhow!("nsc auth login did not emit a Namespace login URL"))?; + { + let mut state = self.state.lock().await; + state.active_login = Some(ActiveNamespaceLogin { login_url: login_url.clone() }); + state.last_error = None; + } + + let manager = self.clone(); + tokio::spawn(async move { + let outcome = child.wait().await; + let mut state = manager.state.lock().await; + state.active_login = None; + match outcome { + Ok(status) if status.success() => { + drop(state); + if let Err(err) = manager.refresh_token().await { + manager.state.lock().await.last_error = Some(format!( + "Namespace login finished, but token refresh failed: {err}" + )); + } + } + Ok(status) => { + state.last_error = Some(format!( + "Namespace login command exited with status {}", + status + )); + } + Err(err) => { + state.last_error = Some(format!("Namespace login command failed: {err}")); + } + } + }); + + Ok(login_url) + } + + async fn refresh_token(&self) -> Result<()> { + self.config.ensure_paths()?; + self.check_login().await?; + let mut command = self.base_command(); + command.args([ + "auth", + "generate-dev-token", + "--output_to", + self.config + .token_output_path + .to_str() + .ok_or_else(|| anyhow!("token output path is not valid UTF-8"))?, + ]); + let output = command + .output() + .await + .context("failed to run nsc token refresh")?; + if !output.status.success() { + bail!( + "nsc auth generate-dev-token failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + #[cfg(target_family = "unix")] + { + use std::os::unix::fs::PermissionsExt; + + let perms = fs::Permissions::from_mode(0o440); + fs::set_permissions(&self.config.token_output_path, perms).with_context(|| { + format!( + "failed to set permissions on {}", + self.config.token_output_path.display() + ) + })?; + } + self.state.lock().await.last_error = None; + Ok(()) + } + + async fn check_login(&self) -> Result<()> { + let mut command = self.base_command(); + command.args(["auth", "check-login", "--duration", AUTH_CHECK_DURATION]); + let output = command + .output() + .await + .context("failed to run nsc auth check-login")?; + if output.status.success() { + return Ok(()); + } + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + + fn base_command(&self) -> Command { + let mut command = Command::new(&self.config.nsc_bin); + let home = self.config.nsc_state_dir.join("home"); + let data = self.config.nsc_state_dir.join("data"); + let cache = self.config.nsc_state_dir.join("cache"); + let config = self.config.nsc_state_dir.join("config"); + let _ = fs::create_dir_all(&home); + let _ = fs::create_dir_all(&data); + let _ = fs::create_dir_all(&cache); + let _ = fs::create_dir_all(&config); + command + .env("HOME", &home) + .env("XDG_DATA_HOME", &data) + .env("XDG_CACHE_HOME", &cache) + .env("XDG_CONFIG_HOME", &config); + command + } +} + +fn extract_namespace_login_url(line: &str) -> Option { + line.split_whitespace() + .find(|token| token.starts_with("https://")) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_namespace_login_url_from_output() { + let url = extract_namespace_login_url( + " https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o", + ); + assert_eq!( + url.as_deref(), + Some("https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o") + ); + } + + #[test] + fn pkce_challenge_is_stable() { + assert_eq!( + pkce_challenge("hello"), + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" + ); + } + + #[test] + fn parses_session_cookie() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static( + "something=else; burrow_namespace_portal_session=session123; another=value", + ), + ); + assert_eq!(session_cookie(&headers).as_deref(), Some("session123")); + } +} diff --git a/flake.nix b/flake.nix index 1e91dcc..0bba0b1 100644 --- a/flake.nix +++ b/flake.nix @@ -94,6 +94,7 @@ pkgs.stdenvNoCC.mkDerivation { pname = "nsc"; inherit version src; + meta.mainProgram = "nsc"; dontConfigure = true; dontBuild = true; unpackPhase = '' @@ -144,6 +145,35 @@ subPackages = [ "./cmd/forgejo-nsc-autoscaler" ]; vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; }; + burrowSrc = lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + p = toString path; + name = builtins.baseNameOf path; + hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p; + in + !(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result"); + }; + burrowPkg = pkgs.rustPlatform.buildRustPackage { + pname = "burrow"; + version = "0.1.0"; + src = burrowSrc; + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = { + "tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I="; + }; + }; + cargoBuildFlags = [ + "-p" + "burrow" + "--bin" + "burrow" + ]; + nativeBuildInputs = [ pkgs.protobuf ]; + meta.mainProgram = "burrow"; + }; in { devShells.default = pkgs.mkShell { @@ -171,6 +201,7 @@ packages = { agenix = agenix.packages.${system}.agenix; + burrow = burrowPkg; hcloud-upload-image = hcloudUploadImagePkg; forgejo-nsc-dispatcher = forgejoNscDispatcher; forgejo-nsc-autoscaler = forgejoNscAutoscaler; @@ -183,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-namespace-portal = import ./nixos/modules/burrow-namespace-portal.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; diff --git a/nixos/README.md b/nixos/README.md index c79d8ce..13fe76d 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,6 +12,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC +- `modules/burrow-namespace-portal.nix`: small admin portal for forge-owned Namespace authentication and NSC token refresh - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key @@ -24,6 +25,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host - `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists - `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host +- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net` ## Intended Flow @@ -33,10 +35,11 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. -8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. -9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. -10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. +7. Visit `https://nsc.burrow.net/` as a Burrow admin to link the forge-owned Namespace session and rotate `/var/lib/burrow/intake/forgejo_nsc_token.txt` without relying on a personal local `nsc` login. +8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. +11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. ## Current Constraints diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 75b76d4..aecdbfa 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,7 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale + self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -89,8 +90,8 @@ in }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net ''; services.burrow.forge = { @@ -140,4 +141,11 @@ in enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; + + services.burrow.namespacePortal = { + enable = true; + domain = "nsc.burrow.net"; + baseUrl = "https://nsc.burrow.net"; + adminGroup = contributors.groups.admins; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 1616b36..e2ee18d 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; @@ -138,6 +139,30 @@ in description = "Authentik application slug for Tailscale custom OIDC sign-in."; }; + namespacePortalDomain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Burrow Namespace portal."; + }; + + namespacePortalProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik application slug for the Namespace portal."; + }; + + namespacePortalClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Client ID Authentik should present to the Namespace portal."; + }; + + namespacePortalClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local file containing the Authentik Namespace portal OIDC client secret."; + }; + tailscaleClientId = lib.mkOption { type = lib.types.str; default = "tailscale.burrow.net"; @@ -708,6 +733,56 @@ EOF ''; }; + systemd.services.burrow-authentik-namespace-portal-oidc = { + description = "Reconcile the Burrow Authentik Namespace portal OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = + [ + namespacePortalOidcSyncScript + cfg.envFile + ] + ++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId} + ${lib.optionalString (cfg.namespacePortalClientSecretFile != null) '' + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})" + ''} + export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/ + export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]' + + ${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-namespace-portal.nix b/nixos/modules/burrow-namespace-portal.nix new file mode 100644 index 0000000..2eb7b24 --- /dev/null +++ b/nixos/modules/burrow-namespace-portal.nix @@ -0,0 +1,126 @@ +{ config, lib, pkgs, self, ... }: + +let + cfg = config.services.burrow.namespacePortal; + burrowExe = lib.getExe self.packages.${pkgs.system}.burrow; + nscExe = lib.getExe self.packages.${pkgs.system}.nsc; +in +{ + options.services.burrow.namespacePortal = { + enable = lib.mkEnableOption "the Burrow Namespace authentication portal"; + + domain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Namespace portal."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9080; + description = "Local listen port for the Namespace portal."; + }; + + baseUrl = lib.mkOption { + type = lib.types.str; + default = "https://nsc.burrow.net"; + description = "Public base URL for redirects."; + }; + + oidcProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik provider slug used for the portal."; + }; + + oidcClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "OIDC client ID used by the portal."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local OIDC client secret for the portal."; + }; + + adminGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "Authentik group required to access the portal."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/namespace-portal"; + description = "Persistent state directory for the portal-owned NSC session."; + }; + + tokenOutputPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + description = "Path where refreshed NSC tokens should be written."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.services.forgejo-nsc.enable; + message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable"; + } + ]; + + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -" + "d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -" + ]; + + systemd.services.burrow-namespace-portal = { + description = "Burrow Namespace authentication portal"; + after = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wants = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + self.packages.${pkgs.system}.burrow + self.packages.${pkgs.system}.nsc + pkgs.coreutils + ]; + serviceConfig = { + Type = "simple"; + User = "forgejo-nsc"; + Group = "forgejo-nsc"; + WorkingDirectory = cfg.stateDir; + Restart = "on-failure"; + RestartSec = "2s"; + }; + script = '' + set -euo pipefail + export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port} + export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl} + export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"} + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId} + export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup} + export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe} + export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"} + export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath} + ${lib.optionalString (cfg.oidcClientSecretFile != null) '' + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" + ''} + exec ${burrowExe} namespace-portal + ''; + }; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + }; +} From 70607e874ce710bb05823f9206735c6fe6ea259a Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 23:08:23 -0700 Subject: [PATCH 3/3] Move forgejo-nsc credentials into agenix --- .../authentik-sync-namespace-portal-oidc.sh | 246 ----- Scripts/check-forge-host.sh | 12 +- Scripts/seal-forgejo-nsc-secrets.sh | 112 +++ Scripts/sync-forgejo-nsc-config.sh | 133 +-- burrow/src/main.rs | 12 - burrow/src/namespace_portal.rs | 880 ------------------ flake.nix | 2 - nixos/README.md | 15 +- nixos/hosts/burrow-forge/default.nix | 36 +- nixos/modules/burrow-authentik.nix | 75 -- nixos/modules/burrow-namespace-portal.nix | 126 --- secrets.nix | 3 + .../infra/forgejo-nsc-autoscaler-config.age | Bin 0 -> 1264 bytes .../infra/forgejo-nsc-dispatcher-config.age | Bin 0 -> 1127 bytes secrets/infra/forgejo-nsc-token.age | 15 + 15 files changed, 172 insertions(+), 1495 deletions(-) delete mode 100644 Scripts/authentik-sync-namespace-portal-oidc.sh create mode 100755 Scripts/seal-forgejo-nsc-secrets.sh delete mode 100644 burrow/src/namespace_portal.rs delete mode 100644 nixos/modules/burrow-namespace-portal.nix create mode 100644 secrets/infra/forgejo-nsc-autoscaler-config.age create mode 100644 secrets/infra/forgejo-nsc-dispatcher-config.age create mode 100644 secrets/infra/forgejo-nsc-token.age diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh deleted file mode 100644 index a62b0cf..0000000 --- a/Scripts/authentik-sync-namespace-portal-oidc.sh +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" -bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" -application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}" -application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}" -provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}" -template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}" -client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}" -redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[ - \"https://nsc.burrow.net/oauth/callback\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-namespace-portal-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG - AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME - AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME - AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG - AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID - AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET - AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL - AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -if [[ -z "$bootstrap_token" ]]; then - echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 - exit 1 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then - echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 - exit 1 -fi - -api() { - local method="$1" - local path="$2" - local data="${3:-}" - - if [[ -n "$data" ]]; then - curl -fsS \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - -H "Content-Type: application/json" \ - -d "$data" \ - "${authentik_url}${path}" - else - curl -fsS \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - "${authentik_url}${path}" - fi -} - -api_with_status() { - local method="$1" - local path="$2" - local data="${3:-}" - local response_file status - - response_file="$(mktemp)" - trap 'rm -f "$response_file"' RETURN - - if [[ -n "$data" ]]; then - status="$( - curl -sS \ - -o "$response_file" \ - -w '%{http_code}' \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - -H "Content-Type: application/json" \ - -d "$data" \ - "${authentik_url}${path}" - )" - else - status="$( - curl -sS \ - -o "$response_file" \ - -w '%{http_code}' \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - "${authentik_url}${path}" - )" - fi - - printf '%s\n' "$status" - cat "$response_file" -} - -wait_for_authentik() { - for _ in $(seq 1 90); do - if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - - echo "error: Authentik did not become ready at ${authentik_url}" >&2 - exit 1 -} - -wait_for_authentik - -template_provider="$( - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ - | head -n1 -)" - -if [[ -z "$template_provider" ]]; then - echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 - exit 1 -fi - -authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" -invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" -property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" -signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" - -provider_payload="$( - jq -n \ - --arg name "$provider_name" \ - --arg authorization_flow "$authorization_flow" \ - --arg invalidation_flow "$invalidation_flow" \ - --arg client_id "$client_id" \ - --arg client_secret "$client_secret" \ - --arg signing_key "$signing_key" \ - --argjson property_mappings "$property_mappings" \ - --argjson redirect_uris "$redirect_uris_json" \ - '{ - name: $name, - authorization_flow: $authorization_flow, - invalidation_flow: $invalidation_flow, - client_type: (if $client_secret == "" then "public" else "confidential" end), - client_id: $client_id, - include_claims_in_id_token: true, - redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), - property_mappings: $property_mappings, - signing_key: $signing_key, - issuer_mode: "per_provider", - sub_mode: "hashed_user_id" - } - + (if $client_secret == "" then {} else {client_secret: $client_secret} end)' -)" - -existing_provider="$( - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -c \ - --arg application_slug "$application_slug" \ - --arg provider_name "$provider_name" \ - '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ - | head -n1 -)" - -if [[ -n "$existing_provider" ]]; then - provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" - api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null -else - provider_pk="$( - api POST "/api/v3/providers/oauth2/" "$provider_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "${provider_pk:-}" ]]; then - echo "error: Namespace portal OIDC provider did not return a primary key" >&2 - exit 1 -fi - -application_payload="$( - jq -n \ - --arg name "$application_name" \ - --arg slug "$application_slug" \ - --arg provider "$provider_pk" \ - --arg launch_url "$launch_url" \ - '{ - name: $name, - slug: $slug, - provider: ($provider | tonumber), - meta_launch_url: $launch_url, - open_in_new_tab: false, - policy_engine_mode: "any" - }' -)" - -existing_application="$( - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ - | head -n1 -)" - -if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" -else - create_application_result="$( - api_with_status POST "/api/v3/core/applications/" "$application_payload" - )" - create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" - create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" - - if [[ "$create_application_status" =~ ^20[01]$ ]]; then - application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" - elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' - (.slug // [] | index("Application with this slug already exists.")) != null - or (.provider // [] | index("Application with this provider already exists.")) != null - ' >/dev/null; then - application_pk="existing-duplicate" - else - printf '%s\n' "$create_application_body" >&2 - echo "error: could not reconcile Authentik application ${application_slug}" >&2 - exit 1 - fi -fi - -if [[ -z "${application_pk:-}" ]]; then - echo "error: Namespace portal OIDC application did not return a primary key" >&2 - exit 1 -fi - -for _ in $(seq 1 30); do - if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then - echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index d824f6d..0f79bf4 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,7 +84,6 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - burrow-namespace-portal.service ) tailnet_services=( @@ -165,6 +164,14 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then test -s /run/agenix/burrowHeadscaleOidcClientSecret fi +if [[ "${EXPECT_NSC}" == "1" ]]; then + echo "== agenix-nsc ==" + ls -l /run/agenix || true + test -s /run/agenix/burrowForgejoNscToken + test -s /run/agenix/burrowForgejoNscDispatcherConfig + test -s /run/agenix/burrowForgejoNscAutoscalerConfig +fi + if command -v curl >/dev/null 2>&1; then echo "== http-local ==" curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login @@ -174,8 +181,5 @@ if command -v curl >/dev/null 2>&1; then curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true fi - if [[ "${EXPECT_NSC}" == "1" ]]; then - curl -fsS -o /dev/null -H 'Host: nsc.burrow.net' -w 'namespace_portal %{http_code}\n' http://127.0.0.1/ - fi fi EOF diff --git a/Scripts/seal-forgejo-nsc-secrets.sh b/Scripts/seal-forgejo-nsc-secrets.sh new file mode 100755 index 0000000..a6b3918 --- /dev/null +++ b/Scripts/seal-forgejo-nsc-secrets.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/seal-forgejo-nsc-secrets.sh [options] + +Encrypt Burrow forgejo-nsc runtime inputs from intake/ into the agenix secrets +consumed by burrow-forge. + +Options: + --provision Re-render the local intake files before sealing. + --host SSH target forwarded to provision-forgejo-nsc.sh. + --ssh-key SSH private key forwarded to provision-forgejo-nsc.sh. + --nsc-bin Override the nsc binary for provisioning. + -h, --help Show this help text. +EOF +} + +PROVISION=0 +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +NSC_BIN="${NSC_BIN:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --provision) + PROVISION=1 + shift + ;; + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --nsc-bin) + NSC_BIN="${2:?missing value for --nsc-bin}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd age +require_cmd nix +require_cmd python3 + +if [[ "${PROVISION}" -eq 1 ]]; then + provision_args=(--host "${HOST}" --ssh-key "${SSH_KEY}") + if [[ -n "${NSC_BIN}" ]]; then + provision_args+=(--nsc-bin "${NSC_BIN}") + fi + "${SCRIPT_DIR}/provision-forgejo-nsc.sh" "${provision_args[@]}" +fi + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +seal_secret() { + local target="$1" + local source_path="$2" + recipients_file="${tmpdir}/$(basename "${target}").recipients" + if [[ ! -s "${source_path}" ]]; then + echo "required runtime input missing or empty: ${source_path}" >&2 + exit 1 + fi + nix eval --impure --json --expr "let s = import ${REPO_ROOT}/secrets.nix; in s.\"${target}\".publicKeys" \ + | python3 -c 'import json, sys; [print(item) for item in json.load(sys.stdin)]' \ + > "${recipients_file}" + + age -R "${recipients_file}" -o "${REPO_ROOT}/${target}" "${source_path}" +} + +seal_secret "secrets/infra/forgejo-nsc-token.age" "${REPO_ROOT}/intake/forgejo_nsc_token.txt" +seal_secret "secrets/infra/forgejo-nsc-dispatcher-config.age" "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" +seal_secret "secrets/infra/forgejo-nsc-autoscaler-config.age" "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" + +chmod 600 \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" + +echo "Sealed forgejo-nsc runtime inputs into:" +printf ' %s\n' \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" +echo "Deploy burrow-forge to apply the new CI credentials." diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh index 77581f8..2ce7114 100755 --- a/Scripts/sync-forgejo-nsc-config.sh +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -1,132 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -usage() { - cat <<'EOF' -Usage: Scripts/sync-forgejo-nsc-config.sh [options] - -Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and -restart the dispatcher/autoscaler units. - -Options: - --host SSH target (default: root@git.burrow.net) - --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) - --rotate-pat Re-render the intake files before syncing. - --no-restart Copy files only. - -h, --help Show this help text. -EOF -} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" -KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" -ROTATE_PAT=0 -NO_RESTART=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --host) - HOST="${2:?missing value for --host}" - shift 2 - ;; - --ssh-key) - SSH_KEY="${2:?missing value for --ssh-key}" - shift 2 - ;; - --rotate-pat) - ROTATE_PAT=1 - shift - ;; - --no-restart) - NO_RESTART=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown option: $1" >&2 - usage >&2 - exit 64 - ;; - esac -done - -mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" - -burrow_require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - -burrow_require_cmd ssh -burrow_require_cmd scp - -if [[ ! -f "${SSH_KEY}" ]]; then - echo "forge SSH key not found: ${SSH_KEY}" >&2 - exit 1 -fi - -if [[ "${ROTATE_PAT}" -eq 1 ]]; then - "${SCRIPT_DIR}/provision-forgejo-nsc.sh" --host "${HOST}" --ssh-key "${SSH_KEY}" -fi - -token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" -dispatcher_file="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" -autoscaler_file="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" - -for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do - if [[ ! -s "${path}" ]]; then - echo "required runtime input missing or empty: ${path}" >&2 - exit 1 - fi -done - -ssh_opts=( - -i "${SSH_KEY}" - -o IdentitiesOnly=yes - -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" - -o StrictHostKeyChecking=accept-new -) - -remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" -cleanup() { - if [[ -n "${remote_tmp:-}" ]]; then - ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -scp "${ssh_opts[@]}" \ - "${token_file}" \ - "${dispatcher_file}" \ - "${autoscaler_file}" \ - "${HOST}:${remote_tmp}/" - -ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - install -d -m 0755 /var/lib/burrow/intake - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${token_file}")' /var/lib/burrow/intake/forgejo_nsc_token.txt - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${dispatcher_file}")' /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${autoscaler_file}")' /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml -" - -if [[ "${NO_RESTART}" -eq 0 ]]; then - ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - systemctl restart forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - systemctl is-active forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - ls -l \ - /var/lib/burrow/intake/forgejo_nsc_token.txt \ - /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml \ - /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml - " -fi - -echo "forgejo-nsc runtime sync complete (host=${HOST}, restarted=$((1 - NO_RESTART)))." +echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2 +echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2 +echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2 +exit 1 diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 01591e7..cfa2085 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,8 +5,6 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; -#[cfg(target_os = "linux")] -mod namespace_portal; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -62,12 +60,6 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, - #[cfg(target_os = "linux")] - /// Admin portal for forge-owned Namespace authentication and NSC token minting - NamespacePortal, - #[cfg(target_os = "linux")] - /// Refresh the forge-owned Namespace dev token once - NamespaceRefreshToken, /// Server Status ServerStatus, /// Tunnel Config @@ -767,10 +759,6 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, - #[cfg(target_os = "linux")] - Commands::NamespacePortal => crate::namespace_portal::serve().await?, - #[cfg(target_os = "linux")] - Commands::NamespaceRefreshToken => crate::namespace_portal::refresh_token_once().await?, Commands::ServerStatus => try_serverstatus().await?, Commands::TunnelConfig => try_tun_config().await?, Commands::NetworkAdd(args) => { diff --git a/burrow/src/namespace_portal.rs b/burrow/src/namespace_portal.rs deleted file mode 100644 index eb20775..0000000 --- a/burrow/src/namespace_portal.rs +++ /dev/null @@ -1,880 +0,0 @@ -#![cfg(target_os = "linux")] - -use std::{ - collections::HashMap, - env, fs, - path::{Path, PathBuf}, - process::Stdio, - sync::Arc, - time::{Duration, Instant}, -}; - -use anyhow::{anyhow, bail, Context, Result}; -use axum::{ - extract::{Query, State}, - http::{ - header::{COOKIE, LOCATION, SET_COOKIE}, - HeaderMap, HeaderValue, StatusCode, - }, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, - Router, -}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use rand::RngCore; -use reqwest::Url; -use ring::digest::{digest, SHA256}; -use serde::Deserialize; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command, - sync::Mutex, -}; - -const SESSION_COOKIE: &str = "burrow_namespace_portal_session"; -const OIDC_TIMEOUT: Duration = Duration::from_secs(600); -const AUTH_CHECK_DURATION: &str = "10m"; - -#[derive(Clone, Debug)] -pub struct NamespacePortalConfig { - pub listen: String, - pub public_base_url: String, - pub oidc_discovery_url: String, - pub oidc_client_id: String, - pub oidc_client_secret: Option, - pub allowed_group: String, - pub nsc_bin: String, - pub nsc_state_dir: PathBuf, - pub token_output_path: PathBuf, -} - -impl Default for NamespacePortalConfig { - fn default() -> Self { - Self { - listen: "127.0.0.1:9080".to_owned(), - public_base_url: "https://nsc.burrow.net".to_owned(), - oidc_discovery_url: - "https://auth.burrow.net/application/o/namespace/.well-known/openid-configuration" - .to_owned(), - oidc_client_id: "nsc.burrow.net".to_owned(), - oidc_client_secret: None, - allowed_group: "burrow-admins".to_owned(), - nsc_bin: "nsc".to_owned(), - nsc_state_dir: PathBuf::from("/var/lib/burrow/namespace-portal/nsc"), - token_output_path: PathBuf::from("/var/lib/burrow/intake/forgejo_nsc_token.txt"), - } - } -} - -impl NamespacePortalConfig { - pub fn from_env() -> Self { - let mut config = Self::default(); - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_LISTEN") { - config.listen = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_BASE_URL") { - config.public_base_url = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL") { - config.oidc_discovery_url = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID") { - config.oidc_client_id = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET") { - let value = value.trim().to_owned(); - if !value.is_empty() { - config.oidc_client_secret = Some(value); - } - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP") { - config.allowed_group = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_BIN") { - config.nsc_bin = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR") { - config.nsc_state_dir = PathBuf::from(value); - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH") { - config.token_output_path = PathBuf::from(value); - } - config - } - - fn callback_url(&self) -> Result { - let mut url = Url::parse(&self.public_base_url) - .with_context(|| format!("invalid public base url {}", self.public_base_url))?; - url.set_path("/oauth/callback"); - url.set_query(None); - Ok(url.to_string()) - } - - fn ensure_paths(&self) -> Result<()> { - fs::create_dir_all(&self.nsc_state_dir).with_context(|| { - format!( - "failed to create namespace portal state dir {}", - self.nsc_state_dir.display() - ) - })?; - if let Some(parent) = self.token_output_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("failed to create token output dir {}", parent.display()) - })?; - } - Ok(()) - } -} - -#[derive(Clone)] -struct AppState { - config: NamespacePortalConfig, - client: reqwest::Client, - oidc: OidcDiscovery, - pending_logins: Arc>>, - sessions: Arc>>, - namespace: NamespaceSessionManager, -} - -#[derive(Clone, Debug, Deserialize)] -struct OidcDiscovery { - authorization_endpoint: String, - token_endpoint: String, - userinfo_endpoint: String, -} - -#[derive(Clone, Debug)] -struct PendingOidcLogin { - verifier: String, - expires_at: Instant, -} - -#[derive(Clone, Debug)] -struct PortalSession { - email: String, - display_name: String, - groups: Vec, - issued_at: Instant, -} - -#[derive(Debug, Deserialize)] -struct OidcCallbackQuery { - code: Option, - state: Option, - error: Option, - error_description: Option, -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, -} - -#[derive(Debug, Deserialize)] -struct UserInfo { - #[serde(default)] - email: String, - #[serde(default)] - name: String, - #[serde(default)] - preferred_username: String, - #[serde(default)] - groups: Vec, -} - -#[derive(Clone)] -struct NamespaceSessionManager { - config: NamespacePortalConfig, - state: Arc>, -} - -#[derive(Clone, Debug, Default)] -struct NamespacePortalState { - active_login: Option, - last_error: Option, -} - -#[derive(Clone, Debug)] -struct ActiveNamespaceLogin { - login_url: String, -} - -#[derive(Clone, Debug)] -struct NamespaceStatus { - linked: bool, - login_url: Option, - last_error: Option, - token_present: bool, -} - -pub async fn serve() -> Result<()> { - serve_with_config(NamespacePortalConfig::from_env()).await -} - -pub async fn refresh_token_once() -> Result<()> { - let config = NamespacePortalConfig::from_env(); - config.ensure_paths()?; - NamespaceSessionManager::new(config).refresh_token().await -} - -pub async fn serve_with_config(config: NamespacePortalConfig) -> Result<()> { - config.ensure_paths()?; - let oidc = fetch_oidc_discovery(&config.oidc_discovery_url).await?; - let listen = config.listen.clone(); - let app = Router::new() - .route("/", get(index)) - .route("/healthz", get(healthz)) - .route("/login", get(oidc_login)) - .route("/logout", post(logout)) - .route("/oauth/callback", get(oidc_callback)) - .route("/namespace/link/start", post(namespace_link_start)) - .route("/namespace/token/refresh", post(namespace_token_refresh)) - .with_state(AppState { - config: config.clone(), - client: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build()?, - oidc, - pending_logins: Arc::new(Mutex::new(HashMap::new())), - sessions: Arc::new(Mutex::new(HashMap::new())), - namespace: NamespaceSessionManager::new(config), - }); - - let listener = tokio::net::TcpListener::bind(&listen).await?; - log::info!("Starting Namespace portal on {}", listen); - axum::serve(listener, app).await?; - Ok(()) -} - -async fn fetch_oidc_discovery(discovery_url: &str) -> Result { - reqwest::Client::new() - .get(discovery_url) - .send() - .await - .with_context(|| format!("failed to fetch oidc discovery {}", discovery_url))? - .error_for_status() - .with_context(|| format!("oidc discovery returned non-success {}", discovery_url))? - .json() - .await - .context("failed to decode oidc discovery document") -} - -async fn healthz() -> impl IntoResponse { - StatusCode::OK -} - -async fn index(State(state): State, headers: HeaderMap) -> Response { - match current_session(&state, &headers).await { - Ok(Some(session)) => { - let namespace_status = match state.namespace.status().await { - Ok(status) => status, - Err(err) => NamespaceStatus { - linked: false, - login_url: None, - last_error: Some(err.to_string()), - token_present: false, - }, - }; - Html(render_dashboard(&state.config, &session, &namespace_status)).into_response() - } - Ok(None) => Html(render_login_page()).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Html(render_error_page(&format!("session lookup failed: {err}"))), - ) - .into_response(), - } -} - -async fn oidc_login(State(state): State) -> Result { - prune_pending(&state).await; - let state_token = random_url_token(32); - let verifier = random_url_token(48); - let challenge = pkce_challenge(&verifier); - let callback_url = state.config.callback_url().map_err(internal_error)?; - - state.pending_logins.lock().await.insert( - state_token.clone(), - PendingOidcLogin { - verifier, - expires_at: Instant::now() + OIDC_TIMEOUT, - }, - ); - - let mut url = Url::parse(&state.oidc.authorization_endpoint).map_err(internal_error)?; - url.query_pairs_mut() - .append_pair("client_id", &state.config.oidc_client_id) - .append_pair("response_type", "code") - .append_pair("scope", "openid profile email groups") - .append_pair("redirect_uri", &callback_url) - .append_pair("state", &state_token) - .append_pair("code_challenge", &challenge) - .append_pair("code_challenge_method", "S256"); - Ok(Redirect::to(url.as_str())) -} - -async fn oidc_callback( - State(state): State, - Query(query): Query, -) -> Result { - if let Some(error) = query.error { - let description = query.error_description.unwrap_or_default(); - return Err(( - StatusCode::BAD_GATEWAY, - format!("oidc login failed: {error} {description}") - .trim() - .to_owned(), - )); - } - - let code = query - .code - .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc code".to_owned()))?; - let state_token = query - .state - .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc state".to_owned()))?; - - let verifier = { - let mut pending = state.pending_logins.lock().await; - let Some(login) = pending.remove(&state_token) else { - return Err((StatusCode::BAD_REQUEST, "unknown oidc state".to_owned())); - }; - if login.expires_at <= Instant::now() { - return Err((StatusCode::BAD_REQUEST, "expired oidc state".to_owned())); - } - login.verifier - }; - - let callback_url = state.config.callback_url().map_err(internal_error)?; - - let mut params = vec![ - ("grant_type", "authorization_code".to_owned()), - ("code", code), - ("client_id", state.config.oidc_client_id.clone()), - ("redirect_uri", callback_url), - ("code_verifier", verifier), - ]; - if let Some(secret) = &state.config.oidc_client_secret { - params.push(("client_secret", secret.clone())); - } - - let token = state - .client - .post(&state.oidc.token_endpoint) - .form(¶ms) - .send() - .await - .context("failed to exchange oidc code") - .map_err(internal_error)? - .error_for_status() - .context("oidc token endpoint returned non-success") - .map_err(internal_error)? - .json::() - .await - .context("failed to decode oidc token response") - .map_err(internal_error)?; - - let userinfo = state - .client - .get(&state.oidc.userinfo_endpoint) - .bearer_auth(&token.access_token) - .send() - .await - .context("failed to fetch oidc userinfo") - .map_err(internal_error)? - .error_for_status() - .context("oidc userinfo returned non-success") - .map_err(internal_error)? - .json::() - .await - .context("failed to decode oidc userinfo") - .map_err(internal_error)?; - - if !userinfo - .groups - .iter() - .any(|group| group == &state.config.allowed_group) - { - return Err(( - StatusCode::FORBIDDEN, - format!( - "authenticated user is not in required group {}", - state.config.allowed_group - ), - )); - } - - let session_id = random_url_token(32); - state.sessions.lock().await.insert( - session_id.clone(), - PortalSession { - email: userinfo.email.clone(), - display_name: display_name(&userinfo), - groups: userinfo.groups, - issued_at: Instant::now(), - }, - ); - - let mut response = Redirect::to("/").into_response(); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&session_cookie_value(&session_id)).map_err(internal_error)?, - ); - Ok(response) -} - -async fn logout( - State(state): State, - headers: HeaderMap, -) -> Result { - if let Some(session_id) = session_cookie(&headers) { - state.sessions.lock().await.remove(&session_id); - } - let mut response = Redirect::to("/").into_response(); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_static( - "burrow_namespace_portal_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", - ), - ); - Ok(response) -} - -async fn namespace_link_start( - State(state): State, - headers: HeaderMap, -) -> Result { - require_session(&state, &headers).await?; - state - .namespace - .start_login() - .await - .map_err(internal_error)?; - Ok(Redirect::to("/")) -} - -async fn namespace_token_refresh( - State(state): State, - headers: HeaderMap, -) -> Result { - require_session(&state, &headers).await?; - state - .namespace - .refresh_token() - .await - .map_err(internal_error)?; - Ok(Redirect::to("/")) -} - -fn render_login_page() -> String { - r#" - - - - - Burrow Namespace Portal - - - -
-

Burrow Namespace Portal

-

Authenticate with burrow.net to manage the dedicated Namespace session that backs Forgejo NSC automation.

- Sign in with burrow.net -
- -"# - .to_owned() -} - -fn render_dashboard( - config: &NamespacePortalConfig, - session: &PortalSession, - status: &NamespaceStatus, -) -> String { - let refresh = if status.login_url.is_some() { - r#""# - } else { - "" - }; - let login_action = if let Some(url) = &status.login_url { - format!( - "

Namespace Login In Progress

Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.

Open Namespace Login

", - escape_html(url) - ) - } else if status.linked { - "

Namespace Linked

The forge-owned NSC session is authenticated and ready to mint runner tokens.

".to_owned() - } else { - "

Namespace Not Linked

Start a server-side Namespace login. The portal will produce a Namespace URL, and completing that browser flow will authenticate the forge-owned NSC state directory.

".to_owned() - }; - let error = status - .last_error - .as_ref() - .map(|error| format!("

{}

", escape_html(error))) - .unwrap_or_default(); - let token_state = if status.token_present { - "present" - } else { - "missing" - }; - format!( - r#" - - - - - Burrow Namespace Portal - {refresh} - - - -
-
-
-

Burrow Namespace Portal

-

Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.

-
-
-
- -
-
-
burrow.net identity
{identity}
-
required group
{group}
-
NSC token file
{token_path}
-
current token
{token_state}
-
-
- - {login_action} - {error} - -
-

Actions

-
-
-
-
-
-
- -"#, - refresh = refresh, - email = escape_html(&session.email), - identity = escape_html(&session.display_name), - group = escape_html(&config.allowed_group), - token_path = escape_html(&config.token_output_path.display().to_string()), - token_state = token_state, - login_action = login_action, - error = error, - ) -} - -fn render_error_page(message: &str) -> String { - format!( - r#"

Namespace Portal Error

{}

"#, - escape_html(message) - ) -} - -fn display_name(userinfo: &UserInfo) -> String { - if !userinfo.name.trim().is_empty() { - return userinfo.name.trim().to_owned(); - } - if !userinfo.preferred_username.trim().is_empty() { - return userinfo.preferred_username.trim().to_owned(); - } - userinfo.email.clone() -} - -async fn current_session(state: &AppState, headers: &HeaderMap) -> Result> { - let Some(session_id) = session_cookie(headers) else { - return Ok(None); - }; - Ok(state.sessions.lock().await.get(&session_id).cloned()) -} - -async fn require_session( - state: &AppState, - headers: &HeaderMap, -) -> Result { - current_session(state, headers) - .await - .map_err(internal_error)? - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign-in required".to_owned())) -} - -async fn prune_pending(state: &AppState) { - state - .pending_logins - .lock() - .await - .retain(|_, login| login.expires_at > Instant::now()); -} - -fn session_cookie(headers: &HeaderMap) -> Option { - let cookie_header = headers.get(COOKIE)?.to_str().ok()?; - for pair in cookie_header.split(';') { - let mut parts = pair.trim().splitn(2, '='); - let name = parts.next()?.trim(); - let value = parts.next()?.trim(); - if name == SESSION_COOKIE && !value.is_empty() { - return Some(value.to_owned()); - } - } - None -} - -fn session_cookie_value(session_id: &str) -> String { - format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax") -} - -fn random_url_token(bytes: usize) -> String { - let mut buf = vec![0u8; bytes]; - rand::thread_rng().fill_bytes(&mut buf); - URL_SAFE_NO_PAD.encode(buf) -} - -fn pkce_challenge(verifier: &str) -> String { - let digest = digest(&SHA256, verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest.as_ref()) -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - -fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) -} - -impl NamespaceSessionManager { - fn new(config: NamespacePortalConfig) -> Self { - Self { - config, - state: Arc::new(Mutex::new(NamespacePortalState::default())), - } - } - - async fn status(&self) -> Result { - let linked = self.check_login().await.is_ok(); - let state = self.state.lock().await.clone(); - let token_present = tokio::fs::metadata(&self.config.token_output_path) - .await - .is_ok(); - Ok(NamespaceStatus { - linked, - login_url: state.active_login.map(|login| login.login_url), - last_error: state.last_error, - token_present, - }) - } - - async fn start_login(&self) -> Result { - if self.check_login().await.is_ok() { - self.refresh_token().await?; - return Ok("already linked".to_owned()); - } - - { - let state = self.state.lock().await; - if let Some(active) = &state.active_login { - return Ok(active.login_url.clone()); - } - } - - self.config.ensure_paths()?; - let mut command = self.base_command(); - command - .args(["auth", "login", "--browser=false"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let mut child = command.spawn().context("failed to spawn nsc auth login")?; - let stdout = child - .stdout - .take() - .context("nsc auth login stdout was not piped")?; - let mut lines = BufReader::new(stdout).lines(); - let mut login_url = None; - while let Some(line) = lines.next_line().await? { - if let Some(candidate) = extract_namespace_login_url(&line) { - login_url = Some(candidate); - break; - } - } - - let login_url = login_url - .ok_or_else(|| anyhow!("nsc auth login did not emit a Namespace login URL"))?; - { - let mut state = self.state.lock().await; - state.active_login = Some(ActiveNamespaceLogin { login_url: login_url.clone() }); - state.last_error = None; - } - - let manager = self.clone(); - tokio::spawn(async move { - let outcome = child.wait().await; - let mut state = manager.state.lock().await; - state.active_login = None; - match outcome { - Ok(status) if status.success() => { - drop(state); - if let Err(err) = manager.refresh_token().await { - manager.state.lock().await.last_error = Some(format!( - "Namespace login finished, but token refresh failed: {err}" - )); - } - } - Ok(status) => { - state.last_error = Some(format!( - "Namespace login command exited with status {}", - status - )); - } - Err(err) => { - state.last_error = Some(format!("Namespace login command failed: {err}")); - } - } - }); - - Ok(login_url) - } - - async fn refresh_token(&self) -> Result<()> { - self.config.ensure_paths()?; - self.check_login().await?; - let mut command = self.base_command(); - command.args([ - "auth", - "generate-dev-token", - "--output_to", - self.config - .token_output_path - .to_str() - .ok_or_else(|| anyhow!("token output path is not valid UTF-8"))?, - ]); - let output = command - .output() - .await - .context("failed to run nsc token refresh")?; - if !output.status.success() { - bail!( - "nsc auth generate-dev-token failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - #[cfg(target_family = "unix")] - { - use std::os::unix::fs::PermissionsExt; - - let perms = fs::Permissions::from_mode(0o440); - fs::set_permissions(&self.config.token_output_path, perms).with_context(|| { - format!( - "failed to set permissions on {}", - self.config.token_output_path.display() - ) - })?; - } - self.state.lock().await.last_error = None; - Ok(()) - } - - async fn check_login(&self) -> Result<()> { - let mut command = self.base_command(); - command.args(["auth", "check-login", "--duration", AUTH_CHECK_DURATION]); - let output = command - .output() - .await - .context("failed to run nsc auth check-login")?; - if output.status.success() { - return Ok(()); - } - bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); - } - - fn base_command(&self) -> Command { - let mut command = Command::new(&self.config.nsc_bin); - let home = self.config.nsc_state_dir.join("home"); - let data = self.config.nsc_state_dir.join("data"); - let cache = self.config.nsc_state_dir.join("cache"); - let config = self.config.nsc_state_dir.join("config"); - let _ = fs::create_dir_all(&home); - let _ = fs::create_dir_all(&data); - let _ = fs::create_dir_all(&cache); - let _ = fs::create_dir_all(&config); - command - .env("HOME", &home) - .env("XDG_DATA_HOME", &data) - .env("XDG_CACHE_HOME", &cache) - .env("XDG_CONFIG_HOME", &config); - command - } -} - -fn extract_namespace_login_url(line: &str) -> Option { - line.split_whitespace() - .find(|token| token.starts_with("https://")) - .map(ToOwned::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_namespace_login_url_from_output() { - let url = extract_namespace_login_url( - " https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o", - ); - assert_eq!( - url.as_deref(), - Some("https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o") - ); - } - - #[test] - fn pkce_challenge_is_stable() { - assert_eq!( - pkce_challenge("hello"), - "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" - ); - } - - #[test] - fn parses_session_cookie() { - let mut headers = HeaderMap::new(); - headers.insert( - COOKIE, - HeaderValue::from_static( - "something=else; burrow_namespace_portal_session=session123; another=value", - ), - ); - assert_eq!(session_cookie(&headers).as_deref(), Some("session123")); - } -} diff --git a/flake.nix b/flake.nix index 0bba0b1..1974f17 100644 --- a/flake.nix +++ b/flake.nix @@ -214,8 +214,6 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; - nixosModules.burrow-namespace-portal = import ./nixos/modules/burrow-namespace-portal.nix; - nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { diff --git a/nixos/README.md b/nixos/README.md index 13fe76d..23907f3 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,7 +12,6 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC -- `modules/burrow-namespace-portal.nix`: small admin portal for forge-owned Namespace authentication and NSC token refresh - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key @@ -24,8 +23,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host - `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists -- `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host -- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net` +- `../Scripts/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge` ## Intended Flow @@ -34,16 +32,17 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. -6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. -7. Visit `https://nsc.burrow.net/` as a Burrow admin to link the forge-owned Namespace session and rotate `/var/lib/burrow/intake/forgejo_nsc_token.txt` without relying on a personal local `nsc` login. -8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. -9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`. +7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`. +8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, `secrets/infra/headscale-oidc-client-secret.age`, `secrets/infra/forgejo-nsc-token.age`, `secrets/infra/forgejo-nsc-dispatcher-config.age`, and `secrets/infra/forgejo-nsc-autoscaler-config.age`, and let agenix materialize them under `/run/agenix/`. +9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. ## Current Constraints -- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`, and `Scripts/check-forge-host.sh --expect-nsc` passes locally against that host. +- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`. +- `services.forgejo-nsc` now expects agenix-backed runtime inputs at `/run/agenix/burrowForgejoNscToken`, `/run/agenix/burrowForgejoNscDispatcherConfig`, and `/run/agenix/burrowForgejoNscAutoscalerConfig`. - Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. - Public Burrow forge cutover completed on March 15, 2026: - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index aecdbfa..7f6af22 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,7 +33,6 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale - self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -88,10 +87,28 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowForgejoNscToken = { + file = ../../../secrets/infra/forgejo-nsc-token.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscDispatcherConfig = { + file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscAutoscalerConfig = { + file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net ''; services.burrow.forge = { @@ -113,13 +130,13 @@ in services.forgejo-nsc = { enable = true; - nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + nscTokenFile = config.age.secrets.burrowForgejoNscToken.path; dispatcher = { - configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml"; + configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path; }; autoscaler = { enable = true; - configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml"; + configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path; }; }; @@ -141,11 +158,4 @@ in enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; - - services.burrow.namespacePortal = { - enable = true; - domain = "nsc.burrow.net"; - baseUrl = "https://nsc.burrow.net"; - adminGroup = contributors.groups.admins; - }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index e2ee18d..1616b36 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,7 +10,6 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; - namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; @@ -139,30 +138,6 @@ in description = "Authentik application slug for Tailscale custom OIDC sign-in."; }; - namespacePortalDomain = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Public domain for the Burrow Namespace portal."; - }; - - namespacePortalProviderSlug = lib.mkOption { - type = lib.types.str; - default = "namespace"; - description = "Authentik application slug for the Namespace portal."; - }; - - namespacePortalClientId = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Client ID Authentik should present to the Namespace portal."; - }; - - namespacePortalClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional host-local file containing the Authentik Namespace portal OIDC client secret."; - }; - tailscaleClientId = lib.mkOption { type = lib.types.str; default = "tailscale.burrow.net"; @@ -733,56 +708,6 @@ EOF ''; }; - systemd.services.burrow-authentik-namespace-portal-oidc = { - description = "Reconcile the Burrow Authentik Namespace portal OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = - [ - namespacePortalOidcSyncScript - cfg.envFile - ] - ++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ]; - path = [ - pkgs.bash - pkgs.coreutils - pkgs.curl - pkgs.jq - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - set -a - source ${lib.escapeShellArg cfg.envFile} - set +a - - export AUTHENTIK_URL=https://${cfg.domain} - export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug} - export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal" - export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal" - export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId} - ${lib.optionalString (cfg.namespacePortalClientSecretFile != null) '' - export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})" - ''} - export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/ - export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]' - - ${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript} - ''; - }; - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-namespace-portal.nix b/nixos/modules/burrow-namespace-portal.nix deleted file mode 100644 index 2eb7b24..0000000 --- a/nixos/modules/burrow-namespace-portal.nix +++ /dev/null @@ -1,126 +0,0 @@ -{ config, lib, pkgs, self, ... }: - -let - cfg = config.services.burrow.namespacePortal; - burrowExe = lib.getExe self.packages.${pkgs.system}.burrow; - nscExe = lib.getExe self.packages.${pkgs.system}.nsc; -in -{ - options.services.burrow.namespacePortal = { - enable = lib.mkEnableOption "the Burrow Namespace authentication portal"; - - domain = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Public domain for the Namespace portal."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 9080; - description = "Local listen port for the Namespace portal."; - }; - - baseUrl = lib.mkOption { - type = lib.types.str; - default = "https://nsc.burrow.net"; - description = "Public base URL for redirects."; - }; - - oidcProviderSlug = lib.mkOption { - type = lib.types.str; - default = "namespace"; - description = "Authentik provider slug used for the portal."; - }; - - oidcClientId = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "OIDC client ID used by the portal."; - }; - - oidcClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional host-local OIDC client secret for the portal."; - }; - - adminGroup = lib.mkOption { - type = lib.types.str; - default = "burrow-admins"; - description = "Authentik group required to access the portal."; - }; - - stateDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/namespace-portal"; - description = "Persistent state directory for the portal-owned NSC session."; - }; - - tokenOutputPath = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; - description = "Path where refreshed NSC tokens should be written."; - }; - }; - - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = config.services.forgejo-nsc.enable; - message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable"; - } - ]; - - systemd.tmpfiles.rules = [ - "d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -" - "d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -" - ]; - - systemd.services.burrow-namespace-portal = { - description = "Burrow Namespace authentication portal"; - after = [ - "network-online.target" - "burrow-authentik-ready.service" - ]; - wants = [ - "network-online.target" - "burrow-authentik-ready.service" - ]; - wantedBy = [ "multi-user.target" ]; - path = [ - self.packages.${pkgs.system}.burrow - self.packages.${pkgs.system}.nsc - pkgs.coreutils - ]; - serviceConfig = { - Type = "simple"; - User = "forgejo-nsc"; - Group = "forgejo-nsc"; - WorkingDirectory = cfg.stateDir; - Restart = "on-failure"; - RestartSec = "2s"; - }; - script = '' - set -euo pipefail - export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port} - export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl} - export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"} - export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId} - export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup} - export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe} - export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"} - export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath} - ${lib.optionalString (cfg.oidcClientSecretFile != null) '' - export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" - ''} - exec ${burrowExe} namespace-portal - ''; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} - ''; - }; -} diff --git a/secrets.nix b/secrets.nix index c0b9b53..a8fb923 100644 --- a/secrets.nix +++ b/secrets.nix @@ -16,6 +16,9 @@ in "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/forgejo-nsc-autoscaler-config.age b/secrets/infra/forgejo-nsc-autoscaler-config.age new file mode 100644 index 0000000000000000000000000000000000000000..28e3d4ae7ab2661a31c8175b638ac203ef7414f5 GIT binary patch literal 1264 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vn%ZPch6g z^K>k8clR#$4b1T@sj4h43Qut=*Uzpjb`K0Psz{8;@+b}t&gKfL3^d5|%`fms&kQZd z_6sym&eS&b^{Xt(F9{6Hc1kWZ4>n8kC@M0`k3_f4vnVRpFFU78w>%WtJfsNd}RoVYx;= zl|jKt9vNQQ5n0}4`B5c-?!H__AtvdbK`Gwah6aX48Sa(YA*EGGfu82RWjR$Dm5$ol zX^91?S*aFD#jaesy1EKxnLg#om8G6RrvBxwQB`j4LE44BSsoQ8hUxw;!J%$Bx#f-~ ziQazUKKWexCro>@`sL+AF2P%-?VnSA_v+14+vPW86nk&p{X#nQuy^3>RiEE5`Coo} z?rF!3{j&?#^giCOc%DtTWR=@fL$7P=bvA8Z=eTNB{fexA>Y_T^d6?fYiV{doL(PWGBpH+sU}soI|UzCk`oGOo3gt0?<(1%Ho#t=sCSPxQ_6MaZ@Jb&dx{NTgw8oPPsTkz`DNwrLn`}=9mOuGWzVs56Zvh=?;X0j zljR_Paf|yZc7OZ{PowBi~aXT&kjX(VB&C z8f$LOdhX|Y{BXb}`Jm22{+SzR_VJgCP5%5-zHVb#+1_`WcceK~wD!a)J<7?dmuCLcQgw*qZB}LT^+8I2-b%V$upd?Otw`bdkML zXd_l~_1Nu=i+p1@Jzn}QfbV|Nxy5Virt3_5Hg|2hrTp@Ubu*o{y$;;7xxg2%x6Up` zHdgZI+Su7ef2?m9@m_qsASO({D7e2uc0;6X*uSgG#9mK0FQ6^-H)~<)5_!e2y6-D~ zu3G#;@XH$K!B_TB3$myc?H(4!)Us*A8LLvj+w%%v^Yk0xE|MH1d zH#aZW&X|2^=c%gd=;DBtZ_VnZ?^?x3x2c)En0WGE=FYyK%ySRPFs$y@`K;%g)Wn)t zrrdfhSbIS?Gjp-FOu|3r_eVX`=CfRTT(x!G0{J!F3P1ka+5R#*{cF?tPl~76rv6Q8 zJs=XK>Adik;(pF2qNU3wRo%THy}Mn*s=DxTt7Ed48`Ql_>`@MoUi!f$ z`i7<^6=^w1Zs}aQy1EKw#vYz&g}$XFUU?}_6(td=S(#qBW}acD#r~1nP6j?nxyk<8 zMxo|zMJ8N~ZF~a0p;1kLJGi)Bwru4*`t8_5*vAfi zCw!wiR$S%$wD*-znVtCD=5Gu6R=V%FeKp)#_;10!xh_ zF21PLR`jNG!R0mY)8BDRFVnmCbGGA!t6Tp+V4rvL)pZ?Xv)1cVcc?A6meHqmV2!NT zis}!J>qDpM7TlZ|BBtu*ll4zJ>f-?^-l&=TEf33XW2r2>81!yhx9*k}9Y+2?Q~V|$ zS4#YKTXdECT#ngAx1Suh5Z9lwO+xg6_r$zAy<9O&LN_J{728*yy*!_xFuifkRK;gq zrVliY`(C9zc9~nUE@xliVi9vygW~M(-}>(=tW8jy`$W0HG5dq}hLaqA{Ij2ao+>%< zw;J1Il?nZ)H|&)v{gSic)-9`d9(F=@qL&_b+3&N@di+|AeIFNph}<79qt$QjybU`3 zVWX$W{0$5Lh0QLv$yxVmZ|@;96W<*hv_*TiSBvE~9u?9TPMGxj`{(mAZC0r&0Z~8E!>s<85g!8X{YZb^eF8Fe~F3M+0wJFQQML#E{Y)iPbW+i9EuR|~5 zm*?JH@#8_HW8Bs6Ri}%4m$mu)<1ah)DF2qPvB{39@X+W$F|WCV|{ zztO(JNu}(%tnZj-=`eoD62w<>8J%jM`(4w^On%KSc6^+!(owNiW} zwY6S`yeKz2R`=$;;=~+V_q*;N#rtNwn%xoiUCH67wdFBG9k0L_-7eGq&wceig!5o( zC$Hh#T{D)}-R{~ha?R9_slugDRr77p^R?$@FxYDOX_w|dl6O*N+2CA%AvN#wozOMH zQ)OGCdomjTf9Y`3eR}oWUA@&yk3?LlQTXcLvX?o!{ciN19WQ>UF?y-JQ ssh-ed25519 ux4N8Q yCjzc3QW91l62Y+U2YZqLpTkiZyTJAxQQCiZ+DxHiWI +mG/+2fppo3RITeohTM/Dm1M6fsErtxhOgIeI2FqvoUs +-> ssh-ed25519 IrZmAg +Y59O8SVATZfe8Vu2gis1KNWcL34Ct7M3G34XNURczw +GGkVYcmoUtJRx4zftjLFID2wLtNtCgGVnYuMN8XF74s +-> X25519 xqDMDV9XRhSPlFy2IJPBfpUGuNA9gpX73kg8Pnj48VI +TPZZNrRUK+FzruetDFuJcTzed03d7gkxOv8QAZshBn8 +--- PRD84efdrqDmPeRA8zi0D2V8RmT0tFVbDIVD6U/4KVo +2Wk*cS++j9{4j;`wd3,"gligЇ e`''# "'(=LS3hFjgYIF|0$Fp^` +QknUx78b!>n?9^!=ͮ [a ` ϫ_#?T@]Eβ[,g퟇cjx}.̞f45֕DLH4_HdwXwXkRx7DM,0 7*TU{~ä8yC "/oXCe8-ulYt ;ҖDZdm wFyiIώɅ8F}l"Isu{L!+UBei_Z~D>B)L>