Compare commits

..

3 commits

Author SHA1 Message Date
Conrad Kramer
70607e874c Move forgejo-nsc credentials into agenix
Some checks are pending
Build Rust / Cargo Test (push) Waiting to run
Build Site / Next.js Build (push) Waiting to run
Lint Governance / BEP Metadata (push) Waiting to run
2026-04-05 23:08:23 -07:00
Conrad Kramer
e40a947223 Add forge-owned Namespace auth portal 2026-04-05 20:52:52 -07:00
Conrad Kramer
64103abbea Refocus Tailnet flow on Tailscale 2026-04-05 02:10:49 -07:00
28 changed files with 2070 additions and 499 deletions

View file

@ -55,7 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let statusBar = NSStatusBar.system let statusBar = NSStatusBar.system
let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button { 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 return statusItem
}() }()

View file

@ -1,15 +1,31 @@
import XCTest import XCTest
import UIKit
@MainActor @MainActor
final class BurrowTailnetLoginUITests: XCTestCase { 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 { override func setUpWithError() throws {
continueAfterFailure = false continueAfterFailure = false
} }
func testTailnetLoginThroughAuthentikWebSession() throws { func testTailnetLoginThroughAuthentikWebSession() throws {
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") let config = try loadTestConfig()
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email let email = config.email
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") let username = config.username
let password = config.password
let mode = config.mode ?? .tailscale
let browserIdentity = mode == .tailscale ? email : username
let app = XCUIApplication() let app = XCUIApplication()
app.launch() app.launch()
@ -18,51 +34,90 @@ final class BurrowTailnetLoginUITests: XCTestCase {
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
tailnetButton.tap() tailnetButton.tap()
configureTailnetIfNeeded(in: app, mode: mode)
let discoveryField = app.textFields["tailnet-discovery-email"] let discoveryField = app.textFields["tailnet-discovery-email"]
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
replaceText(in: discoveryField, with: email) replaceText(in: discoveryField, with: email)
let findServerButton = app.buttons["tailnet-find-server"] let serverCard = app.descendants(matching: .any)
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") .matching(identifier: "tailnet-server-card")
findServerButton.tap() .firstMatch
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
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 signInButton = app.buttons["tailnet-start-sign-in"] let signInButton = app.buttons["tailnet-start-sign-in"]
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
signInButton.tap() signInButton.tap()
acceptAuthenticationPromptIfNeeded(in: app) acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
let webSession = webAuthenticationSession() let webSession = webAuthenticationSession()
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") 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() app.activate()
XCTAssertTrue( 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" "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 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 = [ let promptCandidates = [
springboard.buttons["Continue"], springboard.buttons["Continue"],
springboard.buttons["Allow"], springboard.buttons["Allow"],
@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
app.buttons["Allow"], app.buttons["Allow"],
] ]
for button in promptCandidates where button.waitForExistence(timeout: 3) { for button in promptCandidates where button.exists {
button.tap() button.tap()
return return
} }
@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase {
} }
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { 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( let usernameField = firstExistingElement(
in: webSession, in: webSession,
queries: [ queries: [
@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase {
{ $0.webViews.textFields["Email or Username"] }, { $0.webViews.textFields["Email or Username"] },
{ $0.descendants(matching: .textField).firstMatch }, { $0.descendants(matching: .textField).firstMatch },
], ],
timeout: 25 timeout: 12
) )
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") if !usernameField.exists {
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
)
return return
} }
replaceText(in: usernameField, with: username)
tapFirstExistingButton( tapFirstExistingButton(
in: webSession, in: webSession,
@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let passwordField = firstExistingSecureField(in: webSession, timeout: 20) let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
replaceSecureText(in: passwordField, with: password) replaceSecureText(in: passwordField, within: webSession, with: password)
tapFirstExistingButton( submitAuthenticationForm(in: webSession, focusedField: passwordField)
in: webSession, }
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5 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 { private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
let candidates = [ let candidates = [
app.descendants(matching: .secureTextField).firstMatch,
app.secureTextFields["Password"], app.secureTextFields["Password"],
app.secureTextFields["Password or Token"], app.secureTextFields["Password or Token"],
app.webViews.secureTextFields["Password"], app.webViews.secureTextFields["Password"],
app.webViews.secureTextFields["Password or Token"], app.webViews.secureTextFields["Password or Token"],
app.descendants(matching: .secureTextField).firstMatch,
] ]
return firstExistingElement(from: candidates, timeout: timeout) return firstExistingElement(from: candidates, timeout: timeout)
@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase {
button.tap() button.tap()
} }
private func requiredEnvironment(_ key: String) throws -> String { private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
guard let value = ProcessInfo.processInfo.environment[key], focus(focusedField)
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 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 { else {
throw XCTSkip("Missing required UI test environment variable \(key)") return nil
} }
return value return value
} }
@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase {
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed 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( private func firstExistingElement(
in app: XCUIApplication, in app: XCUIApplication,
queries: [(XCUIApplication) -> XCUIElement], queries: [(XCUIApplication) -> XCUIElement],
@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase {
} }
private func replaceText(in element: XCUIElement, with value: String) { private func replaceText(in element: XCUIElement, with value: String) {
element.tap() focus(element)
clearText(in: element) clearText(in: element)
element.typeText(value) element.typeText(value)
} }
private func replaceSecureText(in element: XCUIElement, with value: String) { private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
element.tap() UIPasteboard.general.string = value
clearText(in: element) 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) element.typeText(value)
} }
@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
element.typeText(deleteSequence) 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,
]
}
}
} }

View file

@ -36,13 +36,9 @@ public enum Constants {
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> { private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
Result { Result {
let baseURL = try FileManager.default.url( // The simulator app's Application Support path lives inside its sandbox container,
for: .applicationSupportDirectory, // so the host daemon cannot reach it. Use a shared host temp location instead.
in: .userDomainMask, let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
appropriateFor: nil,
create: true
)
let url = baseURL
.appending(component: bundleIdentifier, directoryHint: .isDirectory) .appending(component: bundleIdentifier, directoryHint: .isDirectory)
.appending(component: "SimulatorFallback", directoryHint: .isDirectory) .appending(component: "SimulatorFallback", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)

View file

@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
public init() {} 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 { extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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 struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel public let channel: GRPCChannel
public var defaultCallOptions: CallOptions 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<Burrow_TunnelPacket, Burrow_TunnelPacket> {
self.makeAsyncBidirectionalStreamingCall(
path: "/burrow.Tunnel/TunnelPackets",
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
}

View file

@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
public var mtu: Int32 = 0 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 var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {} public init() {}
@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "addresses"), 1: .same(proto: "addresses"),
2: .same(proto: "mtu"), 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<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
switch fieldNumber { switch fieldNumber {
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }() case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }() 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 default: break
} }
} }
@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
if self.mtu != 0 { if self.mtu != 0 {
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2) 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) try unknownFields.traverse(visitor: &visitor)
} }
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool { public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
if lhs.addresses != rhs.addresses {return false} if lhs.addresses != rhs.addresses {return false}
if lhs.mtu != rhs.mtu {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} if lhs.unknownFields != rhs.unknownFields {return false}
return true return true
} }

View file

@ -1,6 +1,7 @@
import AsyncAlgorithms import AsyncAlgorithms
import BurrowConfiguration import BurrowConfiguration
import BurrowCore import BurrowCore
import GRPC
import libburrow import libburrow
import NetworkExtension import NetworkExtension
import os import os
@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
} }
private let logger = Logger.logger(for: PacketTunnelProvider.self) private let logger = Logger.logger(for: PacketTunnelProvider.self)
private var packetCall: GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket>?
private var inboundPacketTask: Task<Void, Never>?
private var outboundPacketTask: Task<Void, Never>?
private var client: TunnelClient { private var client: TunnelClient {
get throws { try _client.get() } get throws { try _client.get() }
@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
let completion = SendableCallbackBox(completionHandler) let completion = SendableCallbackBox(completionHandler)
Task { Task {
do { do {
_ = try await client.tunnelStart(.init())
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
guard let settings = configuration?.settings else { guard let settings = configuration?.settings else {
throw Error.missingTunnelConfiguration throw Error.missingTunnelConfiguration
} }
try await setTunnelNetworkSettings(settings) try await setTunnelNetworkSettings(settings)
_ = try await client.tunnelStart(.init()) try startPacketBridge()
logger.log("Started tunnel with network settings: \(settings)") logger.log("Started tunnel with network settings: \(settings)")
completion.callback(nil) completion.callback(nil)
} catch { } catch {
logger.error("Failed to start tunnel: \(error)") logger.error("Failed to start tunnel: \(error)")
stopPacketBridge()
completion.callback(error) completion.callback(error)
} }
} }
@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
) { ) {
let completion = SendableCallbackBox(completionHandler) let completion = SendableCallbackBox(completionHandler)
Task { Task {
stopPacketBridge()
do { do {
_ = try await client.tunnelStop(.init()) _ = try await client.tunnelStop(.init())
logger.log("Stopped client") 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 { extension Burrow_TunnelConfigurationResponse {
fileprivate var settings: NEPacketTunnelNetworkSettings { 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") let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
settings.mtu = NSNumber(value: mtu) settings.mtu = NSNumber(value: mtu)
settings.ipv4Settings = NEIPv4Settings( if !ipv4Addresses.isEmpty {
addresses: addresses.filter { IPv4Address($0) != nil }, let ipv4Settings = NEIPv4Settings(
subnetMasks: ["255.255.255.0"] addresses: ipv4Addresses.map(\.address),
) subnetMasks: ipv4Addresses.map(\.subnetMask)
settings.ipv6Settings = NEIPv6Settings( )
addresses: ipv6Addresses, if !ipv4Routes.isEmpty {
networkPrefixLengths: ipv6Addresses.map { _ in 64 } 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 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: ".")
}
}

View file

@ -83,7 +83,7 @@ public struct BurrowView: View {
ContentUnavailableView( ContentUnavailableView(
"No Accounts Yet", "No Accounts Yet",
systemImage: "person.crop.circle.badge.plus", 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) .frame(maxWidth: .infinity, minHeight: 180)
} else { } else {
@ -135,7 +135,7 @@ public struct BurrowView: View {
private func runAutomationIfNeeded() { private func runAutomationIfNeeded() {
guard !didRunAutomation, guard !didRunAutomation,
let automation = BurrowAutomationConfig.current, let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe automation.action == .tailnetLogin || automation.action == .tailnetProbe
else { else {
return return
} }
@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View {
@State private var isStartingTailnetLogin = false @State private var isStartingTailnetLogin = false
@State private var tailnetPresentedAuthURL: URL? @State private var tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false @State private var preserveTailnetLoginSession = false
@State private var usesCustomTailnetAuthority = false
@State private var showsAdvancedTailnetSettings = false
@State private var browserAuthenticator = TailnetBrowserAuthenticator() @State private var browserAuthenticator = TailnetBrowserAuthenticator()
@State private var tailnetLoginPollTask: Task<Void, Never>? @State private var tailnetLoginPollTask: Task<Void, Never>?
@State private var tailnetDiscoveryTask: Task<Void, Never>?
@State private var tailnetProbeTask: Task<Void, Never>?
@State private var didRunAutomation = false @State private var didRunAutomation = false
init( init(
@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View {
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
Section("Identity") { if showsIdentitySection {
TextField("Title", text: $draft.title) Section("Identity") {
TextField("Account", text: $draft.accountName) identityFields
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
} }
} }
@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View {
} }
.onChange(of: draft.authority) { _, _ in .onChange(of: draft.authority) { _, _ in
resetAuthorityProbe() resetAuthorityProbe()
if sheet == .tailnet, usesCustomTailnetAuthority {
scheduleTailnetAuthorityProbe()
}
} }
.onChange(of: draft.discoveryEmail) { _, _ in .onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback() resetTailnetDiscoveryFeedback()
if sheet == .tailnet, !usesCustomTailnetAuthority {
scheduleTailnetDiscovery()
}
} }
.onChange(of: draft.authMode) { _, newMode in .onChange(of: draft.authMode) { _, newMode in
guard newMode != .web else { return } guard newMode != .web else { return }
@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View {
} }
.onDisappear { .onDisappear {
tailnetLoginPollTask?.cancel() tailnetLoginPollTask?.cancel()
tailnetDiscoveryTask?.cancel()
tailnetProbeTask?.cancel()
browserAuthenticator.cancel() browserAuthenticator.cancel()
if !preserveTailnetLoginSession { if !preserveTailnetLoginSession {
Task { @MainActor in 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 @ViewBuilder
private var tailnetSections: some View { private var tailnetSections: some View {
Section("Connection") { Section("Connection") {
@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View {
.burrowLoginField() .burrowLoginField()
.autocorrectionDisabled() .autocorrectionDisabled()
.accessibilityIdentifier("tailnet-discovery-email") .accessibilityIdentifier("tailnet-discovery-email")
.submitLabel(.continue)
Button { .onSubmit {
discoverTailnetAuthority() if !usesCustomTailnetAuthority {
} label: { scheduleTailnetDiscovery(immediate: true)
Label {
Text(isDiscoveringTailnet ? "Finding Server" : "Find Server")
} icon: {
Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle")
} }
} }
.buttonStyle(.borderless)
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
.accessibilityIdentifier("tailnet-find-server")
if let discoveryStatus { tailnetServerCard
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
} else if let discoveryError {
tailnetDiscoveryCard(status: nil, failure: discoveryError)
}
TextField("Authority URL", text: $draft.authority) if showsAdvancedTailnetSettings {
.burrowLoginField() if usesCustomTailnetAuthority {
.autocorrectionDisabled() TextField("Server URL", text: $draft.authority)
.accessibilityIdentifier("tailnet-authority") .burrowLoginField()
.autocorrectionDisabled()
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") .accessibilityIdentifier("tailnet-authority")
.font(.footnote) } else {
.foregroundStyle(.secondary) TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
Button { .autocorrectionDisabled()
probeTailnetAuthority() .accessibilityIdentifier("tailnet-name")
} label: {
Label {
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
} icon: {
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
} }
} }
.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") { Section("Authentication") {
Picker("Authentication", selection: $draft.authMode) { if showsAdvancedTailnetSettings {
ForEach(availableTailnetAuthModes) { mode in Picker("Authentication", selection: $draft.authMode) {
Text(mode.title).tag(mode) ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
}
} }
.pickerStyle(.menu)
} }
.pickerStyle(.menu)
if draft.authMode == .web { if draft.authMode == .web {
Button { Button {
@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View {
} }
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil) .disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
.accessibilityIdentifier("tailnet-start-sign-in") .accessibilityIdentifier("tailnet-start-sign-in")
if let tailnetLoginStatus { if let tailnetLoginStatus {
@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View {
} }
if sheet == .tailnet { if sheet == .tailnet {
if let authorityProbeStatus { labeledValue("Server", tailnetServerDisplayLabel)
Text(authorityProbeStatus.summary) if let connectionSummary = tailnetConnectionSummary {
Text(connectionSummary)
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))
.foregroundStyle(.primary) .foregroundStyle(tailnetConnectionSummaryColor)
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)
} }
} if tailnetLoginStatus?.running == true {
HStack(spacing: 8) {
if sheet == .tailnet {
HStack(spacing: 8) {
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
summaryBadge(draft.authMode.title)
if tailnetLoginStatus?.running == true {
summaryBadge("Signed In") 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( private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?, status: TailnetAuthorityProbeStatus?,
failure: String? failure: String?
@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View {
} }
case .tailnet: case .tailnet:
Button("Use Tailscale Managed Server") { Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
applyTailnetDefaults(for: .tailscale) toggleTailnetAuthorityMode()
} }
if availableTailnetAuthModes.count > 1 { Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
showsAdvancedTailnetSettings.toggle()
}
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
Menu("Authentication") { Menu("Authentication") {
ForEach(availableTailnetAuthModes) { mode in ForEach(availableTailnetAuthModes) { mode in
Button(mode.title) { Button(mode.title) {
@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View {
} }
} }
Button("Clear Discovery Result") { Button("Refresh Server Lookup") {
resetTailnetDiscoveryFeedback() scheduleTailnetDiscovery(immediate: true)
} }
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
} }
} }
@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View {
private var showsBottomActionButton: Bool { private var showsBottomActionButton: Bool {
#if os(iOS) #if os(iOS)
true return true
#else #else
false return false
#endif #endif
} }
private var showsIdentitySection: Bool {
switch sheet {
case .wireGuard, .tor:
return true
case .tailnet:
return showsAdvancedTailnetSettings
}
}
private var wireGuardEditorHeight: CGFloat { private var wireGuardEditorHeight: CGFloat {
#if os(iOS) #if os(iOS)
180 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 { private var submissionDisabled: Bool {
switch sheet { switch sheet {
case .wireGuard: 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() { private func submit() {
isSubmitting = true isSubmitting = true
errorMessage = nil errorMessage = nil
@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View {
guard !didRunAutomation, guard !didRunAutomation,
sheet == .tailnet, sheet == .tailnet,
let automation = BurrowAutomationConfig.current, let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe automation.action == .tailnetLogin || automation.action == .tailnetProbe
else { else {
return return
} }
@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View {
case .tailnetLogin: case .tailnetLogin:
applyTailnetDefaults(for: .tailscale) applyTailnetDefaults(for: .tailscale)
startTailnetLogin() startTailnetLogin()
case .headscaleProbe: case .tailnetProbe:
usesCustomTailnetAuthority = true
showsAdvancedTailnetSettings = true
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority() probeTailnetAuthority()
} }
@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View {
) )
var noteParts: [String] = [ var noteParts: [String] = [
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", "Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
"Auth: \(draft.authMode.title)",
] ]
if showsAdvancedTailnetSettings || draft.authMode != .web {
noteParts.append("Auth: \(draft.authMode.title)")
}
if draft.authMode == .web, tailnetLoginStatus?.running == true { if draft.authMode == .web, tailnetLoginStatus?.running == true {
noteParts.append("Browser sign-in complete") noteParts.append("Browser sign-in complete")
} }
@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View {
private func applyTailnetDefaults(for provider: TailnetProvider) { private func applyTailnetDefaults(for provider: TailnetProvider) {
resetTailnetDiscoveryFeedback() resetTailnetDiscoveryFeedback()
usesCustomTailnetAuthority = provider != .tailscale
draft.authority = provider.defaultAuthority ?? "" draft.authority = provider.defaultAuthority ?? ""
if !availableTailnetAuthModes.contains(draft.authMode) { if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = .web draft.authMode = .web
@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View {
} }
private func startTailnetLogin() { private func startTailnetLogin() {
guard let authority = normalizedOptional(draft.authority) else {
tailnetLoginStatus = nil
tailnetLoginError = "Enter a server URL first."
return
}
isStartingTailnetLogin = true isStartingTailnetLogin = true
tailnetLoginError = nil tailnetLoginError = nil
preserveTailnetLoginSession = false preserveTailnetLoginSession = false
@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in Task { @MainActor in
defer { isStartingTailnetLogin = false } defer { isStartingTailnetLogin = false }
do { do {
let authority = try await resolveTailnetAuthorityForLogin()
let status = try await networkViewModel.startTailnetLogin( let status = try await networkViewModel.startTailnetLogin(
accountName: normalized(draft.accountName, fallback: "default"), accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"), identityName: normalized(draft.identityName, fallback: "apple"),
@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View {
} }
private func resetAuthorityProbe() { private func resetAuthorityProbe() {
tailnetProbeTask?.cancel()
authorityProbeStatus = nil authorityProbeStatus = nil
authorityProbeError = nil authorityProbeError = nil
tailnetLoginError = nil tailnetLoginError = nil
} }
private func resetTailnetDiscoveryFeedback() { private func resetTailnetDiscoveryFeedback() {
tailnetDiscoveryTask?.cancel()
discoveryStatus = nil discoveryStatus = nil
discoveryError = 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) { private func beginTailnetLoginPolling(sessionID: String) {
tailnetLoginPollTask?.cancel() tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = Task { @MainActor in tailnetLoginPollTask = Task { @MainActor in
@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View {
if tailnetLoginSessionID != nil { if tailnetLoginSessionID != nil {
return "Resume Sign-In" return "Resume Sign-In"
} }
return "Start Sign-In" return "Continue with Tailscale"
} }
private var tailnetAuthenticationFootnote: String { private var tailnetAuthenticationFootnote: String {
switch draft.authMode { switch draft.authMode {
case .web: 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: case .none:
return "Save the authority only. Useful when the control plane handles authentication elsewhere." return "Save the authority only. Useful when the control plane handles authentication elsewhere."
case .password, .preauthKey: case .password, .preauthKey:
@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View {
) )
} }
private var isManagedTailnetAuthority: Bool {
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
}
@ViewBuilder @ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View { private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -1383,12 +1543,7 @@ private struct AccountRowView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(account.title) Text(account.title)
.font(.headline) .font(.headline)
HStack(spacing: 8) { Text(account.kind.title)
Text(account.kind.title)
if let provider = account.provider {
Text(provider.title)
}
}
.font(.subheadline) .font(.subheadline)
.foregroundStyle(account.kind.accentColor) .foregroundStyle(account.kind.accentColor)
} }
@ -1470,6 +1625,12 @@ private extension View {
@MainActor @MainActor
private final class TailnetBrowserAuthenticator: NSObject { private final class TailnetBrowserAuthenticator: NSObject {
private var session: ASWebAuthenticationSession? 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) { func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
cancel() cancel()
@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
onDismiss() onDismiss()
} }
session.presentationContextProvider = self session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
self.session = session self.session = session
_ = session.start() _ = session.start()
} }
@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator {
private struct BurrowAutomationConfig { private struct BurrowAutomationConfig {
enum Action: String { enum Action: String {
case tailnetLogin = "tailnet-login" case tailnetLogin = "tailnet-login"
case headscaleProbe = "headscale-probe" case tailnetProbe = "tailnet-probe"
} }
let action: Action let action: Action

View file

@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String { var title: String {
switch self { switch self {
case .tailscale: "Tailscale" case .tailscale: "Tailscale"
case .headscale: "Headscale" case .headscale: "Custom Tailnet"
case .burrow: "Burrow" case .burrow: "Burrow"
} }
} }
@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .wireGuard: "Import a tunnel and optional account metadata." case .wireGuard: "Import a tunnel and optional account metadata."
case .tor: "Store Arti account and identity preferences." 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: case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
case .tailnet: 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."
} }
} }
} }

View file

@ -164,6 +164,14 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then
test -s /run/agenix/burrowHeadscaleOidcClientSecret test -s /run/agenix/burrowHeadscaleOidcClientSecret
fi 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 if command -v curl >/dev/null 2>&1; then
echo "== http-local ==" echo "== http-local =="
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login

View file

@ -5,13 +5,18 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" 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}" 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}" 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" 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}" 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_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" 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" password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
@ -25,10 +30,60 @@ if [[ -z "$ui_test_password" ]]; then
fi fi
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" 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() { cleanup() {
rm -f "$ui_test_config_path"
if [[ -n "${daemon_pid:-}" ]]; then if [[ -n "${daemon_pid:-}" ]]; then
kill "$daemon_pid" >/dev/null 2>&1 || true kill "$daemon_pid" >/dev/null 2>&1 || true
wait "$daemon_pid" >/dev/null 2>&1 || true wait "$daemon_pid" >/dev/null 2>&1 || true
@ -36,11 +91,33 @@ cleanup() {
} }
trap cleanup EXIT 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 cargo build -p burrow --bin burrow
( (
cd "$fallback_dir" cd "$fallback_dir"
RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \
BURROW_SOCKET_PATH="burrow.sock" \ BURROW_SOCKET_PATH="burrow.sock" \
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
) & ) &
daemon_pid=$! daemon_pid=$!
@ -56,18 +133,31 @@ if [[ ! -S "$socket_path" ]]; then
exit 1 exit 1
fi 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_EMAIL="$ui_test_email" \
BURROW_UI_TEST_USERNAME="$ui_test_username" \ BURROW_UI_TEST_USERNAME="$ui_test_username" \
BURROW_UI_TEST_PASSWORD="$ui_test_password" \ BURROW_UI_TEST_PASSWORD="$ui_test_password" \
BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \
BURROW_UI_TEST_EPHEMERAL_AUTH=1 \
xcodebuild \ xcodebuild \
-quiet \ "${common_xcodebuild_args[@]}" \
-skipPackagePluginValidation \ test-without-building
-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

View file

@ -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 <user@host> SSH target forwarded to provision-forgejo-nsc.sh.
--ssh-key <path> SSH private key forwarded to provision-forgejo-nsc.sh.
--nsc-bin <path> 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."

View file

@ -1,132 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
usage() { echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2
cat <<'EOF' echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2
Usage: Scripts/sync-forgejo-nsc-config.sh [options] echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2
exit 1
Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and
restart the dispatcher/autoscaler units.
Options:
--host <user@host> SSH target (default: root@git.burrow.net)
--ssh-key <path> 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)))."

View file

@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest {
pub hostname: Option<String>, pub hostname: Option<String>,
#[serde(default)] #[serde(default)]
pub control_url: Option<String>, pub control_url: Option<String>,
#[serde(default)]
pub packet_socket: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Serialize, Deserialize, Default)]
@ -55,23 +57,35 @@ pub struct TailscaleLoginStartResponse {
pub status: TailscaleLoginStatus, pub status: TailscaleLoginStatus,
} }
pub struct TailscaleLoginSession {
pub session_id: String,
pub helper: Arc<TailscaleHelperProcess>,
pub status: TailscaleLoginStatus,
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct TailscaleBridgeManager { pub struct TailscaleBridgeManager {
client: Client, client: Client,
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>, sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
} }
struct ManagedSession { pub struct TailscaleHelperProcess {
session_id: String, session_id: String,
listen_url: String, listen_url: String,
packet_socket: Option<PathBuf>,
control_url: Option<String>,
state_dir: PathBuf, state_dir: PathBuf,
child: Arc<Mutex<Child>>, child: Arc<Mutex<Child>>,
_stderr_task: JoinHandle<()>, _stderr_task: JoinHandle<()>,
} }
type ManagedSession = TailscaleHelperProcess;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HelperHello { struct HelperHello {
listen_addr: String, listen_addr: String,
#[serde(default)]
packet_socket: Option<String>,
} }
impl TailscaleBridgeManager { impl TailscaleBridgeManager {
@ -79,76 +93,71 @@ impl TailscaleBridgeManager {
&self, &self,
request: TailscaleLoginStartRequest, request: TailscaleLoginStartRequest,
) -> Result<TailscaleLoginStartResponse> { ) -> Result<TailscaleLoginStartResponse> {
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<TailscaleLoginSession> {
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() { if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
match self.fetch_status(existing.as_ref()).await { let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket())
Ok(status) => { {
return Ok(TailscaleLoginStartResponse { (Some(requested), Some(current)) => current != Path::new(requested),
session_id: existing.session_id.clone(), (Some(_), None) => true,
status, _ => false,
}); };
} let needs_restart_for_control_url =
Err(err) => { requested_control_url != existing.control_url().map(|value| value.trim());
log::warn!(
"tailscale login session {} is stale, restarting: {err}", if !needs_restart_for_socket && !needs_restart_for_control_url {
existing.session_id match self.fetch_status(existing.as_ref()).await {
); Ok(status) => {
self.sessions.lock().await.remove(&key); return Ok(TailscaleLoginSession {
let _ = self.shutdown_session(existing.as_ref()).await; 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)); let session = Arc::new(spawn_tailscale_helper(&request).await?);
tokio::fs::create_dir_all(&state_dir)
.await
.with_context(|| format!("failed to create {}", state_dir.display()))?;
let mut child = helper_command(&request, &state_dir)?
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn tailscale login helper")?;
let stdout = child
.stdout
.take()
.context("tailscale helper stdout unavailable")?;
let stderr = child
.stderr
.take()
.context("tailscale helper stderr unavailable")?;
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
let mut lines = BufReader::new(stdout).lines();
lines.next_line().await
})
.await
.context("timed out waiting for tailscale helper startup")??
.context("tailscale helper exited before reporting listen address")?;
let hello: HelperHello =
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("tailscale-login-bridge: {line}");
}
});
let session = Arc::new(ManagedSession {
session_id: random_session_id(),
listen_url: format!("http://{}", hello.listen_addr),
state_dir,
child: Arc::new(Mutex::new(child)),
_stderr_task: stderr_task,
});
let status = self.wait_for_status(session.as_ref()).await?; let status = self.wait_for_status(session.as_ref()).await?;
let response = TailscaleLoginStartResponse { let response = TailscaleLoginSession {
session_id: session.session_id.clone(), session_id: session.session_id.clone(),
helper: session.clone(),
status, status,
}; };
@ -192,7 +201,7 @@ impl TailscaleBridgeManager {
let mut last_error = None; let mut last_error = None;
let mut last_status = None; let mut last_status = None;
for _ in 0..40 { 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) if status.running || status.auth_url.is_some() => return Ok(status),
Ok(status) => last_status = Some(status), Ok(status) => last_status = Some(status),
Err(err) => last_error = Some(err), Err(err) => last_error = Some(err),
@ -206,28 +215,7 @@ impl TailscaleBridgeManager {
} }
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> { async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
let mut child = session.child.lock().await; session.status_with_client(&self.client).await
if let Some(status) = child.try_wait()? {
return Err(anyhow!(
"tailscale helper exited with status {status} for {}",
session.state_dir.display()
));
}
drop(child);
let response = self
.client
.get(format!("{}/status", session.listen_url))
.send()
.await
.context("failed to query tailscale helper status")?
.error_for_status()
.context("tailscale helper status request failed")?;
response
.json::<TailscaleLoginStatus>()
.await
.context("invalid tailscale helper status response")
} }
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> { async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
@ -239,14 +227,74 @@ impl TailscaleBridgeManager {
} }
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
let _ = self session.shutdown_with_client(&self.client).await
.client }
.post(format!("{}/shutdown", session.listen_url)) }
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<TailscaleLoginStatus> {
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<TailscaleLoginStatus> {
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() .send()
.await; .await
.context("failed to query tailscale helper status")?
.error_for_status()
.context("tailscale helper status request failed")?;
let status = response
.json::<TailscaleLoginStatus>()
.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 { for _ in 0..10 {
let mut child = session.child.lock().await; let mut child = self.child.lock().await;
if child.try_wait()?.is_some() { if child.try_wait()?.is_some() {
return Ok(()); return Ok(());
} }
@ -254,7 +302,7 @@ impl TailscaleBridgeManager {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
let mut child = session.child.lock().await; let mut child = self.child.lock().await;
child child
.start_kill() .start_kill()
.context("failed to kill tailscale helper")?; .context("failed to kill tailscale helper")?;
@ -263,6 +311,58 @@ impl TailscaleBridgeManager {
} }
} }
pub async fn spawn_tailscale_helper(
request: &TailscaleLoginStartRequest,
) -> Result<TailscaleHelperProcess> {
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<Command> { fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") { let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
Command::new(path) 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) 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") { if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
return PathBuf::from(path); return PathBuf::from(path);
} }
@ -315,19 +426,34 @@ fn state_root() -> PathBuf {
.join("tailscale") .join("tailscale")
} }
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
format!( format!(
"{}-{}", "{}-{}-{}",
slug(&request.account_name), 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 { fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String {
format!("{account_name}:{identity_name}") 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 request
.hostname .hostname
.as_deref() .as_deref()
@ -370,14 +496,24 @@ mod tests {
} }
#[test] #[test]
fn state_dir_is_stable_by_account_and_identity() { fn state_dir_is_scoped_by_account_identity_and_control_plane() {
let request = TailscaleLoginStartRequest { let request = TailscaleLoginStartRequest {
account_name: "default".to_owned(), account_name: "default".to_owned(),
identity_name: "apple".to_owned(), identity_name: "apple".to_owned(),
hostname: None, hostname: None,
control_url: 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"); 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"
);
} }
} }

View file

@ -1,6 +1,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use reqwest::{Client, StatusCode, Url}; use reqwest::{Client, StatusCode, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use super::TailnetProvider; use super::TailnetProvider;
@ -43,6 +44,7 @@ struct WebFingerLink {
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> { pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
let domain = email_domain(email)?; let domain = email_domain(email)?;
info!(%email, %domain, "tailnet discovery requested");
let base_url = Url::parse(&format!("https://{domain}")) let base_url = Url::parse(&format!("https://{domain}"))
.with_context(|| format!("invalid discovery domain {domain}"))?; .with_context(|| format!("invalid discovery domain {domain}"))?;
let client = Client::builder() let client = Client::builder()
@ -116,12 +118,21 @@ pub async fn discover_tailnet_at(
base_url: &Url, base_url: &Url,
) -> Result<TailnetDiscovery> { ) -> Result<TailnetDiscovery> {
let domain = email_domain(email)?; 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? { 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 }); return Ok(TailnetDiscovery { domain, ..discovery });
} }
if let Some(authority) = discover_webfinger(client, email, base_url).await? { if let Some(authority) = discover_webfinger(client, email, base_url).await? {
info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger");
return Ok(TailnetDiscovery { return Ok(TailnetDiscovery {
domain, domain,
provider: inferred_provider(Some(&authority), None), provider: inferred_provider(Some(&authority), None),
@ -162,6 +173,7 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
let url = base_url let url = base_url
.join(TAILNET_DISCOVERY_PATH) .join(TAILNET_DISCOVERY_PATH)
.context("failed to build tailnet discovery URL")?; .context("failed to build tailnet discovery URL")?;
debug!(%url, "requesting tailnet well-known document");
let response = client let response = client
.get(url) .get(url)
.header("accept", "application/json") .header("accept", "application/json")
@ -187,6 +199,7 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("resource", &format!("acct:{email}")) .append_pair("resource", &format!("acct:{email}"))
.append_pair("rel", TAILNET_DISCOVERY_REL); .append_pair("rel", TAILNET_DISCOVERY_REL);
debug!(%email, url = %url, "requesting tailnet webfinger document");
let response = client let response = client
.get(url) .get(url)

View file

@ -8,7 +8,7 @@ use rusqlite::Connection;
use tokio::sync::{mpsc, watch, RwLock}; use tokio::sync::{mpsc, watch, RwLock};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status as RspStatus}; use tonic::{Request, Response, Status as RspStatus};
use tracing::warn; use tracing::{debug, info, warn};
use tun::tokio::TunInterface; use tun::tokio::TunInterface;
use super::{ use super::{
@ -16,15 +16,15 @@ use super::{
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
TunnelStatusResponse, TunnelStatusResponse,
}, },
runtime::{ActiveTunnel, ResolvedTunnel}, runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
}; };
use crate::{ use crate::{
auth::server::tailscale::{ auth::server::tailscale::{
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, packet_socket_path, TailscaleBridgeManager,
TailscaleLoginStatus, TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus,
}, },
control::discovery, control::discovery,
daemon::rpc::ServerConfig, daemon::rpc::ServerConfig,
@ -87,11 +87,20 @@ impl DaemonRPCServer {
} }
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> { async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
let config = self let config = {
.resolve_tunnel() let active = self.active_tunnel.read().await;
.await? active
.server_config() .as_ref()
.map_err(proc_err)?; .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)) Ok(configuration_rsp(config))
} }
@ -111,8 +120,18 @@ impl DaemonRPCServer {
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> { async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
let _ = self.stop_active_tunnel().await?; 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 let active = desired
.start(self.tun_interface.clone()) .start(self.tun_interface.clone(), tailnet_helper)
.await .await
.map_err(proc_err)?; .map_err(proc_err)?;
self.active_tunnel.write().await.replace(active); self.active_tunnel.write().await.replace(active);
@ -137,6 +156,23 @@ impl DaemonRPCServer {
Ok(()) 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<String> { fn tailnet_control_url(authority: &str) -> Option<String> {
let authority = discovery::normalize_authority(authority); let authority = discovery::normalize_authority(authority);
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
@ -146,6 +182,7 @@ impl DaemonRPCServer {
#[tonic::async_trait] #[tonic::async_trait]
impl Tunnel for DaemonRPCServer { impl Tunnel for DaemonRPCServer {
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>; type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
type TunnelPacketsStream = ReceiverStream<Result<TunnelPacket, RspStatus>>;
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>; type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
async fn tunnel_configuration( async fn tunnel_configuration(
@ -171,6 +208,62 @@ impl Tunnel for DaemonRPCServer {
Ok(Response::new(ReceiverStream::new(rx))) Ok(Response::new(ReceiverStream::new(rx)))
} }
async fn tunnel_packets(
&self,
request: Request<tonic::Streaming<TunnelPacket>>,
) -> Result<Response<Self::TunnelPacketsStream>, 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<Empty>) -> Result<Response<Empty>, RspStatus> { async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
let desired = self.resolve_tunnel().await?; let desired = self.resolve_tunnel().await?;
let already_running = { let already_running = {
@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer {
request: Request<TailnetDiscoverRequest>, request: Request<TailnetDiscoverRequest>,
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> { ) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
let request = request.into_inner(); let request = request.into_inner();
info!(email = %request.email, "daemon tailnet discover RPC received");
let discovery = discovery::discover_tailnet(&request.email) let discovery = discovery::discover_tailnet(&request.email)
.await .await
.map_err(proc_err)?; .map_err(proc_err)?;
info!(
email = %request.email,
authority = %discovery.authority,
provider = ?discovery.provider,
"daemon tailnet discover RPC resolved"
);
Ok(Response::new(TailnetDiscoverResponse { Ok(Response::new(TailnetDiscoverResponse {
domain: discovery.domain, domain: discovery.domain,
@ -325,17 +425,32 @@ impl TailnetControl for DaemonRPCServer {
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>, request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> { ) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner(); 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 let response = self
.tailnet_login .tailnet_login
.start_login(BridgeLoginStartRequest { .start_login(Self::tailnet_bridge_request(
account_name: request.account_name, request.account_name,
identity_name: request.identity_name, request.identity_name,
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname), request.hostname,
control_url: Self::tailnet_control_url(&request.authority), request.authority,
}) ))
.await .await
.map_err(proc_err)?; .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( Ok(Response::new(tailnet_login_rsp(
response.session_id, response.session_id,
response.status, response.status,
@ -347,6 +462,7 @@ impl TailnetControl for DaemonRPCServer {
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>, request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> { ) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner(); let request = request.into_inner();
info!(session_id = %request.session_id, "daemon tailnet login status RPC received");
let status = self let status = self
.tailnet_login .tailnet_login
.status(&request.session_id) .status(&request.session_id)
@ -355,6 +471,14 @@ impl TailnetControl for DaemonRPCServer {
let Some(status) = status else { let Some(status) = status else {
return Err(RspStatus::not_found("tailnet login session not found")); 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))) 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 { fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
TunnelConfigurationResponse { TunnelConfigurationResponse {
mtu: config.mtu.unwrap_or(1000),
addresses: config.address, 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,
} }
} }

View file

@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct ServerConfig { pub struct ServerConfig {
pub address: Vec<String>, pub address: Vec<String>,
#[serde(default)]
pub routes: Vec<String>,
#[serde(default)]
pub dns_servers: Vec<String>,
#[serde(default)]
pub search_domains: Vec<String>,
#[serde(default)]
pub include_default_route: bool,
pub name: Option<String>, pub name: Option<String>,
pub mtu: Option<i32>, pub mtu: Option<i32>,
} }
@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig {
fn try_from(config: &Config) -> anyhow::Result<Self> { fn try_from(config: &Config) -> anyhow::Result<Self> {
Ok(ServerConfig { Ok(ServerConfig {
address: config.interface.address.clone(), 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, name: None,
mtu: config.interface.mtu.map(|mtu| mtu as i32), mtu: config.interface.mtu.map(|mtu| mtu as i32),
}) })
@ -88,6 +104,10 @@ impl Default for ServerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
address: vec!["10.13.13.2".to_string()], // Dummy remote address 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, name: None,
mtu: None, mtu: None,
} }

View file

@ -2,4 +2,4 @@
source: burrow/src/daemon/rpc/response.rs source: burrow/src/daemon/rpc/response.rs
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(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}

View file

@ -1,7 +1,13 @@
use std::sync::Arc; use std::{path::PathBuf, sync::Arc};
use anyhow::{Context, Result}; use anyhow::{bail, Context, Result};
use tokio::{sync::RwLock, task::JoinHandle}; use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::UnixStream,
sync::{broadcast, mpsc, RwLock},
task::JoinHandle,
time::{sleep, Duration},
};
use tun::{tokio::TunInterface, TunOptions}; use tun::{tokio::TunInterface, TunOptions};
use super::rpc::{ use super::rpc::{
@ -9,7 +15,11 @@ use super::rpc::{
ServerConfig, ServerConfig,
}; };
use crate::{ 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}, wireguard::{Config, Interface as WireGuardInterface},
}; };
@ -78,11 +88,19 @@ impl ResolvedTunnel {
match self { match self {
Self::Passthrough { .. } => Ok(ServerConfig { Self::Passthrough { .. } => Ok(ServerConfig {
address: Vec::new(), address: Vec::new(),
routes: Vec::new(),
dns_servers: Vec::new(),
search_domains: Vec::new(),
include_default_route: false,
name: None, name: None,
mtu: Some(1500), mtu: Some(1500),
}), }),
Self::Tailnet { .. } => Ok(ServerConfig { Self::Tailnet { .. } => Ok(ServerConfig {
address: Vec::new(), address: Vec::new(),
routes: tailnet_routes(),
dns_servers: tailnet_dns_servers(),
search_domains: Vec::new(),
include_default_route: false,
name: None, name: None,
mtu: Some(1280), mtu: Some(1280),
}), }),
@ -93,21 +111,71 @@ impl ResolvedTunnel {
pub async fn start( pub async fn start(
self, self,
tun_interface: Arc<RwLock<Option<TunInterface>>>, tun_interface: Arc<RwLock<Option<TunInterface>>>,
tailnet_helper: Option<Arc<TailscaleHelperProcess>>,
) -> Result<ActiveTunnel> { ) -> Result<ActiveTunnel> {
match self { match self {
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }), Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough {
Self::Tailnet { config, .. } => Err(anyhow::anyhow!( identity,
"tailnet runtime is not wired in this checkout yet ({:?})", server_config: ServerConfig {
config.provider 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 } => { Self::WireGuard { identity, config } => {
let server_config = ServerConfig::try_from(&config)?;
let tun = TunOptions::new().open()?; let tun = TunOptions::new().open()?;
tun_interface.write().await.replace(tun); tun_interface.write().await.replace(tun);
match start_wireguard_runtime(config, tun_interface.clone()).await { match start_wireguard_runtime(config, tun_interface.clone()).await {
Ok((interface, task)) => { Ok((interface, task)) => Ok(ActiveTunnel::WireGuard {
Ok(ActiveTunnel::WireGuard { identity, interface, task }) identity,
} server_config,
interface,
task,
}),
Err(err) => { Err(err) => {
tun_interface.write().await.take(); tun_interface.write().await.take();
Err(err) Err(err)
@ -121,9 +189,19 @@ impl ResolvedTunnel {
pub enum ActiveTunnel { pub enum ActiveTunnel {
Passthrough { Passthrough {
identity: RuntimeIdentity, identity: RuntimeIdentity,
server_config: ServerConfig,
},
Tailnet {
identity: RuntimeIdentity,
server_config: ServerConfig,
helper: Arc<TailscaleHelperProcess>,
shutdown_helper_on_stop: bool,
packet_bridge: TailnetPacketBridge,
tun_task: Option<JoinHandle<Result<()>>>,
}, },
WireGuard { WireGuard {
identity: RuntimeIdentity, identity: RuntimeIdentity,
server_config: ServerConfig,
interface: Arc<RwLock<WireGuardInterface>>, interface: Arc<RwLock<WireGuardInterface>>,
task: JoinHandle<Result<()>>, task: JoinHandle<Result<()>>,
}, },
@ -132,15 +210,69 @@ pub enum ActiveTunnel {
impl ActiveTunnel { impl ActiveTunnel {
pub fn identity(&self) -> &RuntimeIdentity { pub fn identity(&self) -> &RuntimeIdentity {
match self { match self {
Self::Passthrough { identity } Self::Passthrough { identity, .. }
| Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } => 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<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
match self {
Self::Tailnet { packet_bridge, .. } => Some((
packet_bridge.outbound_sender(),
packet_bridge.subscribe(),
)),
_ => None,
}
}
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> { pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
match self { match self {
Self::Passthrough { .. } => Ok(()), 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; interface.read().await.remove_tun().await;
let task_result = task.await; let task_result = task.await;
tun_interface.write().await.take(); tun_interface.write().await.take();
@ -151,6 +283,22 @@ impl ActiveTunnel {
} }
} }
pub struct TailnetPacketBridge {
outbound: mpsc::Sender<Vec<u8>>,
inbound: broadcast::Sender<Vec<u8>>,
task: JoinHandle<Result<()>>,
}
impl TailnetPacketBridge {
fn outbound_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.outbound.clone()
}
fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
self.inbound.subscribe()
}
}
async fn start_wireguard_runtime( async fn start_wireguard_runtime(
config: Config, config: Config,
tun_interface: Arc<RwLock<Option<TunInterface>>>, tun_interface: Arc<RwLock<Option<TunInterface>>>,
@ -166,6 +314,279 @@ async fn start_wireguard_runtime(
Ok((interface, task)) 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<TailscaleLoginStatus> {
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<String> {
vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()]
}
fn tailnet_dns_servers() -> Vec<String> {
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<TailnetPacketBridge> {
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<Vec<u8>>,
inbound_tx: broadcast::Sender<Vec<u8>>,
) -> 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<RwLock<Option<TunInterface>>>,
outbound_tx: mpsc::Sender<Vec<u8>>,
mut inbound_rx: broadcast::Receiver<Vec<u8>>,
) -> 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<R>(reader: &mut R) -> Result<Vec<u8>>
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<W>(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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -179,4 +600,19 @@ mod tests {
Vec::<String>::new() Vec::<String>::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));
}
} }

View file

@ -283,9 +283,7 @@ async fn try_tailnet_discover(email: &str) -> Result<()> {
let mut client = BurrowClient::from_uds().await?; let mut client = BurrowClient::from_uds().await?;
let response = client let response = client
.tailnet_client .tailnet_client
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() })
email: email.to_owned(),
})
.await? .await?
.into_inner(); .into_inner();
println!("Tailnet Discover Response: {:?}", response); println!("Tailnet Discover Response: {:?}", response);
@ -370,13 +368,9 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul
"tailnet ping received {} bytes from daemon packet stream", "tailnet ping received {} bytes from daemon packet stream",
packet.payload.len() packet.payload.len()
); );
if let Some(reply) = parse_icmp_echo_reply( if let Some(reply) =
&packet.payload, parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)?
local_ip, {
remote_ip,
identifier,
sequence,
)? {
break Ok::<_, anyhow::Error>(reply); break Ok::<_, anyhow::Error>(reply);
} }
} }
@ -464,8 +458,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
let egress_task = tokio::spawn(async move { let egress_task = tokio::spawn(async move {
while let Some(packet) = stack_stream.next().await { while let Some(packet) = stack_stream.next().await {
let payload = let payload = packet.context("failed to read outbound packet from userspace stack")?;
packet.context("failed to read outbound packet from userspace stack")?;
log::debug!( log::debug!(
"tailnet udp echo sending {} bytes into daemon packet stream", "tailnet udp echo sending {} bytes into daemon packet stream",
payload.len() payload.len()
@ -484,9 +477,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)) .send((message.as_bytes().to_vec(), local_addr, remote_addr))
.await .await
.context("failed to send UDP echo probe into userspace stack")?; .context("failed to send UDP echo probe into userspace stack")?;
log::debug!( log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}");
"tailnet udp echo probe queued from {local_addr} to {remote_addr}"
);
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
.await .await
@ -516,7 +507,10 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
} }
#[cfg(any(target_os = "linux", target_vendor = "apple"))] #[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result<std::net::IpAddr> { fn select_tailnet_local_ip(
addresses: &[String],
remote_ip: std::net::IpAddr,
) -> Result<std::net::IpAddr> {
use anyhow::Context; use anyhow::Context;
let family_is_v4 = remote_ip.is_ipv4(); let family_is_v4 = remote_ip.is_ipv4();

View file

@ -47,10 +47,16 @@ pub fn initialize() {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let subscriber = { let subscriber = {
let system_log = Some(tracing_oslog::OsLogger::new( // `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on
"com.hackclub.burrow", // current macOS. Keep logging on stderr by default and allow opt-in OSLog
"tracing", // 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); let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
Registry::default().with(stderr).with(system_log) Registry::default().with(stderr).with(system_log)
}; };

View file

@ -94,6 +94,7 @@
pkgs.stdenvNoCC.mkDerivation { pkgs.stdenvNoCC.mkDerivation {
pname = "nsc"; pname = "nsc";
inherit version src; inherit version src;
meta.mainProgram = "nsc";
dontConfigure = true; dontConfigure = true;
dontBuild = true; dontBuild = true;
unpackPhase = '' unpackPhase = ''
@ -144,6 +145,35 @@
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ]; subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; 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 in
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@ -171,6 +201,7 @@
packages = packages =
{ {
agenix = agenix.packages.${system}.agenix; agenix = agenix.packages.${system}.agenix;
burrow = burrowPkg;
hcloud-upload-image = hcloudUploadImagePkg; hcloud-upload-image = hcloudUploadImagePkg;
forgejo-nsc-dispatcher = forgejoNscDispatcher; forgejo-nsc-dispatcher = forgejoNscDispatcher;
forgejo-nsc-autoscaler = forgejoNscAutoscaler; forgejo-nsc-autoscaler = forgejoNscAutoscaler;
@ -183,7 +214,6 @@
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs = { specialArgs = {

View file

@ -23,7 +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/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/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/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/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge`
## Intended Flow ## Intended Flow
@ -32,15 +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/`. 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. 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 <agent@burrow.net>`. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
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. 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. 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/`. 7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`.
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. 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/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 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. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. 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 ## 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. - 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: - 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` - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21`

View file

@ -87,6 +87,24 @@ in
group = "root"; group = "root";
mode = "0400"; 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 = '' networking.extraHosts = ''
127.0.0.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
@ -112,13 +130,13 @@ in
services.forgejo-nsc = { services.forgejo-nsc = {
enable = true; enable = true;
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; nscTokenFile = config.age.secrets.burrowForgejoNscToken.path;
dispatcher = { dispatcher = {
configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml"; configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path;
}; };
autoscaler = { autoscaler = {
enable = true; enable = true;
configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml"; configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path;
}; };
}; };

View file

@ -5,6 +5,7 @@ import "google/protobuf/timestamp.proto";
service Tunnel { service Tunnel {
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse); rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket);
rpc TunnelStart (Empty) returns (Empty); rpc TunnelStart (Empty) returns (Empty);
rpc TunnelStop (Empty) returns (Empty); rpc TunnelStop (Empty) returns (Empty);
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
@ -128,4 +129,12 @@ message TunnelStatusResponse {
message TunnelConfigurationResponse { message TunnelConfigurationResponse {
repeated string addresses = 1; repeated string addresses = 1;
int32 mtu = 2; 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;
} }

View file

@ -16,6 +16,9 @@ in
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "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/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
} }

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,15 @@
age-encryption.org/v1
-> ssh-ed25519 ux4N8Q yCjzc3QW91l62Y+U2YZqLpTkiZyTJAxQQCiZ+DxHiWI
mG/+2fppo3RITeohTM/Dm1M6fsErtxhOgIeI2FqvoUs
-> ssh-ed25519 IrZmAg +Y59O8SVATZfe8Vu2gis1KNWcL34Ct7M3G34XNURczw
GGkVYcmoUtJRx4zftjLFID2wLtNtCgGVnYuMN8XF74s
-> X25519 xqDMDV9XRhSPlFy2IJPBfpUGuNA9gpX73kg8Pnj48VI
TPZZNrRUK+FzruetDFuJcTzed03d7gkxOv8QAZshBn8
--- PRD84efdrqDmPeRA8zi0D2V8RmT0tFVbDIVD6U/4KVo
Š2Wák*cS+ž+j9ƒ{° 4jñ;Š`wØd3­·«,‰"îgligÉЇþ¥ eèâ`Äü‘æ'¼ûßà'Ù®#Ñ× …"ò'(ò=LÁ¶SÀ3hºFjg¼ûYI·ŠFÐ|°Ê0$Fp<46>ÒÖ^¯Š`ª
QkñÇn˜¨œïUú“•¬®x7Ö8œbßÎ!Ìòß>nö?ú9^£ø!=Í® [a Ï` ¬Ï«¼_#޶<C5BD>?Tä̤¿@Ìø]öEçβµê¼ö­°[,Ûg퟇£Ëèàc<>àjöƒx}ö¹˜™.ÌžÿÛf4Àе5Ö•DôLH4Ìðý_H¯dwX‰åwX¿žðk÷ÜêRx7‰DMœ,0í½ 7ó˜â*ŠƒTU{Ã~ðä8yCÕûó¶ "™/oXÚCÅe8-¹“àulYtŸ ¹;ä§ÒDZdm¨¡ù ów÷×F…yÚiIæ†×öÏŽÉ…8F ¥Á¯ð}lÓø"ÒÜ´IÕøÕsuÿµ{L!ëÌ+Á™UBei¨_Zì~Œ D>åB)±ŠL§><º€R
ÓàÕÂ]Ô‹õ°:ùá`ùêªóe2ÿw˜ÌñâmP¹®ÚcSFÏføZ+Û·!þ_|V*ñŸ4®A¥ÿ‰õcAÂòÀ£ãdªx¤“H&©û
äQbË{z¹€vM¯ŸiS¹¯ fLÄŒc<Tñž²Û0d®‘ð€&ÉÕ÷¨<C3B7>¼‰R'
¸êþKo_a:<'˜ßcn
ŸŠæ”üø‡¦výmñ\?‡FPÀQNByj—tcßÀ<C39F><19>4WB}ÇÒYººsª¾!*M,@¦yKîðªöÇð$lŠ¥e<C2A5>ßÒ­¨Õµ âÀêVù\z3BûM³æ ¹‹&­rIÈþ|O„(pW)Š)
¥î•åŒÈ ÍÐm^“}uWàä*<2A>µ ®ߦOd ˆz<47 ³ú÷ä<C3B7>õ[×VePÀê½<>a¬wÿtB¬œ¶#?~ïôŒVF€J¸ÿã­w•»å:}ä