Compare commits
No commits in common. "70607e874ce710bb05823f9206735c6fe6ea259a" and "3ebb0a8e61b3420097483bf5a9f033c53e1cd5cf" have entirely different histories.
70607e874c
...
3ebb0a8e61
28 changed files with 499 additions and 2070 deletions
|
|
@ -55,7 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let statusBar = NSStatusBar.system
|
||||
let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
|
||||
if let button = statusItem.button {
|
||||
button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil)
|
||||
button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil)
|
||||
}
|
||||
return statusItem
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -1,31 +1,15 @@
|
|||
import XCTest
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class BurrowTailnetLoginUITests: XCTestCase {
|
||||
private enum TailnetLoginMode: String, Decodable {
|
||||
case tailscale
|
||||
case discovered
|
||||
}
|
||||
|
||||
private struct TestConfig: Decodable {
|
||||
let email: String
|
||||
let username: String
|
||||
let password: String
|
||||
let mode: TailnetLoginMode?
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTailnetLoginThroughAuthentikWebSession() throws {
|
||||
let config = try loadTestConfig()
|
||||
let email = config.email
|
||||
let username = config.username
|
||||
let password = config.password
|
||||
let mode = config.mode ?? .tailscale
|
||||
let browserIdentity = mode == .tailscale ? email : username
|
||||
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
|
||||
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
|
||||
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
|
@ -34,75 +18,51 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
||||
tailnetButton.tap()
|
||||
|
||||
configureTailnetIfNeeded(in: app, mode: mode)
|
||||
|
||||
let discoveryField = app.textFields["tailnet-discovery-email"]
|
||||
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
||||
replaceText(in: discoveryField, with: email)
|
||||
|
||||
let serverCard = app.descendants(matching: .any)
|
||||
.matching(identifier: "tailnet-server-card")
|
||||
.firstMatch
|
||||
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
|
||||
let findServerButton = app.buttons["tailnet-find-server"]
|
||||
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
|
||||
findServerButton.tap()
|
||||
|
||||
let discoveryCard = app.otherElements["tailnet-discovery-card"]
|
||||
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
|
||||
|
||||
let authorityField = app.textFields["tailnet-authority"]
|
||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
||||
XCTAssertTrue(
|
||||
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
|
||||
"Tailnet authority was not populated from discovery"
|
||||
)
|
||||
|
||||
let probeButton = app.buttons["tailnet-check-connection"]
|
||||
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
|
||||
probeButton.tap()
|
||||
|
||||
let probeCard = app.otherElements["tailnet-authority-probe-card"]
|
||||
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
|
||||
|
||||
let signInButton = app.buttons["tailnet-start-sign-in"]
|
||||
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
||||
signInButton.tap()
|
||||
|
||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
|
||||
acceptAuthenticationPromptIfNeeded(in: app)
|
||||
|
||||
let webSession = webAuthenticationSession()
|
||||
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
||||
|
||||
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
|
||||
signIntoAuthentik(in: webSession, username: username, password: password)
|
||||
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
waitForTailnetSignedIn(in: app, timeout: 60),
|
||||
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
|
||||
"Tailnet sign-in never reached the running state"
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
repeat {
|
||||
let promptCandidates = [
|
||||
springboard.buttons["Continue"],
|
||||
springboard.buttons["Allow"],
|
||||
|
|
@ -110,22 +70,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.exists && button.isHittable {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||
} while Date() < deadline
|
||||
|
||||
let promptCandidates = [
|
||||
springboard.buttons["Continue"],
|
||||
springboard.buttons["Allow"],
|
||||
app.buttons["Continue"],
|
||||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.exists {
|
||||
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
|
|
@ -143,19 +88,6 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
||||
followTailnetRedirectIfNeeded(in: webSession)
|
||||
|
||||
if !webSession.exists {
|
||||
return
|
||||
}
|
||||
|
||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||
if immediatePasswordField.exists {
|
||||
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
|
||||
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
|
||||
return
|
||||
}
|
||||
|
||||
let usernameField = firstExistingElement(
|
||||
in: webSession,
|
||||
queries: [
|
||||
|
|
@ -167,12 +99,21 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
{ $0.webViews.textFields["Email or Username"] },
|
||||
{ $0.descendants(matching: .textField).firstMatch },
|
||||
],
|
||||
timeout: 12
|
||||
timeout: 25
|
||||
)
|
||||
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
|
||||
replaceText(in: usernameField, with: username)
|
||||
|
||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||
if immediatePasswordField.exists {
|
||||
replaceSecureText(in: immediatePasswordField, with: password)
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
if !usernameField.exists {
|
||||
return
|
||||
}
|
||||
replaceText(in: usernameField, with: username)
|
||||
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
|
|
@ -182,31 +123,21 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
|
||||
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
||||
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
||||
replaceSecureText(in: passwordField, within: webSession, with: password)
|
||||
submitAuthenticationForm(in: webSession, focusedField: passwordField)
|
||||
}
|
||||
|
||||
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
|
||||
let redirectCandidates = [
|
||||
webSession.links["Found"],
|
||||
webSession.webViews.links["Found"],
|
||||
webSession.buttons["Found"],
|
||||
webSession.webViews.buttons["Found"],
|
||||
]
|
||||
|
||||
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
|
||||
if redirectLink.exists {
|
||||
redirectLink.tap()
|
||||
}
|
||||
replaceSecureText(in: passwordField, with: password)
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
}
|
||||
|
||||
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
||||
let candidates = [
|
||||
app.descendants(matching: .secureTextField).firstMatch,
|
||||
app.secureTextFields["Password"],
|
||||
app.secureTextFields["Password or Token"],
|
||||
app.webViews.secureTextFields["Password"],
|
||||
app.webViews.secureTextFields["Password or Token"],
|
||||
app.descendants(matching: .secureTextField).firstMatch,
|
||||
]
|
||||
|
||||
return firstExistingElement(from: candidates, timeout: timeout)
|
||||
|
|
@ -229,92 +160,11 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
button.tap()
|
||||
}
|
||||
|
||||
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
|
||||
focus(focusedField)
|
||||
focusedField.typeText("\n")
|
||||
if waitForAny(
|
||||
[
|
||||
{ !focusedField.exists },
|
||||
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
|
||||
],
|
||||
timeout: 1.5
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let keyboard = app.keyboards.firstMatch
|
||||
if keyboard.waitForExistence(timeout: 2) {
|
||||
let keyboardCandidates = [
|
||||
"Return",
|
||||
"return",
|
||||
"Go",
|
||||
"go",
|
||||
"Continue",
|
||||
"continue",
|
||||
"Done",
|
||||
"done",
|
||||
"Join",
|
||||
"join",
|
||||
"Sign In",
|
||||
"Log In",
|
||||
"Login",
|
||||
]
|
||||
for title in keyboardCandidates {
|
||||
let key = keyboard.buttons[title]
|
||||
if key.exists && key.isHittable {
|
||||
key.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
|
||||
lastKey.exists,
|
||||
lastKey.isHittable
|
||||
{
|
||||
lastKey.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tapFirstExistingButton(
|
||||
in: app,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
}
|
||||
|
||||
private func loadTestConfig() throws -> TestConfig {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
|
||||
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||
{
|
||||
return TestConfig(
|
||||
email: email,
|
||||
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
|
||||
password: password,
|
||||
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
|
||||
.flatMap(TailnetLoginMode.init(rawValue:))
|
||||
)
|
||||
}
|
||||
|
||||
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
|
||||
let configURL = URL(fileURLWithPath: configPath)
|
||||
guard FileManager.default.fileExists(atPath: configURL.path) else {
|
||||
throw XCTSkip(
|
||||
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
|
||||
)
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: configURL)
|
||||
return try JSONDecoder().decode(TestConfig.self, from: data)
|
||||
}
|
||||
|
||||
private func nonEmptyEnvironment(_ key: String) -> String? {
|
||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
private func requiredEnvironment(_ key: String) throws -> String {
|
||||
guard let value = ProcessInfo.processInfo.environment[key],
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return nil
|
||||
throw XCTSkip("Missing required UI test environment variable \(key)")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
@ -339,32 +189,6 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let button = app.buttons["tailnet-start-sign-in"]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
repeat {
|
||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
|
||||
if button.exists, button.label == "Signed In" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
} while Date() < deadline
|
||||
|
||||
return button.exists && button.label == "Signed In"
|
||||
}
|
||||
|
||||
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if conditions.contains(where: { $0() }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
} while Date() < deadline
|
||||
return conditions.contains(where: { $0() })
|
||||
}
|
||||
|
||||
private func firstExistingElement(
|
||||
in app: XCUIApplication,
|
||||
queries: [(XCUIApplication) -> XCUIElement],
|
||||
|
|
@ -386,27 +210,14 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func replaceText(in element: XCUIElement, with value: String) {
|
||||
focus(element)
|
||||
element.tap()
|
||||
clearText(in: element)
|
||||
element.typeText(value)
|
||||
}
|
||||
|
||||
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
|
||||
UIPasteboard.general.string = value
|
||||
focus(element)
|
||||
for revealMenu in [
|
||||
{ element.doubleTap() },
|
||||
{ element.press(forDuration: 1.2) },
|
||||
] {
|
||||
revealMenu()
|
||||
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
|
||||
if pasteButton.exists {
|
||||
pasteButton.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
focus(element)
|
||||
private func replaceSecureText(in element: XCUIElement, with value: String) {
|
||||
element.tap()
|
||||
clearText(in: element)
|
||||
element.typeText(value)
|
||||
}
|
||||
|
||||
|
|
@ -418,22 +229,4 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||
element.typeText(deleteSequence)
|
||||
}
|
||||
|
||||
private func focus(_ element: XCUIElement) {
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
}
|
||||
|
||||
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
|
||||
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
|
||||
return pasteLabels.flatMap { label in
|
||||
[
|
||||
app.menuItems[label],
|
||||
app.buttons[label],
|
||||
app.webViews.buttons[label],
|
||||
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,13 @@ public enum Constants {
|
|||
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
|
||||
#if targetEnvironment(simulator)
|
||||
Result {
|
||||
// The simulator app's Application Support path lives inside its sandbox container,
|
||||
// so the host daemon cannot reach it. Use a shared host temp location instead.
|
||||
let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
|
||||
let baseURL = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let url = baseURL
|
||||
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
|
||||
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
|
|
|
|||
|
|
@ -108,13 +108,6 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
|
|||
public init() {}
|
||||
}
|
||||
|
||||
public struct Burrow_TunnelPacket: Sendable {
|
||||
public var payload = Data()
|
||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
|
|
@ -394,29 +387,6 @@ 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 let channel: GRPCChannel
|
||||
public var defaultCallOptions: CallOptions
|
||||
|
|
@ -486,23 +456,3 @@ 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: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,14 +215,6 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
|
|||
|
||||
public var mtu: Int32 = 0
|
||||
|
||||
public var routes: [String] = []
|
||||
|
||||
public var dnsServers: [String] = []
|
||||
|
||||
public var searchDomains: [String] = []
|
||||
|
||||
public var includeDefaultRoute: Bool = false
|
||||
|
||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
public init() {}
|
||||
|
|
@ -540,10 +532,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
|||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
1: .same(proto: "addresses"),
|
||||
2: .same(proto: "mtu"),
|
||||
3: .same(proto: "routes"),
|
||||
4: .standard(proto: "dns_servers"),
|
||||
5: .standard(proto: "search_domains"),
|
||||
6: .standard(proto: "include_default_route"),
|
||||
]
|
||||
|
||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
|
|
@ -554,10 +542,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
|||
switch fieldNumber {
|
||||
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
|
||||
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
|
||||
case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }()
|
||||
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
|
||||
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
|
||||
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
|
@ -570,28 +554,12 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
|||
if self.mtu != 0 {
|
||||
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
|
||||
}
|
||||
if !self.routes.isEmpty {
|
||||
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
|
||||
}
|
||||
if !self.dnsServers.isEmpty {
|
||||
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
|
||||
}
|
||||
if !self.searchDomains.isEmpty {
|
||||
try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5)
|
||||
}
|
||||
if self.includeDefaultRoute {
|
||||
try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
|
||||
if lhs.addresses != rhs.addresses {return false}
|
||||
if lhs.mtu != rhs.mtu {return false}
|
||||
if lhs.routes != rhs.routes {return false}
|
||||
if lhs.dnsServers != rhs.dnsServers {return false}
|
||||
if lhs.searchDomains != rhs.searchDomains {return false}
|
||||
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}
|
||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import AsyncAlgorithms
|
||||
import BurrowConfiguration
|
||||
import BurrowCore
|
||||
import GRPC
|
||||
import libburrow
|
||||
import NetworkExtension
|
||||
import os
|
||||
|
|
@ -20,9 +19,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||
}
|
||||
|
||||
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 {
|
||||
get throws { try _client.get() }
|
||||
|
|
@ -49,18 +45,16 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||
let completion = SendableCallbackBox(completionHandler)
|
||||
Task {
|
||||
do {
|
||||
_ = try await client.tunnelStart(.init())
|
||||
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
|
||||
guard let settings = configuration?.settings else {
|
||||
throw Error.missingTunnelConfiguration
|
||||
}
|
||||
try await setTunnelNetworkSettings(settings)
|
||||
try startPacketBridge()
|
||||
_ = try await client.tunnelStart(.init())
|
||||
logger.log("Started tunnel with network settings: \(settings)")
|
||||
completion.callback(nil)
|
||||
} catch {
|
||||
logger.error("Failed to start tunnel: \(error)")
|
||||
stopPacketBridge()
|
||||
completion.callback(error)
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +66,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||
) {
|
||||
let completion = SendableCallbackBox(completionHandler)
|
||||
Task {
|
||||
stopPacketBridge()
|
||||
do {
|
||||
_ = try await client.tunnelStop(.init())
|
||||
logger.log("Stopped client")
|
||||
|
|
@ -84,243 +77,20 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
extension PacketTunnelProvider {
|
||||
private func startPacketBridge() throws {
|
||||
stopPacketBridge()
|
||||
|
||||
let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL)
|
||||
let call = packetClient.makeTunnelPacketsCall()
|
||||
self.packetCall = call
|
||||
|
||||
inboundPacketTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
for try await packet in call.responseStream {
|
||||
let payload = packet.payload
|
||||
self.packetFlow.writePackets(
|
||||
[payload],
|
||||
withProtocols: [Self.protocolNumber(for: payload)]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
self.logger.error("Tunnel packet receive loop failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
outboundPacketTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { call.requestStream.finish() }
|
||||
do {
|
||||
while !Task.isCancelled {
|
||||
let packets = await self.readPacketsBatch()
|
||||
for (payload, _) in packets {
|
||||
var packet = Burrow_TunnelPacket()
|
||||
packet.payload = payload
|
||||
try await call.requestStream.send(packet)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
self.logger.error("Tunnel packet send loop failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPacketBridge() {
|
||||
inboundPacketTask?.cancel()
|
||||
inboundPacketTask = nil
|
||||
outboundPacketTask?.cancel()
|
||||
outboundPacketTask = nil
|
||||
packetCall?.cancel()
|
||||
packetCall = nil
|
||||
}
|
||||
|
||||
private func readPacketsBatch() async -> [(Data, NSNumber)] {
|
||||
await withCheckedContinuation { continuation in
|
||||
packetFlow.readPackets { packets, protocols in
|
||||
continuation.resume(returning: Array(zip(packets, protocols)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func protocolNumber(for payload: Data) -> NSNumber {
|
||||
guard let version = payload.first.map({ $0 >> 4 }) else {
|
||||
return NSNumber(value: AF_INET)
|
||||
}
|
||||
switch version {
|
||||
case 6:
|
||||
return NSNumber(value: AF_INET6)
|
||||
default:
|
||||
return NSNumber(value: AF_INET)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Burrow_TunnelConfigurationResponse {
|
||||
fileprivate var settings: NEPacketTunnelNetworkSettings {
|
||||
let 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 ipv6Addresses = addresses.filter { IPv6Address($0) != nil }
|
||||
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
|
||||
settings.mtu = NSNumber(value: mtu)
|
||||
if !ipv4Addresses.isEmpty {
|
||||
let ipv4Settings = NEIPv4Settings(
|
||||
addresses: ipv4Addresses.map(\.address),
|
||||
subnetMasks: ipv4Addresses.map(\.subnetMask)
|
||||
settings.ipv4Settings = NEIPv4Settings(
|
||||
addresses: addresses.filter { IPv4Address($0) != nil },
|
||||
subnetMasks: ["255.255.255.0"]
|
||||
)
|
||||
if !ipv4Routes.isEmpty {
|
||||
ipv4Settings.includedRoutes = ipv4Routes
|
||||
}
|
||||
settings.ipv4Settings = ipv4Settings
|
||||
}
|
||||
if !ipv6Addresses.isEmpty {
|
||||
let ipv6Settings = NEIPv6Settings(
|
||||
addresses: ipv6Addresses.map(\.address),
|
||||
networkPrefixLengths: ipv6Addresses.map(\.prefixLength)
|
||||
settings.ipv6Settings = NEIPv6Settings(
|
||||
addresses: ipv6Addresses,
|
||||
networkPrefixLengths: ipv6Addresses.map { _ in 64 }
|
||||
)
|
||||
if !ipv6Routes.isEmpty {
|
||||
ipv6Settings.includedRoutes = ipv6Routes
|
||||
}
|
||||
settings.ipv6Settings = ipv6Settings
|
||||
}
|
||||
if !dnsServers.isEmpty {
|
||||
let dnsSettings = NEDNSSettings(servers: dnsServers)
|
||||
if !searchDomains.isEmpty {
|
||||
dnsSettings.matchDomains = searchDomains
|
||||
}
|
||||
settings.dnsSettings = dnsSettings
|
||||
}
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParsedTunnelAddress {
|
||||
struct IPv4AddressSetting {
|
||||
let address: String
|
||||
let subnetMask: String
|
||||
}
|
||||
|
||||
struct IPv6AddressSetting {
|
||||
let address: String
|
||||
let prefixLength: NSNumber
|
||||
}
|
||||
|
||||
let ipv4Address: IPv4AddressSetting?
|
||||
let ipv6Address: IPv6AddressSetting?
|
||||
|
||||
init?(rawValue: String) {
|
||||
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
|
||||
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !address.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let prefix = components.count == 2 ? Int(components[1]) : nil
|
||||
if IPv4Address(address) != nil {
|
||||
let prefixLength = prefix ?? 32
|
||||
guard (0 ... 32).contains(prefixLength) else {
|
||||
return nil
|
||||
}
|
||||
ipv4Address = IPv4AddressSetting(
|
||||
address: address,
|
||||
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
|
||||
)
|
||||
ipv6Address = nil
|
||||
return
|
||||
}
|
||||
|
||||
if IPv6Address(address) != nil {
|
||||
let prefixLength = prefix ?? 128
|
||||
guard (0 ... 128).contains(prefixLength) else {
|
||||
return nil
|
||||
}
|
||||
ipv4Address = nil
|
||||
ipv6Address = IPv6AddressSetting(
|
||||
address: address,
|
||||
prefixLength: NSNumber(value: prefixLength)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func ipv4SubnetMask(prefixLength: Int) -> String {
|
||||
guard prefixLength > 0 else {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
let mask = UInt32.max << (32 - prefixLength)
|
||||
let octets = [
|
||||
(mask >> 24) & 0xff,
|
||||
(mask >> 16) & 0xff,
|
||||
(mask >> 8) & 0xff,
|
||||
mask & 0xff,
|
||||
]
|
||||
return octets.map(String.init).joined(separator: ".")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParsedTunnelRoute {
|
||||
let ipv4Route: NEIPv4Route?
|
||||
let ipv6Route: NEIPv6Route?
|
||||
|
||||
init?(rawValue: String) {
|
||||
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
|
||||
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !address.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let prefix = components.count == 2 ? Int(components[1]) : nil
|
||||
if IPv4Address(address) != nil {
|
||||
let prefixLength = prefix ?? 32
|
||||
guard (0 ... 32).contains(prefixLength) else {
|
||||
return nil
|
||||
}
|
||||
ipv4Route = NEIPv4Route(
|
||||
destinationAddress: address,
|
||||
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
|
||||
)
|
||||
ipv6Route = nil
|
||||
return
|
||||
}
|
||||
|
||||
if IPv6Address(address) != nil {
|
||||
let prefixLength = prefix ?? 128
|
||||
guard (0 ... 128).contains(prefixLength) else {
|
||||
return nil
|
||||
}
|
||||
ipv4Route = nil
|
||||
ipv6Route = NEIPv6Route(
|
||||
destinationAddress: address,
|
||||
networkPrefixLength: NSNumber(value: prefixLength)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func ipv4SubnetMask(prefixLength: Int) -> String {
|
||||
var mask = UInt32.max << (32 - prefixLength)
|
||||
if prefixLength == 0 {
|
||||
mask = 0
|
||||
}
|
||||
let octets = [
|
||||
String((mask >> 24) & 0xff),
|
||||
String((mask >> 16) & 0xff),
|
||||
String((mask >> 8) & 0xff),
|
||||
String(mask & 0xff),
|
||||
]
|
||||
return octets.joined(separator: ".")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ public struct BurrowView: View {
|
|||
ContentUnavailableView(
|
||||
"No Accounts Yet",
|
||||
systemImage: "person.crop.circle.badge.plus",
|
||||
description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.")
|
||||
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
|
|
@ -135,7 +135,7 @@ public struct BurrowView: View {
|
|||
private func runAutomationIfNeeded() {
|
||||
guard !didRunAutomation,
|
||||
let automation = BurrowAutomationConfig.current,
|
||||
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
||||
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
|
@ -340,12 +340,8 @@ private struct ConfigurationSheetView: View {
|
|||
@State private var isStartingTailnetLogin = false
|
||||
@State private var tailnetPresentedAuthURL: URL?
|
||||
@State private var preserveTailnetLoginSession = false
|
||||
@State private var usesCustomTailnetAuthority = false
|
||||
@State private var showsAdvancedTailnetSettings = false
|
||||
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
||||
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
||||
@State private var tailnetDiscoveryTask: Task<Void, Never>?
|
||||
@State private var tailnetProbeTask: Task<Void, Never>?
|
||||
@State private var didRunAutomation = false
|
||||
|
||||
init(
|
||||
|
|
@ -368,9 +364,14 @@ private struct ConfigurationSheetView: View {
|
|||
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if showsIdentitySection {
|
||||
Section("Identity") {
|
||||
identityFields
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -457,15 +458,9 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
.onChange(of: draft.authority) { _, _ in
|
||||
resetAuthorityProbe()
|
||||
if sheet == .tailnet, usesCustomTailnetAuthority {
|
||||
scheduleTailnetAuthorityProbe()
|
||||
}
|
||||
}
|
||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||
resetTailnetDiscoveryFeedback()
|
||||
if sheet == .tailnet, !usesCustomTailnetAuthority {
|
||||
scheduleTailnetDiscovery()
|
||||
}
|
||||
}
|
||||
.onChange(of: draft.authMode) { _, newMode in
|
||||
guard newMode != .web else { return }
|
||||
|
|
@ -475,8 +470,6 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
.onDisappear {
|
||||
tailnetLoginPollTask?.cancel()
|
||||
tailnetDiscoveryTask?.cancel()
|
||||
tailnetProbeTask?.cancel()
|
||||
browserAuthenticator.cancel()
|
||||
if !preserveTailnetLoginSession {
|
||||
Task { @MainActor in
|
||||
|
|
@ -486,18 +479,6 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var identityFields: some View {
|
||||
TextField("Title", text: $draft.title)
|
||||
TextField("Account", text: $draft.accountName)
|
||||
TextField("Identity", text: $draft.identityName)
|
||||
if sheet == .tailnet {
|
||||
TextField("Hostname", text: $draft.hostname)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tailnetSections: some View {
|
||||
Section("Connection") {
|
||||
|
|
@ -506,39 +487,67 @@ private struct ConfigurationSheetView: View {
|
|||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("tailnet-discovery-email")
|
||||
.submitLabel(.continue)
|
||||
.onSubmit {
|
||||
if !usesCustomTailnetAuthority {
|
||||
scheduleTailnetDiscovery(immediate: true)
|
||||
|
||||
Button {
|
||||
discoverTailnetAuthority()
|
||||
} label: {
|
||||
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")
|
||||
|
||||
tailnetServerCard
|
||||
if let discoveryStatus {
|
||||
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
|
||||
} else if let discoveryError {
|
||||
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
||||
}
|
||||
|
||||
if showsAdvancedTailnetSettings {
|
||||
if usesCustomTailnetAuthority {
|
||||
TextField("Server URL", text: $draft.authority)
|
||||
TextField("Authority URL", text: $draft.authority)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("tailnet-authority")
|
||||
} else {
|
||||
|
||||
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
probeTailnetAuthority()
|
||||
} label: {
|
||||
Label {
|
||||
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
|
||||
} icon: {
|
||||
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
|
||||
}
|
||||
}
|
||||
.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") {
|
||||
if showsAdvancedTailnetSettings {
|
||||
Picker("Authentication", selection: $draft.authMode) {
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
if draft.authMode == .web {
|
||||
Button {
|
||||
|
|
@ -551,7 +560,7 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
|
||||
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
|
||||
.accessibilityIdentifier("tailnet-start-sign-in")
|
||||
|
||||
if let tailnetLoginStatus {
|
||||
|
|
@ -607,14 +616,32 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
if sheet == .tailnet {
|
||||
labeledValue("Server", tailnetServerDisplayLabel)
|
||||
if let connectionSummary = tailnetConnectionSummary {
|
||||
Text(connectionSummary)
|
||||
if let authorityProbeStatus {
|
||||
Text(authorityProbeStatus.summary)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(tailnetConnectionSummaryColor)
|
||||
.foregroundStyle(.primary)
|
||||
if let detail = authorityProbeStatus.detail {
|
||||
Text(detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
if tailnetLoginStatus?.running == true {
|
||||
} else if let authorityProbeError {
|
||||
Text("Connection failed")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.red)
|
||||
Text(authorityProbeError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
|
||||
if sheet == .tailnet {
|
||||
HStack(spacing: 8) {
|
||||
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
||||
summaryBadge(draft.authMode.title)
|
||||
if tailnetLoginStatus?.running == true {
|
||||
summaryBadge("Signed In")
|
||||
}
|
||||
}
|
||||
|
|
@ -627,44 +654,6 @@ private struct ConfigurationSheetView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var tailnetServerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(usesCustomTailnetAuthority ? "Custom Server" : "Server")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(tailnetServerDisplayLabel)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isDiscoveringTailnet || isProbingAuthority {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if let summary = tailnetConnectionSummary {
|
||||
Text(summary)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(tailnetConnectionSummaryColor)
|
||||
}
|
||||
}
|
||||
|
||||
if let detail = tailnetServerDetail {
|
||||
Text(detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
.accessibilityIdentifier("tailnet-server-card")
|
||||
}
|
||||
|
||||
private func tailnetAuthorityProbeCard(
|
||||
status: TailnetAuthorityProbeStatus?,
|
||||
failure: String?
|
||||
|
|
@ -838,15 +827,11 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
case .tailnet:
|
||||
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
|
||||
toggleTailnetAuthorityMode()
|
||||
Button("Use Tailscale Managed Server") {
|
||||
applyTailnetDefaults(for: .tailscale)
|
||||
}
|
||||
|
||||
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
|
||||
showsAdvancedTailnetSettings.toggle()
|
||||
}
|
||||
|
||||
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
|
||||
if availableTailnetAuthModes.count > 1 {
|
||||
Menu("Authentication") {
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Button(mode.title) {
|
||||
|
|
@ -859,10 +844,9 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Button("Refresh Server Lookup") {
|
||||
scheduleTailnetDiscovery(immediate: true)
|
||||
Button("Clear Discovery Result") {
|
||||
resetTailnetDiscoveryFeedback()
|
||||
}
|
||||
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -901,21 +885,12 @@ private struct ConfigurationSheetView: View {
|
|||
|
||||
private var showsBottomActionButton: Bool {
|
||||
#if os(iOS)
|
||||
return true
|
||||
true
|
||||
#else
|
||||
return false
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var showsIdentitySection: Bool {
|
||||
switch sheet {
|
||||
case .wireGuard, .tor:
|
||||
return true
|
||||
case .tailnet:
|
||||
return showsAdvancedTailnetSettings
|
||||
}
|
||||
}
|
||||
|
||||
private var wireGuardEditorHeight: CGFloat {
|
||||
#if os(iOS)
|
||||
180
|
||||
|
|
@ -935,18 +910,6 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var tailnetLoginActionDisabled: Bool {
|
||||
switch sheet {
|
||||
case .tailnet:
|
||||
if usesCustomTailnetAuthority {
|
||||
return normalizedOptional(draft.authority) == nil
|
||||
}
|
||||
return false
|
||||
case .wireGuard, .tor:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var submissionDisabled: Bool {
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
|
|
@ -970,50 +933,6 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var tailnetServerDisplayLabel: String {
|
||||
if usesCustomTailnetAuthority {
|
||||
return normalizedOptional(draft.authority)
|
||||
?? "Enter a custom Tailnet server"
|
||||
}
|
||||
return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed"
|
||||
}
|
||||
|
||||
private var tailnetServerDetail: String? {
|
||||
if usesCustomTailnetAuthority {
|
||||
if let discovery = discoveryStatus {
|
||||
return "Discovered from \(discovery.domain)."
|
||||
}
|
||||
if let discoveryError {
|
||||
return discoveryError
|
||||
}
|
||||
return "Use a custom Tailnet authority when your domain does not advertise one."
|
||||
}
|
||||
return "Continue with Tailscale, or open advanced settings to use a custom server."
|
||||
}
|
||||
|
||||
private var tailnetConnectionSummary: String? {
|
||||
if isDiscoveringTailnet {
|
||||
return "Finding server"
|
||||
}
|
||||
if isProbingAuthority {
|
||||
return "Checking"
|
||||
}
|
||||
if let authorityProbeStatus {
|
||||
return authorityProbeStatus.summary
|
||||
}
|
||||
if authorityProbeError != nil {
|
||||
return "Unavailable"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var tailnetConnectionSummaryColor: Color {
|
||||
if authorityProbeError != nil {
|
||||
return .red
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
|
|
@ -1102,7 +1021,7 @@ private struct ConfigurationSheetView: View {
|
|||
guard !didRunAutomation,
|
||||
sheet == .tailnet,
|
||||
let automation = BurrowAutomationConfig.current,
|
||||
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
||||
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
|
@ -1118,9 +1037,7 @@ private struct ConfigurationSheetView: View {
|
|||
case .tailnetLogin:
|
||||
applyTailnetDefaults(for: .tailscale)
|
||||
startTailnetLogin()
|
||||
case .tailnetProbe:
|
||||
usesCustomTailnetAuthority = true
|
||||
showsAdvancedTailnetSettings = true
|
||||
case .headscaleProbe:
|
||||
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
||||
probeTailnetAuthority()
|
||||
}
|
||||
|
|
@ -1143,13 +1060,10 @@ private struct ConfigurationSheetView: View {
|
|||
)
|
||||
|
||||
var noteParts: [String] = [
|
||||
"Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
|
||||
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
|
||||
"Auth: \(draft.authMode.title)",
|
||||
]
|
||||
|
||||
if showsAdvancedTailnetSettings || draft.authMode != .web {
|
||||
noteParts.append("Auth: \(draft.authMode.title)")
|
||||
}
|
||||
|
||||
if draft.authMode == .web, tailnetLoginStatus?.running == true {
|
||||
noteParts.append("Browser sign-in complete")
|
||||
}
|
||||
|
|
@ -1205,7 +1119,6 @@ private struct ConfigurationSheetView: View {
|
|||
|
||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||
resetTailnetDiscoveryFeedback()
|
||||
usesCustomTailnetAuthority = provider != .tailscale
|
||||
draft.authority = provider.defaultAuthority ?? ""
|
||||
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||
draft.authMode = .web
|
||||
|
|
@ -1213,6 +1126,12 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
private func startTailnetLogin() {
|
||||
guard let authority = normalizedOptional(draft.authority) else {
|
||||
tailnetLoginStatus = nil
|
||||
tailnetLoginError = "Enter a server URL first."
|
||||
return
|
||||
}
|
||||
|
||||
isStartingTailnetLogin = true
|
||||
tailnetLoginError = nil
|
||||
preserveTailnetLoginSession = false
|
||||
|
|
@ -1220,7 +1139,6 @@ private struct ConfigurationSheetView: View {
|
|||
Task { @MainActor in
|
||||
defer { isStartingTailnetLogin = false }
|
||||
do {
|
||||
let authority = try await resolveTailnetAuthorityForLogin()
|
||||
let status = try await networkViewModel.startTailnetLogin(
|
||||
accountName: normalized(draft.accountName, fallback: "default"),
|
||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||
|
|
@ -1258,14 +1176,12 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
private func resetAuthorityProbe() {
|
||||
tailnetProbeTask?.cancel()
|
||||
authorityProbeStatus = nil
|
||||
authorityProbeError = nil
|
||||
tailnetLoginError = nil
|
||||
}
|
||||
|
||||
private func resetTailnetDiscoveryFeedback() {
|
||||
tailnetDiscoveryTask?.cancel()
|
||||
discoveryStatus = nil
|
||||
discoveryError = nil
|
||||
}
|
||||
|
|
@ -1294,83 +1210,6 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func scheduleTailnetDiscovery(immediate: Bool = false) {
|
||||
guard sheet == .tailnet else { return }
|
||||
tailnetDiscoveryTask?.cancel()
|
||||
|
||||
guard !usesCustomTailnetAuthority else {
|
||||
discoveryStatus = nil
|
||||
discoveryError = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard normalizedOptional(draft.discoveryEmail) != nil else {
|
||||
discoveryStatus = nil
|
||||
discoveryError = nil
|
||||
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||
return
|
||||
}
|
||||
|
||||
tailnetDiscoveryTask = Task { @MainActor in
|
||||
if !immediate {
|
||||
try? await Task.sleep(for: .milliseconds(450))
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
discoverTailnetAuthority()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTailnetAuthorityProbe() {
|
||||
guard sheet == .tailnet else { return }
|
||||
tailnetProbeTask?.cancel()
|
||||
guard normalizedOptional(draft.authority) != nil else { return }
|
||||
|
||||
tailnetProbeTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
probeTailnetAuthority()
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleTailnetAuthorityMode() {
|
||||
let discoveredAuthority = discoveryStatus?.authority
|
||||
usesCustomTailnetAuthority.toggle()
|
||||
resetTailnetDiscoveryFeedback()
|
||||
resetAuthorityProbe()
|
||||
if usesCustomTailnetAuthority {
|
||||
draft.authority = discoveredAuthority ?? draft.authority
|
||||
} else {
|
||||
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||
scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveTailnetAuthorityForLogin() async throws -> String {
|
||||
if !usesCustomTailnetAuthority {
|
||||
let authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||
draft.authority = authority
|
||||
scheduleTailnetAuthorityProbe()
|
||||
return authority
|
||||
}
|
||||
|
||||
if let authority = normalizedOptional(draft.authority) {
|
||||
return authority
|
||||
}
|
||||
|
||||
if let email = normalizedOptional(draft.discoveryEmail) {
|
||||
let discovery = try await networkViewModel.discoverTailnet(email: email)
|
||||
discoveryStatus = discovery
|
||||
discoveryError = nil
|
||||
draft.authority = discovery.authority
|
||||
scheduleTailnetAuthorityProbe()
|
||||
return discovery.authority
|
||||
}
|
||||
|
||||
throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first."
|
||||
])
|
||||
}
|
||||
|
||||
private func beginTailnetLoginPolling(sessionID: String) {
|
||||
tailnetLoginPollTask?.cancel()
|
||||
tailnetLoginPollTask = Task { @MainActor in
|
||||
|
|
@ -1497,16 +1336,13 @@ private struct ConfigurationSheetView: View {
|
|||
if tailnetLoginSessionID != nil {
|
||||
return "Resume Sign-In"
|
||||
}
|
||||
return "Continue with Tailscale"
|
||||
return "Start Sign-In"
|
||||
}
|
||||
|
||||
private var tailnetAuthenticationFootnote: String {
|
||||
switch draft.authMode {
|
||||
case .web:
|
||||
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."
|
||||
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."
|
||||
case .none:
|
||||
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
||||
case .password, .preauthKey:
|
||||
|
|
@ -1521,6 +1357,10 @@ private struct ConfigurationSheetView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var isManagedTailnetAuthority: Bool {
|
||||
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
|
@ -1543,7 +1383,12 @@ private struct AccountRowView: View {
|
|||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(account.title)
|
||||
.font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Text(account.kind.title)
|
||||
if let provider = account.provider {
|
||||
Text(provider.title)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(account.kind.accentColor)
|
||||
}
|
||||
|
|
@ -1625,12 +1470,6 @@ private extension View {
|
|||
@MainActor
|
||||
private final class TailnetBrowserAuthenticator: NSObject {
|
||||
private var session: ASWebAuthenticationSession?
|
||||
private static var prefersEphemeralSessionForCurrentProcess: Bool {
|
||||
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
|
||||
}
|
||||
|
||||
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
||||
cancel()
|
||||
|
|
@ -1638,7 +1477,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
|
|||
onDismiss()
|
||||
}
|
||||
session.presentationContextProvider = self
|
||||
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
self.session = session
|
||||
_ = session.start()
|
||||
}
|
||||
|
|
@ -1677,7 +1516,7 @@ private final class TailnetBrowserAuthenticator {
|
|||
private struct BurrowAutomationConfig {
|
||||
enum Action: String {
|
||||
case tailnetLogin = "tailnet-login"
|
||||
case tailnetProbe = "tailnet-probe"
|
||||
case headscaleProbe = "headscale-probe"
|
||||
}
|
||||
|
||||
let action: Action
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
|||
var title: String {
|
||||
switch self {
|
||||
case .tailscale: "Tailscale"
|
||||
case .headscale: "Custom Tailnet"
|
||||
case .headscale: "Headscale"
|
||||
case .burrow: "Burrow"
|
||||
}
|
||||
}
|
||||
|
|
@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
|||
switch self {
|
||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||
case .tor: "Store Arti account and identity preferences."
|
||||
case .tailnet: "Save Tailnet authority, identity defaults, and login material."
|
||||
case .tailnet: "Save Tailnet authority, identity, and login material."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -402,7 +402,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
|||
case .tor:
|
||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
||||
case .tailnet:
|
||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon."
|
||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,14 +164,6 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then
|
|||
test -s /run/agenix/burrowHeadscaleOidcClientSecret
|
||||
fi
|
||||
|
||||
if [[ "${EXPECT_NSC}" == "1" ]]; then
|
||||
echo "== agenix-nsc =="
|
||||
ls -l /run/agenix || true
|
||||
test -s /run/agenix/burrowForgejoNscToken
|
||||
test -s /run/agenix/burrowForgejoNscDispatcherConfig
|
||||
test -s /run/agenix/burrowForgejoNscAutoscalerConfig
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo "== http-local =="
|
||||
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
|
||||
|
|
|
|||
|
|
@ -5,18 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
||||
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
||||
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
||||
simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}"
|
||||
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
||||
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
||||
fallback_dir="/tmp/${bundle_id}/SimulatorFallback"
|
||||
fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback"
|
||||
socket_path="${fallback_dir}/burrow.sock"
|
||||
tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState"
|
||||
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
||||
ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}"
|
||||
ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner"
|
||||
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
||||
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
||||
ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}"
|
||||
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
||||
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
||||
|
||||
|
|
@ -30,60 +25,10 @@ if [[ -z "$ui_test_password" ]]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
rm -rf "$fallback_dir" "$tailnet_state_root"
|
||||
mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path"
|
||||
mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path"
|
||||
rm -f "$socket_path"
|
||||
|
||||
resolve_simulator_id() {
|
||||
xcrun simctl list devices available -j | python3 -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
target_name = sys.argv[1]
|
||||
target_os = sys.argv[2]
|
||||
target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-")
|
||||
devices = json.load(sys.stdin).get("devices", {})
|
||||
healthy = []
|
||||
for runtime, entries in devices.items():
|
||||
if runtime != target_runtime:
|
||||
continue
|
||||
for entry in entries:
|
||||
if not entry.get("isAvailable", False):
|
||||
continue
|
||||
if not os.path.isdir(entry.get("dataPath", "")):
|
||||
continue
|
||||
healthy.append(entry)
|
||||
for entry in healthy:
|
||||
if entry.get("name") == target_name:
|
||||
print(entry["udid"])
|
||||
raise SystemExit(0)
|
||||
for entry in healthy:
|
||||
if target_name in entry.get("name", ""):
|
||||
print(entry["udid"])
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
' "$simulator_name" "$simulator_os"
|
||||
}
|
||||
|
||||
if [[ -z "$simulator_id" ]]; then
|
||||
simulator_id="$(resolve_simulator_id || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$simulator_id" ]]; then
|
||||
xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true
|
||||
xcrun simctl bootstatus "$simulator_id" -b
|
||||
xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
||||
xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
||||
xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
||||
xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
||||
destination="id=${simulator_id}"
|
||||
else
|
||||
destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rm -f "$ui_test_config_path"
|
||||
if [[ -n "${daemon_pid:-}" ]]; then
|
||||
kill "$daemon_pid" >/dev/null 2>&1 || true
|
||||
wait "$daemon_pid" >/dev/null 2>&1 || true
|
||||
|
|
@ -91,33 +36,11 @@ cleanup() {
|
|||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
umask 077
|
||||
python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode"
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
config_path = pathlib.Path(sys.argv[1])
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"email": sys.argv[2],
|
||||
"username": sys.argv[3],
|
||||
"password": sys.argv[4],
|
||||
"mode": sys.argv[5],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
|
||||
cargo build -p burrow --bin burrow
|
||||
|
||||
(
|
||||
cd "$fallback_dir"
|
||||
RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \
|
||||
BURROW_SOCKET_PATH="burrow.sock" \
|
||||
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
|
||||
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
||||
) &
|
||||
daemon_pid=$!
|
||||
|
|
@ -133,31 +56,18 @@ if [[ ! -S "$socket_path" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
common_xcodebuild_args=(
|
||||
-quiet
|
||||
-skipPackagePluginValidation
|
||||
-project "${repo_root}/Apple/Burrow.xcodeproj"
|
||||
-scheme App
|
||||
-configuration Debug
|
||||
-destination "$destination"
|
||||
-derivedDataPath "$derived_data_path"
|
||||
-clonedSourcePackagesDirPath "$source_packages_path"
|
||||
-only-testing:BurrowUITests
|
||||
-parallel-testing-enabled NO
|
||||
-maximum-concurrent-test-simulator-destinations 1
|
||||
-maximum-parallel-testing-workers 1
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
)
|
||||
|
||||
xcodebuild \
|
||||
"${common_xcodebuild_args[@]}" \
|
||||
build-for-testing
|
||||
|
||||
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
||||
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
||||
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
||||
BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \
|
||||
BURROW_UI_TEST_EPHEMERAL_AUTH=1 \
|
||||
xcodebuild \
|
||||
"${common_xcodebuild_args[@]}" \
|
||||
test-without-building
|
||||
-quiet \
|
||||
-skipPackagePluginValidation \
|
||||
-project "${repo_root}/Apple/Burrow.xcodeproj" \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \
|
||||
-derivedDataPath "$derived_data_path" \
|
||||
-clonedSourcePackagesDirPath "$source_packages_path" \
|
||||
-only-testing:BurrowUITests \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
#!/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."
|
||||
|
|
@ -1,7 +1,132 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2
|
||||
echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2
|
||||
echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: Scripts/sync-forgejo-nsc-config.sh [options]
|
||||
|
||||
Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and
|
||||
restart the dispatcher/autoscaler units.
|
||||
|
||||
Options:
|
||||
--host <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)))."
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ pub struct TailscaleLoginStartRequest {
|
|||
pub hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub control_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub packet_socket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
|
|
@ -57,35 +55,23 @@ pub struct TailscaleLoginStartResponse {
|
|||
pub status: TailscaleLoginStatus,
|
||||
}
|
||||
|
||||
pub struct TailscaleLoginSession {
|
||||
pub session_id: String,
|
||||
pub helper: Arc<TailscaleHelperProcess>,
|
||||
pub status: TailscaleLoginStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TailscaleBridgeManager {
|
||||
client: Client,
|
||||
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
|
||||
}
|
||||
|
||||
pub struct TailscaleHelperProcess {
|
||||
struct ManagedSession {
|
||||
session_id: String,
|
||||
listen_url: String,
|
||||
packet_socket: Option<PathBuf>,
|
||||
control_url: Option<String>,
|
||||
state_dir: PathBuf,
|
||||
child: Arc<Mutex<Child>>,
|
||||
_stderr_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type ManagedSession = TailscaleHelperProcess;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HelperHello {
|
||||
listen_addr: String,
|
||||
#[serde(default)]
|
||||
packet_socket: Option<String>,
|
||||
}
|
||||
|
||||
impl TailscaleBridgeManager {
|
||||
|
|
@ -93,45 +79,13 @@ impl TailscaleBridgeManager {
|
|||
&self,
|
||||
request: TailscaleLoginStartRequest,
|
||||
) -> Result<TailscaleLoginStartResponse> {
|
||||
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());
|
||||
let key = session_key(&request.account_name, &request.identity_name);
|
||||
|
||||
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
||||
let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket())
|
||||
{
|
||||
(Some(requested), Some(current)) => current != Path::new(requested),
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
let needs_restart_for_control_url =
|
||||
requested_control_url != existing.control_url().map(|value| value.trim());
|
||||
|
||||
if !needs_restart_for_socket && !needs_restart_for_control_url {
|
||||
match self.fetch_status(existing.as_ref()).await {
|
||||
Ok(status) => {
|
||||
return Ok(TailscaleLoginSession {
|
||||
return Ok(TailscaleLoginStartResponse {
|
||||
session_id: existing.session_id.clone(),
|
||||
helper: existing,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
|
@ -140,24 +94,61 @@ impl TailscaleBridgeManager {
|
|||
"tailscale login session {} is stale, restarting: {err}",
|
||||
existing.session_id
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"tailscale login session {} no longer matches requested transport, restarting",
|
||||
existing.session_id
|
||||
);
|
||||
}
|
||||
|
||||
self.sessions.lock().await.remove(&key);
|
||||
let _ = self.shutdown_session(existing.as_ref()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state_dir = state_root().join(session_dir_name(&request));
|
||||
tokio::fs::create_dir_all(&state_dir)
|
||||
.await
|
||||
.with_context(|| format!("failed to create {}", state_dir.display()))?;
|
||||
|
||||
let mut child = helper_command(&request, &state_dir)?
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn tailscale login helper")?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("tailscale helper stdout unavailable")?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.context("tailscale helper stderr unavailable")?;
|
||||
|
||||
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
lines.next_line().await
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for tailscale helper startup")??
|
||||
.context("tailscale helper exited before reporting listen address")?;
|
||||
|
||||
let hello: HelperHello =
|
||||
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
|
||||
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
log::info!("tailscale-login-bridge: {line}");
|
||||
}
|
||||
});
|
||||
|
||||
let session = Arc::new(ManagedSession {
|
||||
session_id: random_session_id(),
|
||||
listen_url: format!("http://{}", hello.listen_addr),
|
||||
state_dir,
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
_stderr_task: stderr_task,
|
||||
});
|
||||
|
||||
let session = Arc::new(spawn_tailscale_helper(&request).await?);
|
||||
let status = self.wait_for_status(session.as_ref()).await?;
|
||||
let response = TailscaleLoginSession {
|
||||
let response = TailscaleLoginStartResponse {
|
||||
session_id: session.session_id.clone(),
|
||||
helper: session.clone(),
|
||||
status,
|
||||
};
|
||||
|
||||
|
|
@ -201,7 +192,7 @@ impl TailscaleBridgeManager {
|
|||
let mut last_error = None;
|
||||
let mut last_status = None;
|
||||
for _ in 0..40 {
|
||||
match session.status_with_client(&self.client).await {
|
||||
match self.fetch_status(session).await {
|
||||
Ok(status) if status.running || status.auth_url.is_some() => return Ok(status),
|
||||
Ok(status) => last_status = Some(status),
|
||||
Err(err) => last_error = Some(err),
|
||||
|
|
@ -215,7 +206,28 @@ impl TailscaleBridgeManager {
|
|||
}
|
||||
|
||||
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
||||
session.status_with_client(&self.client).await
|
||||
let mut child = session.child.lock().await;
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Err(anyhow!(
|
||||
"tailscale helper exited with status {status} for {}",
|
||||
session.state_dir.display()
|
||||
));
|
||||
}
|
||||
drop(child);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/status", session.listen_url))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to query tailscale helper status")?
|
||||
.error_for_status()
|
||||
.context("tailscale helper status request failed")?;
|
||||
|
||||
response
|
||||
.json::<TailscaleLoginStatus>()
|
||||
.await
|
||||
.context("invalid tailscale helper status response")
|
||||
}
|
||||
|
||||
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
|
||||
|
|
@ -227,74 +239,14 @@ impl TailscaleBridgeManager {
|
|||
}
|
||||
|
||||
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
|
||||
session.shutdown_with_client(&self.client).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TailscaleHelperProcess {
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
pub fn packet_socket(&self) -> Option<&Path> {
|
||||
self.packet_socket.as_deref()
|
||||
}
|
||||
|
||||
pub fn control_url(&self) -> Option<&str> {
|
||||
self.control_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn state_dir(&self) -> &Path {
|
||||
&self.state_dir
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> Result<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))
|
||||
let _ = self
|
||||
.client
|
||||
.post(format!("{}/shutdown", session.listen_url))
|
||||
.send()
|
||||
.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;
|
||||
.await;
|
||||
|
||||
for _ in 0..10 {
|
||||
let mut child = self.child.lock().await;
|
||||
let mut child = session.child.lock().await;
|
||||
if child.try_wait()?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -302,7 +254,7 @@ impl TailscaleHelperProcess {
|
|||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let mut child = self.child.lock().await;
|
||||
let mut child = session.child.lock().await;
|
||||
child
|
||||
.start_kill()
|
||||
.context("failed to kill tailscale helper")?;
|
||||
|
|
@ -311,58 +263,6 @@ impl TailscaleHelperProcess {
|
|||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
|
||||
Command::new(path)
|
||||
|
|
@ -391,21 +291,10 @@ 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
fn state_root() -> PathBuf {
|
||||
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
|
@ -426,34 +315,19 @@ pub(crate) fn state_root() -> PathBuf {
|
|||
.join("tailscale")
|
||||
}
|
||||
|
||||
pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
||||
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
||||
format!(
|
||||
"{}-{}-{}",
|
||||
"{}-{}",
|
||||
slug(&request.account_name),
|
||||
slug(&request.identity_name),
|
||||
slug(control_scope(request))
|
||||
slug(&request.identity_name)
|
||||
)
|
||||
}
|
||||
|
||||
fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
request.account_name,
|
||||
request.identity_name,
|
||||
control_scope(request)
|
||||
)
|
||||
fn session_key(account_name: &str, identity_name: &str) -> String {
|
||||
format!("{account_name}:{identity_name}")
|
||||
}
|
||||
|
||||
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 {
|
||||
fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
|
||||
request
|
||||
.hostname
|
||||
.as_deref()
|
||||
|
|
@ -496,24 +370,14 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn state_dir_is_scoped_by_account_identity_and_control_plane() {
|
||||
fn state_dir_is_stable_by_account_and_identity() {
|
||||
let request = TailscaleLoginStartRequest {
|
||||
account_name: "default".to_owned(),
|
||||
identity_name: "apple".to_owned(),
|
||||
hostname: None,
|
||||
control_url: None,
|
||||
packet_socket: None,
|
||||
};
|
||||
assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed");
|
||||
assert_eq!(session_dir_name(&request), "default-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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::TailnetProvider;
|
||||
|
||||
|
|
@ -44,7 +43,6 @@ struct WebFingerLink {
|
|||
|
||||
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
||||
let domain = email_domain(email)?;
|
||||
info!(%email, %domain, "tailnet discovery requested");
|
||||
let base_url = Url::parse(&format!("https://{domain}"))
|
||||
.with_context(|| format!("invalid discovery domain {domain}"))?;
|
||||
let client = Client::builder()
|
||||
|
|
@ -118,21 +116,12 @@ pub async fn discover_tailnet_at(
|
|||
base_url: &Url,
|
||||
) -> Result<TailnetDiscovery> {
|
||||
let domain = email_domain(email)?;
|
||||
debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery");
|
||||
|
||||
if let Some(discovery) = discover_well_known(client, base_url).await? {
|
||||
info!(
|
||||
%email,
|
||||
%domain,
|
||||
authority = %discovery.authority,
|
||||
provider = ?discovery.provider,
|
||||
"resolved tailnet discovery from well-known document"
|
||||
);
|
||||
return Ok(TailnetDiscovery { domain, ..discovery });
|
||||
}
|
||||
|
||||
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
||||
info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger");
|
||||
return Ok(TailnetDiscovery {
|
||||
domain,
|
||||
provider: inferred_provider(Some(&authority), None),
|
||||
|
|
@ -173,7 +162,6 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
|
|||
let url = base_url
|
||||
.join(TAILNET_DISCOVERY_PATH)
|
||||
.context("failed to build tailnet discovery URL")?;
|
||||
debug!(%url, "requesting tailnet well-known document");
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("accept", "application/json")
|
||||
|
|
@ -199,7 +187,6 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
|
|||
url.query_pairs_mut()
|
||||
.append_pair("resource", &format!("acct:{email}"))
|
||||
.append_pair("rel", TAILNET_DISCOVERY_REL);
|
||||
debug!(%email, url = %url, "requesting tailnet webfinger document");
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use rusqlite::Connection;
|
|||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status as RspStatus};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::warn;
|
||||
use tun::tokio::TunInterface;
|
||||
|
||||
use super::{
|
||||
|
|
@ -16,15 +16,15 @@ use super::{
|
|||
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
||||
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
||||
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
||||
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
|
||||
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
|
||||
TunnelStatusResponse,
|
||||
},
|
||||
runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
|
||||
runtime::{ActiveTunnel, ResolvedTunnel},
|
||||
};
|
||||
use crate::{
|
||||
auth::server::tailscale::{
|
||||
packet_socket_path, TailscaleBridgeManager,
|
||||
TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus,
|
||||
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
|
||||
TailscaleLoginStatus,
|
||||
},
|
||||
control::discovery,
|
||||
daemon::rpc::ServerConfig,
|
||||
|
|
@ -87,20 +87,11 @@ impl DaemonRPCServer {
|
|||
}
|
||||
|
||||
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
|
||||
let config = {
|
||||
let active = self.active_tunnel.read().await;
|
||||
active
|
||||
.as_ref()
|
||||
.map(|tunnel| tunnel.server_config().clone())
|
||||
};
|
||||
let config = match config {
|
||||
Some(config) => config,
|
||||
None => self
|
||||
let config = self
|
||||
.resolve_tunnel()
|
||||
.await?
|
||||
.server_config()
|
||||
.map_err(proc_err)?,
|
||||
};
|
||||
.map_err(proc_err)?;
|
||||
Ok(configuration_rsp(config))
|
||||
}
|
||||
|
||||
|
|
@ -120,18 +111,8 @@ impl DaemonRPCServer {
|
|||
|
||||
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
|
||||
let _ = self.stop_active_tunnel().await?;
|
||||
let tailnet_helper = match &desired {
|
||||
ResolvedTunnel::Tailnet { identity, config } => Some(
|
||||
self.tailnet_login
|
||||
.ensure_session(tailnet_helper_request(identity, config))
|
||||
.await
|
||||
.map_err(proc_err)?
|
||||
.helper,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let active = desired
|
||||
.start(self.tun_interface.clone(), tailnet_helper)
|
||||
.start(self.tun_interface.clone())
|
||||
.await
|
||||
.map_err(proc_err)?;
|
||||
self.active_tunnel.write().await.replace(active);
|
||||
|
|
@ -156,23 +137,6 @@ impl DaemonRPCServer {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn tailnet_bridge_request(
|
||||
account_name: String,
|
||||
identity_name: String,
|
||||
hostname: String,
|
||||
authority: String,
|
||||
) -> BridgeLoginStartRequest {
|
||||
let mut request = BridgeLoginStartRequest {
|
||||
account_name,
|
||||
identity_name,
|
||||
hostname: (!hostname.trim().is_empty()).then_some(hostname),
|
||||
control_url: Self::tailnet_control_url(&authority),
|
||||
packet_socket: None,
|
||||
};
|
||||
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
||||
request
|
||||
}
|
||||
|
||||
fn tailnet_control_url(authority: &str) -> Option<String> {
|
||||
let authority = discovery::normalize_authority(authority);
|
||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
||||
|
|
@ -182,7 +146,6 @@ impl DaemonRPCServer {
|
|||
#[tonic::async_trait]
|
||||
impl Tunnel for DaemonRPCServer {
|
||||
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
|
||||
type TunnelPacketsStream = ReceiverStream<Result<TunnelPacket, RspStatus>>;
|
||||
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
|
||||
|
||||
async fn tunnel_configuration(
|
||||
|
|
@ -208,62 +171,6 @@ impl Tunnel for DaemonRPCServer {
|
|||
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> {
|
||||
let desired = self.resolve_tunnel().await?;
|
||||
let already_running = {
|
||||
|
|
@ -380,16 +287,9 @@ impl TailnetControl for DaemonRPCServer {
|
|||
request: Request<TailnetDiscoverRequest>,
|
||||
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
||||
let request = request.into_inner();
|
||||
info!(email = %request.email, "daemon tailnet discover RPC received");
|
||||
let discovery = discovery::discover_tailnet(&request.email)
|
||||
.await
|
||||
.map_err(proc_err)?;
|
||||
info!(
|
||||
email = %request.email,
|
||||
authority = %discovery.authority,
|
||||
provider = ?discovery.provider,
|
||||
"daemon tailnet discover RPC resolved"
|
||||
);
|
||||
|
||||
Ok(Response::new(TailnetDiscoverResponse {
|
||||
domain: discovery.domain,
|
||||
|
|
@ -425,32 +325,17 @@ impl TailnetControl for DaemonRPCServer {
|
|||
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||
let request = request.into_inner();
|
||||
info!(
|
||||
account = %request.account_name,
|
||||
identity = %request.identity_name,
|
||||
authority = %request.authority,
|
||||
"daemon tailnet login start RPC received"
|
||||
);
|
||||
let response = self
|
||||
.tailnet_login
|
||||
.start_login(Self::tailnet_bridge_request(
|
||||
request.account_name,
|
||||
request.identity_name,
|
||||
request.hostname,
|
||||
request.authority,
|
||||
))
|
||||
.start_login(BridgeLoginStartRequest {
|
||||
account_name: request.account_name,
|
||||
identity_name: request.identity_name,
|
||||
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
|
||||
control_url: Self::tailnet_control_url(&request.authority),
|
||||
})
|
||||
.await
|
||||
.map_err(proc_err)?;
|
||||
|
||||
info!(
|
||||
session_id = %response.session_id,
|
||||
backend_state = %response.status.backend_state,
|
||||
running = response.status.running,
|
||||
needs_login = response.status.needs_login,
|
||||
auth_url = ?response.status.auth_url,
|
||||
"daemon tailnet login start RPC resolved"
|
||||
);
|
||||
|
||||
Ok(Response::new(tailnet_login_rsp(
|
||||
response.session_id,
|
||||
response.status,
|
||||
|
|
@ -462,7 +347,6 @@ impl TailnetControl for DaemonRPCServer {
|
|||
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||
let request = request.into_inner();
|
||||
info!(session_id = %request.session_id, "daemon tailnet login status RPC received");
|
||||
let status = self
|
||||
.tailnet_login
|
||||
.status(&request.session_id)
|
||||
|
|
@ -471,14 +355,6 @@ impl TailnetControl for DaemonRPCServer {
|
|||
let Some(status) = status else {
|
||||
return Err(RspStatus::not_found("tailnet login session not found"));
|
||||
};
|
||||
info!(
|
||||
session_id = %request.session_id,
|
||||
backend_state = %status.backend_state,
|
||||
running = status.running,
|
||||
needs_login = status.needs_login,
|
||||
auth_url = ?status.auth_url,
|
||||
"daemon tailnet login status RPC resolved"
|
||||
);
|
||||
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
||||
}
|
||||
|
||||
|
|
@ -505,12 +381,8 @@ fn proc_err(err: impl ToString) -> RspStatus {
|
|||
|
||||
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
|
||||
TunnelConfigurationResponse {
|
||||
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,
|
||||
addresses: config.address,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,14 +68,6 @@ impl TryFrom<&TunInterface> for ServerInfo {
|
|||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ServerConfig {
|
||||
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 mtu: Option<i32>,
|
||||
}
|
||||
|
|
@ -86,14 +78,6 @@ impl TryFrom<&Config> for ServerConfig {
|
|||
fn try_from(config: &Config) -> anyhow::Result<Self> {
|
||||
Ok(ServerConfig {
|
||||
address: config.interface.address.clone(),
|
||||
routes: config
|
||||
.peers
|
||||
.iter()
|
||||
.flat_map(|peer| peer.allowed_ips.iter().cloned())
|
||||
.collect(),
|
||||
dns_servers: config.interface.dns.clone(),
|
||||
search_domains: Vec::new(),
|
||||
include_default_route: false,
|
||||
name: None,
|
||||
mtu: config.interface.mtu.map(|mtu| mtu as i32),
|
||||
})
|
||||
|
|
@ -104,10 +88,6 @@ impl Default for ServerConfig {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
address: vec!["10.13.13.2".to_string()], // Dummy remote address
|
||||
routes: Vec::new(),
|
||||
dns_servers: Vec::new(),
|
||||
search_domains: Vec::new(),
|
||||
include_default_route: false,
|
||||
name: None,
|
||||
mtu: None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
source: burrow/src/daemon/rpc/response.rs
|
||||
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?"
|
||||
---
|
||||
{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0}
|
||||
{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::UnixStream,
|
||||
sync::{broadcast, mpsc, RwLock},
|
||||
task::JoinHandle,
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::{sync::RwLock, task::JoinHandle};
|
||||
use tun::{tokio::TunInterface, TunOptions};
|
||||
|
||||
use super::rpc::{
|
||||
|
|
@ -15,11 +9,7 @@ use super::rpc::{
|
|||
ServerConfig,
|
||||
};
|
||||
use crate::{
|
||||
auth::server::tailscale::{
|
||||
default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess,
|
||||
TailscaleLoginStartRequest, TailscaleLoginStatus,
|
||||
},
|
||||
control::{discovery, TailnetConfig},
|
||||
control::TailnetConfig,
|
||||
wireguard::{Config, Interface as WireGuardInterface},
|
||||
};
|
||||
|
||||
|
|
@ -88,19 +78,11 @@ impl ResolvedTunnel {
|
|||
match self {
|
||||
Self::Passthrough { .. } => Ok(ServerConfig {
|
||||
address: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
dns_servers: Vec::new(),
|
||||
search_domains: Vec::new(),
|
||||
include_default_route: false,
|
||||
name: None,
|
||||
mtu: Some(1500),
|
||||
}),
|
||||
Self::Tailnet { .. } => Ok(ServerConfig {
|
||||
address: Vec::new(),
|
||||
routes: tailnet_routes(),
|
||||
dns_servers: tailnet_dns_servers(),
|
||||
search_domains: Vec::new(),
|
||||
include_default_route: false,
|
||||
name: None,
|
||||
mtu: Some(1280),
|
||||
}),
|
||||
|
|
@ -111,71 +93,21 @@ impl ResolvedTunnel {
|
|||
pub async fn start(
|
||||
self,
|
||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||
tailnet_helper: Option<Arc<TailscaleHelperProcess>>,
|
||||
) -> Result<ActiveTunnel> {
|
||||
match self {
|
||||
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough {
|
||||
identity,
|
||||
server_config: ServerConfig {
|
||||
address: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
dns_servers: Vec::new(),
|
||||
search_domains: Vec::new(),
|
||||
include_default_route: false,
|
||||
name: None,
|
||||
mtu: Some(1500),
|
||||
},
|
||||
}),
|
||||
Self::Tailnet { identity, config } => {
|
||||
let (helper, shutdown_helper_on_stop) = match tailnet_helper {
|
||||
Some(helper) => (helper, false),
|
||||
None => {
|
||||
let helper_request = tailnet_helper_request(&identity, &config);
|
||||
let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?);
|
||||
(helper, true)
|
||||
}
|
||||
};
|
||||
let status = wait_for_tailnet_ready(helper.as_ref()).await?;
|
||||
let server_config = tailnet_server_config(&status);
|
||||
let packet_socket = helper
|
||||
.packet_socket()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?;
|
||||
let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
let tun_task = None;
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
let tun_task = {
|
||||
let tun = TunOptions::new().open()?;
|
||||
tun_interface.write().await.replace(tun);
|
||||
Some(tokio::spawn(run_tailnet_tun_bridge(
|
||||
tun_interface.clone(),
|
||||
packet_bridge.outbound_sender(),
|
||||
packet_bridge.subscribe(),
|
||||
)))
|
||||
};
|
||||
|
||||
Ok(ActiveTunnel::Tailnet {
|
||||
identity,
|
||||
server_config,
|
||||
helper,
|
||||
shutdown_helper_on_stop,
|
||||
packet_bridge,
|
||||
tun_task,
|
||||
})
|
||||
}
|
||||
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
|
||||
Self::Tailnet { config, .. } => Err(anyhow::anyhow!(
|
||||
"tailnet runtime is not wired in this checkout yet ({:?})",
|
||||
config.provider
|
||||
)),
|
||||
Self::WireGuard { identity, config } => {
|
||||
let server_config = ServerConfig::try_from(&config)?;
|
||||
let tun = TunOptions::new().open()?;
|
||||
tun_interface.write().await.replace(tun);
|
||||
|
||||
match start_wireguard_runtime(config, tun_interface.clone()).await {
|
||||
Ok((interface, task)) => Ok(ActiveTunnel::WireGuard {
|
||||
identity,
|
||||
server_config,
|
||||
interface,
|
||||
task,
|
||||
}),
|
||||
Ok((interface, task)) => {
|
||||
Ok(ActiveTunnel::WireGuard { identity, interface, task })
|
||||
}
|
||||
Err(err) => {
|
||||
tun_interface.write().await.take();
|
||||
Err(err)
|
||||
|
|
@ -189,19 +121,9 @@ impl ResolvedTunnel {
|
|||
pub enum ActiveTunnel {
|
||||
Passthrough {
|
||||
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 {
|
||||
identity: RuntimeIdentity,
|
||||
server_config: ServerConfig,
|
||||
interface: Arc<RwLock<WireGuardInterface>>,
|
||||
task: JoinHandle<Result<()>>,
|
||||
},
|
||||
|
|
@ -210,69 +132,15 @@ pub enum ActiveTunnel {
|
|||
impl ActiveTunnel {
|
||||
pub fn identity(&self) -> &RuntimeIdentity {
|
||||
match self {
|
||||
Self::Passthrough { identity, .. }
|
||||
| Self::Tailnet { identity, .. }
|
||||
Self::Passthrough { 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<()> {
|
||||
match self {
|
||||
Self::Passthrough { .. } => Ok(()),
|
||||
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,
|
||||
..
|
||||
} => {
|
||||
Self::WireGuard { interface, task, .. } => {
|
||||
interface.read().await.remove_tun().await;
|
||||
let task_result = task.await;
|
||||
tun_interface.write().await.take();
|
||||
|
|
@ -283,22 +151,6 @@ 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(
|
||||
config: Config,
|
||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||
|
|
@ -314,279 +166,6 @@ async fn start_wireguard_runtime(
|
|||
Ok((interface, task))
|
||||
}
|
||||
|
||||
pub(crate) fn tailnet_helper_request(
|
||||
identity: &RuntimeIdentity,
|
||||
config: &TailnetConfig,
|
||||
) -> TailscaleLoginStartRequest {
|
||||
let account_name = config
|
||||
.account
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("default")
|
||||
.to_owned();
|
||||
let identity_name = config
|
||||
.identity
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| match identity {
|
||||
RuntimeIdentity::Network { id, .. } => format!("network-{id}"),
|
||||
RuntimeIdentity::Passthrough => "apple".to_owned(),
|
||||
});
|
||||
let control_url = config.authority.as_deref().and_then(|authority| {
|
||||
let authority = discovery::normalize_authority(authority);
|
||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
||||
});
|
||||
|
||||
let mut request = TailscaleLoginStartRequest {
|
||||
account_name,
|
||||
identity_name,
|
||||
hostname: config.hostname.clone(),
|
||||
control_url,
|
||||
packet_socket: None,
|
||||
};
|
||||
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
||||
if request
|
||||
.hostname
|
||||
.as_deref()
|
||||
.map(|value| value.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
request.hostname = Some(default_hostname(&request));
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -600,19 +179,4 @@ mod tests {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,9 @@ async fn try_tailnet_discover(email: &str) -> Result<()> {
|
|||
let mut client = BurrowClient::from_uds().await?;
|
||||
let response = client
|
||||
.tailnet_client
|
||||
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() })
|
||||
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest {
|
||||
email: email.to_owned(),
|
||||
})
|
||||
.await?
|
||||
.into_inner();
|
||||
println!("Tailnet Discover Response: {:?}", response);
|
||||
|
|
@ -368,9 +370,13 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul
|
|||
"tailnet ping received {} bytes from daemon packet stream",
|
||||
packet.payload.len()
|
||||
);
|
||||
if let Some(reply) =
|
||||
parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)?
|
||||
{
|
||||
if let Some(reply) = parse_icmp_echo_reply(
|
||||
&packet.payload,
|
||||
local_ip,
|
||||
remote_ip,
|
||||
identifier,
|
||||
sequence,
|
||||
)? {
|
||||
break Ok::<_, anyhow::Error>(reply);
|
||||
}
|
||||
}
|
||||
|
|
@ -458,7 +464,8 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
|||
|
||||
let egress_task = tokio::spawn(async move {
|
||||
while let Some(packet) = stack_stream.next().await {
|
||||
let payload = packet.context("failed to read outbound packet from userspace stack")?;
|
||||
let payload =
|
||||
packet.context("failed to read outbound packet from userspace stack")?;
|
||||
log::debug!(
|
||||
"tailnet udp echo sending {} bytes into daemon packet stream",
|
||||
payload.len()
|
||||
|
|
@ -477,7 +484,9 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
|||
.send((message.as_bytes().to_vec(), local_addr, remote_addr))
|
||||
.await
|
||||
.context("failed to send UDP echo probe into userspace stack")?;
|
||||
log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}");
|
||||
log::debug!(
|
||||
"tailnet udp echo probe queued from {local_addr} to {remote_addr}"
|
||||
);
|
||||
|
||||
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
|
||||
.await
|
||||
|
|
@ -507,10 +516,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
|
|||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
||||
fn select_tailnet_local_ip(
|
||||
addresses: &[String],
|
||||
remote_ip: std::net::IpAddr,
|
||||
) -> Result<std::net::IpAddr> {
|
||||
fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result<std::net::IpAddr> {
|
||||
use anyhow::Context;
|
||||
|
||||
let family_is_v4 = remote_ip.is_ipv4();
|
||||
|
|
|
|||
|
|
@ -47,16 +47,10 @@ pub fn initialize() {
|
|||
|
||||
#[cfg(target_os = "macos")]
|
||||
let subscriber = {
|
||||
// `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on
|
||||
// current macOS. Keep logging on stderr by default and allow opt-in OSLog
|
||||
// only when explicitly requested for local debugging.
|
||||
let enable_oslog = matches!(
|
||||
std::env::var("BURROW_ENABLE_OSLOG").as_deref(),
|
||||
Ok("1" | "true" | "TRUE" | "yes" | "YES")
|
||||
);
|
||||
let system_log = enable_oslog.then(|| {
|
||||
tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")
|
||||
});
|
||||
let system_log = Some(tracing_oslog::OsLogger::new(
|
||||
"com.hackclub.burrow",
|
||||
"tracing",
|
||||
));
|
||||
let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
|
||||
Registry::default().with(stderr).with(system_log)
|
||||
};
|
||||
|
|
|
|||
32
flake.nix
32
flake.nix
|
|
@ -94,7 +94,6 @@
|
|||
pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "nsc";
|
||||
inherit version src;
|
||||
meta.mainProgram = "nsc";
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
unpackPhase = ''
|
||||
|
|
@ -145,35 +144,6 @@
|
|||
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
|
||||
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs=";
|
||||
};
|
||||
burrowSrc = lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter = path: type:
|
||||
let
|
||||
p = toString path;
|
||||
name = builtins.baseNameOf path;
|
||||
hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p;
|
||||
in
|
||||
!(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result");
|
||||
};
|
||||
burrowPkg = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "burrow";
|
||||
version = "0.1.0";
|
||||
src = burrowSrc;
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
outputHashes = {
|
||||
"tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I=";
|
||||
};
|
||||
};
|
||||
cargoBuildFlags = [
|
||||
"-p"
|
||||
"burrow"
|
||||
"--bin"
|
||||
"burrow"
|
||||
];
|
||||
nativeBuildInputs = [ pkgs.protobuf ];
|
||||
meta.mainProgram = "burrow";
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
|
|
@ -201,7 +171,6 @@
|
|||
packages =
|
||||
{
|
||||
agenix = agenix.packages.${system}.agenix;
|
||||
burrow = burrowPkg;
|
||||
hcloud-upload-image = hcloudUploadImagePkg;
|
||||
forgejo-nsc-dispatcher = forgejoNscDispatcher;
|
||||
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
|
||||
|
|
@ -214,6 +183,7 @@
|
|||
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
||||
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
||||
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
|
||||
|
||||
nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
specialArgs = {
|
||||
|
|
|
|||
|
|
@ -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/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/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge`
|
||||
- `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host
|
||||
|
||||
## Intended Flow
|
||||
|
||||
|
|
@ -32,17 +32,15 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
|||
3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`.
|
||||
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
||||
5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
|
||||
6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`.
|
||||
7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`.
|
||||
8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, `secrets/infra/headscale-oidc-client-secret.age`, `secrets/infra/forgejo-nsc-token.age`, `secrets/infra/forgejo-nsc-dispatcher-config.age`, and `secrets/infra/forgejo-nsc-autoscaler-config.age`, and let agenix materialize them under `/run/agenix/`.
|
||||
9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
||||
10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace.
|
||||
11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`.
|
||||
6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module.
|
||||
7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`.
|
||||
8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
||||
9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace.
|
||||
10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`.
|
||||
|
||||
## Current Constraints
|
||||
|
||||
- `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`.
|
||||
- `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.
|
||||
- Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host.
|
||||
- Public Burrow forge cutover completed on March 15, 2026:
|
||||
- `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21`
|
||||
|
|
|
|||
|
|
@ -87,24 +87,6 @@ in
|
|||
group = "root";
|
||||
mode = "0400";
|
||||
};
|
||||
age.secrets.burrowForgejoNscToken = {
|
||||
file = ../../../secrets/infra/forgejo-nsc-token.age;
|
||||
owner = "forgejo-nsc";
|
||||
group = "forgejo-nsc";
|
||||
mode = "0400";
|
||||
};
|
||||
age.secrets.burrowForgejoNscDispatcherConfig = {
|
||||
file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age;
|
||||
owner = "forgejo-nsc";
|
||||
group = "forgejo-nsc";
|
||||
mode = "0400";
|
||||
};
|
||||
age.secrets.burrowForgejoNscAutoscalerConfig = {
|
||||
file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age;
|
||||
owner = "forgejo-nsc";
|
||||
group = "forgejo-nsc";
|
||||
mode = "0400";
|
||||
};
|
||||
|
||||
networking.extraHosts = ''
|
||||
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
||||
|
|
@ -130,13 +112,13 @@ in
|
|||
|
||||
services.forgejo-nsc = {
|
||||
enable = true;
|
||||
nscTokenFile = config.age.secrets.burrowForgejoNscToken.path;
|
||||
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
||||
dispatcher = {
|
||||
configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path;
|
||||
configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml";
|
||||
};
|
||||
autoscaler = {
|
||||
enable = true;
|
||||
configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path;
|
||||
configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import "google/protobuf/timestamp.proto";
|
|||
|
||||
service Tunnel {
|
||||
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
|
||||
rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket);
|
||||
rpc TunnelStart (Empty) returns (Empty);
|
||||
rpc TunnelStop (Empty) returns (Empty);
|
||||
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
|
||||
|
|
@ -129,12 +128,4 @@ message TunnelStatusResponse {
|
|||
message TunnelConfigurationResponse {
|
||||
repeated string addresses = 1;
|
||||
int32 mtu = 2;
|
||||
repeated string routes = 3;
|
||||
repeated string dns_servers = 4;
|
||||
repeated string search_domains = 5;
|
||||
bool include_default_route = 6;
|
||||
}
|
||||
|
||||
message TunnelPacket {
|
||||
bytes payload = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ in
|
|||
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
||||
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
|||
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{Ã~ðä8–yCÕûó¶ "™/oXÚCÅe8-¹“àulYtŸ¹;ä§Ò–DZdm¨¡ù
ów÷×F…yÚiIæ†×öÏŽ›É…8F ¥Á¯ð}lÓø"ÒÜ´IÕøÕsuÿ‹µ{L!’ëÌ+Á™UBei¨_Zì~Œ D>åB)±Š‹L§><º€R
|
||||
ÓàÕÂ]Ô‹õ°:ùá`ùꂪóe2ÿw˜Ìñâm’P¹®Ú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ÀQNB›yj—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•»å:}ä
|
||||
Loading…
Add table
Add a link
Reference in a new issue