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 statusBar = NSStatusBar.system
|
||||||
let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
|
let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
if let button = statusItem.button {
|
if let button = statusItem.button {
|
||||||
button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil)
|
button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil)
|
||||||
}
|
}
|
||||||
return statusItem
|
return statusItem
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,15 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BurrowTailnetLoginUITests: XCTestCase {
|
final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
private enum TailnetLoginMode: String, Decodable {
|
|
||||||
case tailscale
|
|
||||||
case discovered
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TestConfig: Decodable {
|
|
||||||
let email: String
|
|
||||||
let username: String
|
|
||||||
let password: String
|
|
||||||
let mode: TailnetLoginMode?
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTailnetLoginThroughAuthentikWebSession() throws {
|
func testTailnetLoginThroughAuthentikWebSession() throws {
|
||||||
let config = try loadTestConfig()
|
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
|
||||||
let email = config.email
|
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
|
||||||
let username = config.username
|
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||||
let password = config.password
|
|
||||||
let mode = config.mode ?? .tailscale
|
|
||||||
let browserIdentity = mode == .tailscale ? email : username
|
|
||||||
|
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
@ -34,75 +18,51 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
||||||
tailnetButton.tap()
|
tailnetButton.tap()
|
||||||
|
|
||||||
configureTailnetIfNeeded(in: app, mode: mode)
|
|
||||||
|
|
||||||
let discoveryField = app.textFields["tailnet-discovery-email"]
|
let discoveryField = app.textFields["tailnet-discovery-email"]
|
||||||
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
||||||
replaceText(in: discoveryField, with: email)
|
replaceText(in: discoveryField, with: email)
|
||||||
|
|
||||||
let serverCard = app.descendants(matching: .any)
|
let findServerButton = app.buttons["tailnet-find-server"]
|
||||||
.matching(identifier: "tailnet-server-card")
|
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
|
||||||
.firstMatch
|
findServerButton.tap()
|
||||||
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
|
|
||||||
|
let discoveryCard = app.otherElements["tailnet-discovery-card"]
|
||||||
|
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
|
||||||
|
|
||||||
|
let authorityField = app.textFields["tailnet-authority"]
|
||||||
|
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
|
||||||
|
"Tailnet authority was not populated from discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
let probeButton = app.buttons["tailnet-check-connection"]
|
||||||
|
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
|
||||||
|
probeButton.tap()
|
||||||
|
|
||||||
|
let probeCard = app.otherElements["tailnet-authority-probe-card"]
|
||||||
|
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
|
||||||
|
|
||||||
let signInButton = app.buttons["tailnet-start-sign-in"]
|
let signInButton = app.buttons["tailnet-start-sign-in"]
|
||||||
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
||||||
signInButton.tap()
|
signInButton.tap()
|
||||||
|
|
||||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
|
acceptAuthenticationPromptIfNeeded(in: app)
|
||||||
|
|
||||||
let webSession = webAuthenticationSession()
|
let webSession = webAuthenticationSession()
|
||||||
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
||||||
|
|
||||||
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
|
signIntoAuthentik(in: webSession, username: username, password: password)
|
||||||
|
|
||||||
app.activate()
|
app.activate()
|
||||||
XCTAssertTrue(
|
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"
|
"Tailnet sign-in never reached the running state"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) {
|
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
|
||||||
guard mode == .discovered else { return }
|
|
||||||
|
|
||||||
openTailnetMenu(in: app)
|
|
||||||
tapMenuButton(named: "Edit Custom Server", in: app)
|
|
||||||
|
|
||||||
openTailnetMenu(in: app)
|
|
||||||
tapMenuButton(named: "Show Advanced Settings", in: app)
|
|
||||||
|
|
||||||
let authorityField = app.textFields["tailnet-authority"]
|
|
||||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
|
||||||
replaceText(in: authorityField, with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openTailnetMenu(in app: XCUIApplication) {
|
|
||||||
let moreButton = app.buttons["More"]
|
|
||||||
XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear")
|
|
||||||
moreButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tapMenuButton(named title: String, in app: XCUIApplication) {
|
|
||||||
let menuButton = firstExistingElement(
|
|
||||||
from: [
|
|
||||||
app.buttons[title],
|
|
||||||
app.descendants(matching: .button)[title],
|
|
||||||
],
|
|
||||||
timeout: 5
|
|
||||||
)
|
|
||||||
XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear")
|
|
||||||
menuButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func acceptAuthenticationPromptIfNeeded(
|
|
||||||
in app: XCUIApplication,
|
|
||||||
timeout: TimeInterval
|
|
||||||
) {
|
|
||||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
|
|
||||||
repeat {
|
|
||||||
let promptCandidates = [
|
let promptCandidates = [
|
||||||
springboard.buttons["Continue"],
|
springboard.buttons["Continue"],
|
||||||
springboard.buttons["Allow"],
|
springboard.buttons["Allow"],
|
||||||
|
|
@ -110,22 +70,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
app.buttons["Allow"],
|
app.buttons["Allow"],
|
||||||
]
|
]
|
||||||
|
|
||||||
for button in promptCandidates where button.exists && button.isHittable {
|
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
||||||
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 {
|
|
||||||
button.tap()
|
button.tap()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -143,19 +88,6 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
||||||
followTailnetRedirectIfNeeded(in: webSession)
|
|
||||||
|
|
||||||
if !webSession.exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
|
||||||
if immediatePasswordField.exists {
|
|
||||||
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
|
|
||||||
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let usernameField = firstExistingElement(
|
let usernameField = firstExistingElement(
|
||||||
in: webSession,
|
in: webSession,
|
||||||
queries: [
|
queries: [
|
||||||
|
|
@ -167,12 +99,21 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
{ $0.webViews.textFields["Email or Username"] },
|
{ $0.webViews.textFields["Email or Username"] },
|
||||||
{ $0.descendants(matching: .textField).firstMatch },
|
{ $0.descendants(matching: .textField).firstMatch },
|
||||||
],
|
],
|
||||||
timeout: 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
|
return
|
||||||
}
|
}
|
||||||
replaceText(in: usernameField, with: username)
|
|
||||||
|
|
||||||
tapFirstExistingButton(
|
tapFirstExistingButton(
|
||||||
in: webSession,
|
in: webSession,
|
||||||
|
|
@ -182,31 +123,21 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
|
|
||||||
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
||||||
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
||||||
replaceSecureText(in: passwordField, within: webSession, with: password)
|
replaceSecureText(in: passwordField, with: password)
|
||||||
submitAuthenticationForm(in: webSession, focusedField: passwordField)
|
tapFirstExistingButton(
|
||||||
}
|
in: webSession,
|
||||||
|
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||||
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
|
timeout: 5
|
||||||
let redirectCandidates = [
|
)
|
||||||
webSession.links["Found"],
|
|
||||||
webSession.webViews.links["Found"],
|
|
||||||
webSession.buttons["Found"],
|
|
||||||
webSession.webViews.buttons["Found"],
|
|
||||||
]
|
|
||||||
|
|
||||||
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
|
|
||||||
if redirectLink.exists {
|
|
||||||
redirectLink.tap()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
app.descendants(matching: .secureTextField).firstMatch,
|
|
||||||
app.secureTextFields["Password"],
|
app.secureTextFields["Password"],
|
||||||
app.secureTextFields["Password or Token"],
|
app.secureTextFields["Password or Token"],
|
||||||
app.webViews.secureTextFields["Password"],
|
app.webViews.secureTextFields["Password"],
|
||||||
app.webViews.secureTextFields["Password or Token"],
|
app.webViews.secureTextFields["Password or Token"],
|
||||||
|
app.descendants(matching: .secureTextField).firstMatch,
|
||||||
]
|
]
|
||||||
|
|
||||||
return firstExistingElement(from: candidates, timeout: timeout)
|
return firstExistingElement(from: candidates, timeout: timeout)
|
||||||
|
|
@ -229,92 +160,11 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
button.tap()
|
button.tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
|
private func requiredEnvironment(_ key: String) throws -> String {
|
||||||
focus(focusedField)
|
guard let value = ProcessInfo.processInfo.environment[key],
|
||||||
focusedField.typeText("\n")
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
if waitForAny(
|
|
||||||
[
|
|
||||||
{ !focusedField.exists },
|
|
||||||
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
|
|
||||||
],
|
|
||||||
timeout: 1.5
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let keyboard = app.keyboards.firstMatch
|
|
||||||
if keyboard.waitForExistence(timeout: 2) {
|
|
||||||
let keyboardCandidates = [
|
|
||||||
"Return",
|
|
||||||
"return",
|
|
||||||
"Go",
|
|
||||||
"go",
|
|
||||||
"Continue",
|
|
||||||
"continue",
|
|
||||||
"Done",
|
|
||||||
"done",
|
|
||||||
"Join",
|
|
||||||
"join",
|
|
||||||
"Sign In",
|
|
||||||
"Log In",
|
|
||||||
"Login",
|
|
||||||
]
|
|
||||||
for title in keyboardCandidates {
|
|
||||||
let key = keyboard.buttons[title]
|
|
||||||
if key.exists && key.isHittable {
|
|
||||||
key.tap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
|
|
||||||
lastKey.exists,
|
|
||||||
lastKey.isHittable
|
|
||||||
{
|
|
||||||
lastKey.tap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tapFirstExistingButton(
|
|
||||||
in: app,
|
|
||||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
|
||||||
timeout: 5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadTestConfig() throws -> TestConfig {
|
|
||||||
let environment = ProcessInfo.processInfo.environment
|
|
||||||
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
|
|
||||||
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
|
|
||||||
{
|
|
||||||
return TestConfig(
|
|
||||||
email: email,
|
|
||||||
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
|
|
||||||
password: password,
|
|
||||||
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
|
|
||||||
.flatMap(TailnetLoginMode.init(rawValue:))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
|
|
||||||
let configURL = URL(fileURLWithPath: configPath)
|
|
||||||
guard FileManager.default.fileExists(atPath: configURL.path) else {
|
|
||||||
throw XCTSkip(
|
|
||||||
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: configURL)
|
|
||||||
return try JSONDecoder().decode(TestConfig.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nonEmptyEnvironment(_ key: String) -> String? {
|
|
||||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!value.isEmpty
|
|
||||||
else {
|
else {
|
||||||
return nil
|
throw XCTSkip("Missing required UI test environment variable \(key)")
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
@ -339,32 +189,6 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
|
||||||
let button = app.buttons["tailnet-start-sign-in"]
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
|
|
||||||
repeat {
|
|
||||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
|
|
||||||
if button.exists, button.label == "Signed In" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
|
||||||
} while Date() < deadline
|
|
||||||
|
|
||||||
return button.exists && button.label == "Signed In"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
repeat {
|
|
||||||
if conditions.contains(where: { $0() }) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
||||||
} while Date() < deadline
|
|
||||||
return conditions.contains(where: { $0() })
|
|
||||||
}
|
|
||||||
|
|
||||||
private func firstExistingElement(
|
private func firstExistingElement(
|
||||||
in app: XCUIApplication,
|
in app: XCUIApplication,
|
||||||
queries: [(XCUIApplication) -> XCUIElement],
|
queries: [(XCUIApplication) -> XCUIElement],
|
||||||
|
|
@ -386,27 +210,14 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceText(in element: XCUIElement, with value: String) {
|
private func replaceText(in element: XCUIElement, with value: String) {
|
||||||
focus(element)
|
element.tap()
|
||||||
clearText(in: element)
|
clearText(in: element)
|
||||||
element.typeText(value)
|
element.typeText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
|
private func replaceSecureText(in element: XCUIElement, with value: String) {
|
||||||
UIPasteboard.general.string = value
|
element.tap()
|
||||||
focus(element)
|
clearText(in: element)
|
||||||
for revealMenu in [
|
|
||||||
{ element.doubleTap() },
|
|
||||||
{ element.press(forDuration: 1.2) },
|
|
||||||
] {
|
|
||||||
revealMenu()
|
|
||||||
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
|
|
||||||
if pasteButton.exists {
|
|
||||||
pasteButton.tap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focus(element)
|
|
||||||
element.typeText(value)
|
element.typeText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,22 +229,4 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||||
element.typeText(deleteSequence)
|
element.typeText(deleteSequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func focus(_ element: XCUIElement) {
|
|
||||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
|
|
||||||
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
|
|
||||||
return pasteLabels.flatMap { label in
|
|
||||||
[
|
|
||||||
app.menuItems[label],
|
|
||||||
app.buttons[label],
|
|
||||||
app.webViews.buttons[label],
|
|
||||||
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
|
||||||
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,13 @@ public enum Constants {
|
||||||
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
|
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
Result {
|
Result {
|
||||||
// The simulator app's Application Support path lives inside its sandbox container,
|
let baseURL = try FileManager.default.url(
|
||||||
// so the host daemon cannot reach it. Use a shared host temp location instead.
|
for: .applicationSupportDirectory,
|
||||||
let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let url = baseURL
|
||||||
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
|
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
|
||||||
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
|
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
|
||||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
|
|
||||||
|
|
@ -108,13 +108,6 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
|
||||||
public init() {}
|
public init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Burrow_TunnelPacket: Sendable {
|
|
||||||
public var payload = Data()
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||||
|
|
@ -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 struct TailnetClient: Client, GRPCClient {
|
||||||
public let channel: GRPCChannel
|
public let channel: GRPCChannel
|
||||||
public var defaultCallOptions: CallOptions
|
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 mtu: Int32 = 0
|
||||||
|
|
||||||
public var routes: [String] = []
|
|
||||||
|
|
||||||
public var dnsServers: [String] = []
|
|
||||||
|
|
||||||
public var searchDomains: [String] = []
|
|
||||||
|
|
||||||
public var includeDefaultRoute: Bool = false
|
|
||||||
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
@ -540,10 +532,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||||
1: .same(proto: "addresses"),
|
1: .same(proto: "addresses"),
|
||||||
2: .same(proto: "mtu"),
|
2: .same(proto: "mtu"),
|
||||||
3: .same(proto: "routes"),
|
|
||||||
4: .standard(proto: "dns_servers"),
|
|
||||||
5: .standard(proto: "search_domains"),
|
|
||||||
6: .standard(proto: "include_default_route"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||||
|
|
@ -554,10 +542,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
switch fieldNumber {
|
switch fieldNumber {
|
||||||
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
|
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
|
||||||
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
|
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
|
||||||
case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }()
|
|
||||||
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
|
|
||||||
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
|
|
||||||
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
|
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -570,28 +554,12 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
if self.mtu != 0 {
|
if self.mtu != 0 {
|
||||||
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
|
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
|
||||||
}
|
}
|
||||||
if !self.routes.isEmpty {
|
|
||||||
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
|
|
||||||
}
|
|
||||||
if !self.dnsServers.isEmpty {
|
|
||||||
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
|
|
||||||
}
|
|
||||||
if !self.searchDomains.isEmpty {
|
|
||||||
try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5)
|
|
||||||
}
|
|
||||||
if self.includeDefaultRoute {
|
|
||||||
try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
try unknownFields.traverse(visitor: &visitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
|
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
|
||||||
if lhs.addresses != rhs.addresses {return false}
|
if lhs.addresses != rhs.addresses {return false}
|
||||||
if lhs.mtu != rhs.mtu {return false}
|
if lhs.mtu != rhs.mtu {return false}
|
||||||
if lhs.routes != rhs.routes {return false}
|
|
||||||
if lhs.dnsServers != rhs.dnsServers {return false}
|
|
||||||
if lhs.searchDomains != rhs.searchDomains {return false}
|
|
||||||
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}
|
|
||||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
import BurrowConfiguration
|
import BurrowConfiguration
|
||||||
import BurrowCore
|
import BurrowCore
|
||||||
import GRPC
|
|
||||||
import libburrow
|
import libburrow
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import os
|
import os
|
||||||
|
|
@ -20,9 +19,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private let logger = Logger.logger(for: PacketTunnelProvider.self)
|
private let logger = Logger.logger(for: PacketTunnelProvider.self)
|
||||||
private var packetCall: GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket>?
|
|
||||||
private var inboundPacketTask: Task<Void, Never>?
|
|
||||||
private var outboundPacketTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private var client: TunnelClient {
|
private var client: TunnelClient {
|
||||||
get throws { try _client.get() }
|
get throws { try _client.get() }
|
||||||
|
|
@ -49,18 +45,16 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
let completion = SendableCallbackBox(completionHandler)
|
let completion = SendableCallbackBox(completionHandler)
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
_ = try await client.tunnelStart(.init())
|
|
||||||
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
|
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
|
||||||
guard let settings = configuration?.settings else {
|
guard let settings = configuration?.settings else {
|
||||||
throw Error.missingTunnelConfiguration
|
throw Error.missingTunnelConfiguration
|
||||||
}
|
}
|
||||||
try await setTunnelNetworkSettings(settings)
|
try await setTunnelNetworkSettings(settings)
|
||||||
try startPacketBridge()
|
_ = try await client.tunnelStart(.init())
|
||||||
logger.log("Started tunnel with network settings: \(settings)")
|
logger.log("Started tunnel with network settings: \(settings)")
|
||||||
completion.callback(nil)
|
completion.callback(nil)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to start tunnel: \(error)")
|
logger.error("Failed to start tunnel: \(error)")
|
||||||
stopPacketBridge()
|
|
||||||
completion.callback(error)
|
completion.callback(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +66,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
) {
|
) {
|
||||||
let completion = SendableCallbackBox(completionHandler)
|
let completion = SendableCallbackBox(completionHandler)
|
||||||
Task {
|
Task {
|
||||||
stopPacketBridge()
|
|
||||||
do {
|
do {
|
||||||
_ = try await client.tunnelStop(.init())
|
_ = try await client.tunnelStop(.init())
|
||||||
logger.log("Stopped client")
|
logger.log("Stopped client")
|
||||||
|
|
@ -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 {
|
extension Burrow_TunnelConfigurationResponse {
|
||||||
fileprivate var settings: NEPacketTunnelNetworkSettings {
|
fileprivate var settings: NEPacketTunnelNetworkSettings {
|
||||||
let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:))
|
let ipv6Addresses = addresses.filter { IPv6Address($0) != nil }
|
||||||
let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address)
|
|
||||||
let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address)
|
|
||||||
let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:))
|
|
||||||
var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route)
|
|
||||||
var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route)
|
|
||||||
if includeDefaultRoute {
|
|
||||||
ipv4Routes.append(.default())
|
|
||||||
ipv6Routes.append(.default())
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
|
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
|
||||||
settings.mtu = NSNumber(value: mtu)
|
settings.mtu = NSNumber(value: mtu)
|
||||||
if !ipv4Addresses.isEmpty {
|
settings.ipv4Settings = NEIPv4Settings(
|
||||||
let ipv4Settings = NEIPv4Settings(
|
addresses: addresses.filter { IPv4Address($0) != nil },
|
||||||
addresses: ipv4Addresses.map(\.address),
|
subnetMasks: ["255.255.255.0"]
|
||||||
subnetMasks: ipv4Addresses.map(\.subnetMask)
|
|
||||||
)
|
)
|
||||||
if !ipv4Routes.isEmpty {
|
settings.ipv6Settings = NEIPv6Settings(
|
||||||
ipv4Settings.includedRoutes = ipv4Routes
|
addresses: ipv6Addresses,
|
||||||
}
|
networkPrefixLengths: ipv6Addresses.map { _ in 64 }
|
||||||
settings.ipv4Settings = ipv4Settings
|
|
||||||
}
|
|
||||||
if !ipv6Addresses.isEmpty {
|
|
||||||
let ipv6Settings = NEIPv6Settings(
|
|
||||||
addresses: ipv6Addresses.map(\.address),
|
|
||||||
networkPrefixLengths: ipv6Addresses.map(\.prefixLength)
|
|
||||||
)
|
)
|
||||||
if !ipv6Routes.isEmpty {
|
|
||||||
ipv6Settings.includedRoutes = ipv6Routes
|
|
||||||
}
|
|
||||||
settings.ipv6Settings = ipv6Settings
|
|
||||||
}
|
|
||||||
if !dnsServers.isEmpty {
|
|
||||||
let dnsSettings = NEDNSSettings(servers: dnsServers)
|
|
||||||
if !searchDomains.isEmpty {
|
|
||||||
dnsSettings.matchDomains = searchDomains
|
|
||||||
}
|
|
||||||
settings.dnsSettings = dnsSettings
|
|
||||||
}
|
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ParsedTunnelAddress {
|
|
||||||
struct IPv4AddressSetting {
|
|
||||||
let address: String
|
|
||||||
let subnetMask: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IPv6AddressSetting {
|
|
||||||
let address: String
|
|
||||||
let prefixLength: NSNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
let ipv4Address: IPv4AddressSetting?
|
|
||||||
let ipv6Address: IPv6AddressSetting?
|
|
||||||
|
|
||||||
init?(rawValue: String) {
|
|
||||||
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
|
|
||||||
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !address.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = components.count == 2 ? Int(components[1]) : nil
|
|
||||||
if IPv4Address(address) != nil {
|
|
||||||
let prefixLength = prefix ?? 32
|
|
||||||
guard (0 ... 32).contains(prefixLength) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ipv4Address = IPv4AddressSetting(
|
|
||||||
address: address,
|
|
||||||
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
|
|
||||||
)
|
|
||||||
ipv6Address = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if IPv6Address(address) != nil {
|
|
||||||
let prefixLength = prefix ?? 128
|
|
||||||
guard (0 ... 128).contains(prefixLength) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ipv4Address = nil
|
|
||||||
ipv6Address = IPv6AddressSetting(
|
|
||||||
address: address,
|
|
||||||
prefixLength: NSNumber(value: prefixLength)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func ipv4SubnetMask(prefixLength: Int) -> String {
|
|
||||||
guard prefixLength > 0 else {
|
|
||||||
return "0.0.0.0"
|
|
||||||
}
|
|
||||||
let mask = UInt32.max << (32 - prefixLength)
|
|
||||||
let octets = [
|
|
||||||
(mask >> 24) & 0xff,
|
|
||||||
(mask >> 16) & 0xff,
|
|
||||||
(mask >> 8) & 0xff,
|
|
||||||
mask & 0xff,
|
|
||||||
]
|
|
||||||
return octets.map(String.init).joined(separator: ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ParsedTunnelRoute {
|
|
||||||
let ipv4Route: NEIPv4Route?
|
|
||||||
let ipv6Route: NEIPv6Route?
|
|
||||||
|
|
||||||
init?(rawValue: String) {
|
|
||||||
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
|
|
||||||
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !address.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = components.count == 2 ? Int(components[1]) : nil
|
|
||||||
if IPv4Address(address) != nil {
|
|
||||||
let prefixLength = prefix ?? 32
|
|
||||||
guard (0 ... 32).contains(prefixLength) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ipv4Route = NEIPv4Route(
|
|
||||||
destinationAddress: address,
|
|
||||||
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
|
|
||||||
)
|
|
||||||
ipv6Route = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if IPv6Address(address) != nil {
|
|
||||||
let prefixLength = prefix ?? 128
|
|
||||||
guard (0 ... 128).contains(prefixLength) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ipv4Route = nil
|
|
||||||
ipv6Route = NEIPv6Route(
|
|
||||||
destinationAddress: address,
|
|
||||||
networkPrefixLength: NSNumber(value: prefixLength)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func ipv4SubnetMask(prefixLength: Int) -> String {
|
|
||||||
var mask = UInt32.max << (32 - prefixLength)
|
|
||||||
if prefixLength == 0 {
|
|
||||||
mask = 0
|
|
||||||
}
|
|
||||||
let octets = [
|
|
||||||
String((mask >> 24) & 0xff),
|
|
||||||
String((mask >> 16) & 0xff),
|
|
||||||
String((mask >> 8) & 0xff),
|
|
||||||
String(mask & 0xff),
|
|
||||||
]
|
|
||||||
return octets.joined(separator: ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ public struct BurrowView: View {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Accounts Yet",
|
"No Accounts Yet",
|
||||||
systemImage: "person.crop.circle.badge.plus",
|
systemImage: "person.crop.circle.badge.plus",
|
||||||
description: Text("Save a Tor account or sign in to 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)
|
.frame(maxWidth: .infinity, minHeight: 180)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -135,7 +135,7 @@ public struct BurrowView: View {
|
||||||
private func runAutomationIfNeeded() {
|
private func runAutomationIfNeeded() {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
let automation = BurrowAutomationConfig.current,
|
let automation = BurrowAutomationConfig.current,
|
||||||
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -340,12 +340,8 @@ private struct ConfigurationSheetView: View {
|
||||||
@State private var isStartingTailnetLogin = false
|
@State private var isStartingTailnetLogin = false
|
||||||
@State private var tailnetPresentedAuthURL: URL?
|
@State private var tailnetPresentedAuthURL: URL?
|
||||||
@State private var preserveTailnetLoginSession = false
|
@State private var preserveTailnetLoginSession = false
|
||||||
@State private var usesCustomTailnetAuthority = false
|
|
||||||
@State private var showsAdvancedTailnetSettings = false
|
|
||||||
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
||||||
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
||||||
@State private var tailnetDiscoveryTask: Task<Void, Never>?
|
|
||||||
@State private var tailnetProbeTask: Task<Void, Never>?
|
|
||||||
@State private var didRunAutomation = false
|
@State private var didRunAutomation = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
|
@ -368,9 +364,14 @@ private struct ConfigurationSheetView: View {
|
||||||
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
|
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
if showsIdentitySection {
|
|
||||||
Section("Identity") {
|
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
|
.onChange(of: draft.authority) { _, _ in
|
||||||
resetAuthorityProbe()
|
resetAuthorityProbe()
|
||||||
if sheet == .tailnet, usesCustomTailnetAuthority {
|
|
||||||
scheduleTailnetAuthorityProbe()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
if sheet == .tailnet, !usesCustomTailnetAuthority {
|
|
||||||
scheduleTailnetDiscovery()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: draft.authMode) { _, newMode in
|
.onChange(of: draft.authMode) { _, newMode in
|
||||||
guard newMode != .web else { return }
|
guard newMode != .web else { return }
|
||||||
|
|
@ -475,8 +470,6 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
tailnetLoginPollTask?.cancel()
|
tailnetLoginPollTask?.cancel()
|
||||||
tailnetDiscoveryTask?.cancel()
|
|
||||||
tailnetProbeTask?.cancel()
|
|
||||||
browserAuthenticator.cancel()
|
browserAuthenticator.cancel()
|
||||||
if !preserveTailnetLoginSession {
|
if !preserveTailnetLoginSession {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|
@ -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
|
@ViewBuilder
|
||||||
private var tailnetSections: some View {
|
private var tailnetSections: some View {
|
||||||
Section("Connection") {
|
Section("Connection") {
|
||||||
|
|
@ -506,39 +487,67 @@ private struct ConfigurationSheetView: View {
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-discovery-email")
|
.accessibilityIdentifier("tailnet-discovery-email")
|
||||||
.submitLabel(.continue)
|
|
||||||
.onSubmit {
|
Button {
|
||||||
if !usesCustomTailnetAuthority {
|
discoverTailnetAuthority()
|
||||||
scheduleTailnetDiscovery(immediate: true)
|
} 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 {
|
TextField("Authority URL", text: $draft.authority)
|
||||||
if usesCustomTailnetAuthority {
|
|
||||||
TextField("Server URL", text: $draft.authority)
|
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-authority")
|
.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)
|
TextField("Tailnet", text: $draft.tailnet)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-name")
|
.accessibilityIdentifier("tailnet-name")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
if showsAdvancedTailnetSettings {
|
|
||||||
Picker("Authentication", selection: $draft.authMode) {
|
Picker("Authentication", selection: $draft.authMode) {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
Text(mode.title).tag(mode)
|
Text(mode.title).tag(mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
}
|
|
||||||
|
|
||||||
if draft.authMode == .web {
|
if draft.authMode == .web {
|
||||||
Button {
|
Button {
|
||||||
|
|
@ -551,7 +560,7 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
|
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
|
||||||
.accessibilityIdentifier("tailnet-start-sign-in")
|
.accessibilityIdentifier("tailnet-start-sign-in")
|
||||||
|
|
||||||
if let tailnetLoginStatus {
|
if let tailnetLoginStatus {
|
||||||
|
|
@ -607,14 +616,32 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if sheet == .tailnet {
|
if sheet == .tailnet {
|
||||||
labeledValue("Server", tailnetServerDisplayLabel)
|
if let authorityProbeStatus {
|
||||||
if let connectionSummary = tailnetConnectionSummary {
|
Text(authorityProbeStatus.summary)
|
||||||
Text(connectionSummary)
|
|
||||||
.font(.footnote.weight(.medium))
|
.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) {
|
HStack(spacing: 8) {
|
||||||
|
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
||||||
|
summaryBadge(draft.authMode.title)
|
||||||
|
if tailnetLoginStatus?.running == true {
|
||||||
summaryBadge("Signed In")
|
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(
|
private func tailnetAuthorityProbeCard(
|
||||||
status: TailnetAuthorityProbeStatus?,
|
status: TailnetAuthorityProbeStatus?,
|
||||||
failure: String?
|
failure: String?
|
||||||
|
|
@ -838,15 +827,11 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
|
Button("Use Tailscale Managed Server") {
|
||||||
toggleTailnetAuthorityMode()
|
applyTailnetDefaults(for: .tailscale)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
|
if availableTailnetAuthModes.count > 1 {
|
||||||
showsAdvancedTailnetSettings.toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
|
|
||||||
Menu("Authentication") {
|
Menu("Authentication") {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
Button(mode.title) {
|
Button(mode.title) {
|
||||||
|
|
@ -859,10 +844,9 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Refresh Server Lookup") {
|
Button("Clear Discovery Result") {
|
||||||
scheduleTailnetDiscovery(immediate: true)
|
resetTailnetDiscoveryFeedback()
|
||||||
}
|
}
|
||||||
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -901,21 +885,12 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
private var showsBottomActionButton: Bool {
|
private var showsBottomActionButton: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return true
|
true
|
||||||
#else
|
#else
|
||||||
return false
|
false
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsIdentitySection: Bool {
|
|
||||||
switch sheet {
|
|
||||||
case .wireGuard, .tor:
|
|
||||||
return true
|
|
||||||
case .tailnet:
|
|
||||||
return showsAdvancedTailnetSettings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var wireGuardEditorHeight: CGFloat {
|
private var wireGuardEditorHeight: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
180
|
180
|
||||||
|
|
@ -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 {
|
private var submissionDisabled: Bool {
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .wireGuard:
|
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() {
|
private func submit() {
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
@ -1102,7 +1021,7 @@ private struct ConfigurationSheetView: View {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
sheet == .tailnet,
|
sheet == .tailnet,
|
||||||
let automation = BurrowAutomationConfig.current,
|
let automation = BurrowAutomationConfig.current,
|
||||||
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1118,9 +1037,7 @@ private struct ConfigurationSheetView: View {
|
||||||
case .tailnetLogin:
|
case .tailnetLogin:
|
||||||
applyTailnetDefaults(for: .tailscale)
|
applyTailnetDefaults(for: .tailscale)
|
||||||
startTailnetLogin()
|
startTailnetLogin()
|
||||||
case .tailnetProbe:
|
case .headscaleProbe:
|
||||||
usesCustomTailnetAuthority = true
|
|
||||||
showsAdvancedTailnetSettings = true
|
|
||||||
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
}
|
}
|
||||||
|
|
@ -1143,13 +1060,10 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
|
|
||||||
var noteParts: [String] = [
|
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 {
|
if draft.authMode == .web, tailnetLoginStatus?.running == true {
|
||||||
noteParts.append("Browser sign-in complete")
|
noteParts.append("Browser sign-in complete")
|
||||||
}
|
}
|
||||||
|
|
@ -1205,7 +1119,6 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
usesCustomTailnetAuthority = provider != .tailscale
|
|
||||||
draft.authority = provider.defaultAuthority ?? ""
|
draft.authority = provider.defaultAuthority ?? ""
|
||||||
if !availableTailnetAuthModes.contains(draft.authMode) {
|
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||||
draft.authMode = .web
|
draft.authMode = .web
|
||||||
|
|
@ -1213,6 +1126,12 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTailnetLogin() {
|
private func startTailnetLogin() {
|
||||||
|
guard let authority = normalizedOptional(draft.authority) else {
|
||||||
|
tailnetLoginStatus = nil
|
||||||
|
tailnetLoginError = "Enter a server URL first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isStartingTailnetLogin = true
|
isStartingTailnetLogin = true
|
||||||
tailnetLoginError = nil
|
tailnetLoginError = nil
|
||||||
preserveTailnetLoginSession = false
|
preserveTailnetLoginSession = false
|
||||||
|
|
@ -1220,7 +1139,6 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isStartingTailnetLogin = false }
|
defer { isStartingTailnetLogin = false }
|
||||||
do {
|
do {
|
||||||
let authority = try await resolveTailnetAuthorityForLogin()
|
|
||||||
let status = try await networkViewModel.startTailnetLogin(
|
let status = try await networkViewModel.startTailnetLogin(
|
||||||
accountName: normalized(draft.accountName, fallback: "default"),
|
accountName: normalized(draft.accountName, fallback: "default"),
|
||||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||||
|
|
@ -1258,14 +1176,12 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetAuthorityProbe() {
|
private func resetAuthorityProbe() {
|
||||||
tailnetProbeTask?.cancel()
|
|
||||||
authorityProbeStatus = nil
|
authorityProbeStatus = nil
|
||||||
authorityProbeError = nil
|
authorityProbeError = nil
|
||||||
tailnetLoginError = nil
|
tailnetLoginError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetTailnetDiscoveryFeedback() {
|
private func resetTailnetDiscoveryFeedback() {
|
||||||
tailnetDiscoveryTask?.cancel()
|
|
||||||
discoveryStatus = nil
|
discoveryStatus = nil
|
||||||
discoveryError = nil
|
discoveryError = nil
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
private func beginTailnetLoginPolling(sessionID: String) {
|
||||||
tailnetLoginPollTask?.cancel()
|
tailnetLoginPollTask?.cancel()
|
||||||
tailnetLoginPollTask = Task { @MainActor in
|
tailnetLoginPollTask = Task { @MainActor in
|
||||||
|
|
@ -1497,16 +1336,13 @@ private struct ConfigurationSheetView: View {
|
||||||
if tailnetLoginSessionID != nil {
|
if tailnetLoginSessionID != nil {
|
||||||
return "Resume Sign-In"
|
return "Resume Sign-In"
|
||||||
}
|
}
|
||||||
return "Continue with Tailscale"
|
return "Start Sign-In"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tailnetAuthenticationFootnote: String {
|
private var tailnetAuthenticationFootnote: String {
|
||||||
switch draft.authMode {
|
switch draft.authMode {
|
||||||
case .web:
|
case .web:
|
||||||
if usesCustomTailnetAuthority {
|
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."
|
||||||
return "Burrow signs in through the daemon using your custom Tailnet server."
|
|
||||||
}
|
|
||||||
return "Burrow signs in through the daemon using Tailscale's managed browser flow."
|
|
||||||
case .none:
|
case .none:
|
||||||
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
||||||
case .password, .preauthKey:
|
case .password, .preauthKey:
|
||||||
|
|
@ -1521,6 +1357,10 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isManagedTailnetAuthority: Bool {
|
||||||
|
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
|
@ -1543,7 +1383,12 @@ private struct AccountRowView: View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(account.title)
|
Text(account.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
HStack(spacing: 8) {
|
||||||
Text(account.kind.title)
|
Text(account.kind.title)
|
||||||
|
if let provider = account.provider {
|
||||||
|
Text(provider.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(account.kind.accentColor)
|
.foregroundStyle(account.kind.accentColor)
|
||||||
}
|
}
|
||||||
|
|
@ -1625,12 +1470,6 @@ private extension View {
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class TailnetBrowserAuthenticator: NSObject {
|
private final class TailnetBrowserAuthenticator: NSObject {
|
||||||
private var session: ASWebAuthenticationSession?
|
private var session: ASWebAuthenticationSession?
|
||||||
private static var prefersEphemeralSessionForCurrentProcess: Bool {
|
|
||||||
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
@ -1638,7 +1477,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
session.presentationContextProvider = self
|
session.presentationContextProvider = self
|
||||||
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
self.session = session
|
self.session = session
|
||||||
_ = session.start()
|
_ = session.start()
|
||||||
}
|
}
|
||||||
|
|
@ -1677,7 +1516,7 @@ private final class TailnetBrowserAuthenticator {
|
||||||
private struct BurrowAutomationConfig {
|
private struct BurrowAutomationConfig {
|
||||||
enum Action: String {
|
enum Action: String {
|
||||||
case tailnetLogin = "tailnet-login"
|
case tailnetLogin = "tailnet-login"
|
||||||
case tailnetProbe = "tailnet-probe"
|
case headscaleProbe = "headscale-probe"
|
||||||
}
|
}
|
||||||
|
|
||||||
let action: Action
|
let action: Action
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale: "Tailscale"
|
case .tailscale: "Tailscale"
|
||||||
case .headscale: "Custom Tailnet"
|
case .headscale: "Headscale"
|
||||||
case .burrow: "Burrow"
|
case .burrow: "Burrow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||||
case .tor: "Store Arti account and identity preferences."
|
case .tor: "Store Arti account and identity preferences."
|
||||||
case .tailnet: "Save Tailnet authority, identity 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:
|
case .tor:
|
||||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can 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
|
test -s /run/agenix/burrowHeadscaleOidcClientSecret
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${EXPECT_NSC}" == "1" ]]; then
|
|
||||||
echo "== agenix-nsc =="
|
|
||||||
ls -l /run/agenix || true
|
|
||||||
test -s /run/agenix/burrowForgejoNscToken
|
|
||||||
test -s /run/agenix/burrowForgejoNscDispatcherConfig
|
|
||||||
test -s /run/agenix/burrowForgejoNscAutoscalerConfig
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
echo "== http-local =="
|
echo "== http-local =="
|
||||||
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
|
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
||||||
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
||||||
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
||||||
simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}"
|
|
||||||
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
||||||
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
||||||
fallback_dir="/tmp/${bundle_id}/SimulatorFallback"
|
fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback"
|
||||||
socket_path="${fallback_dir}/burrow.sock"
|
socket_path="${fallback_dir}/burrow.sock"
|
||||||
tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState"
|
|
||||||
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
||||||
ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}"
|
|
||||||
ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner"
|
|
||||||
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
||||||
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
||||||
ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}"
|
|
||||||
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
||||||
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
||||||
|
|
||||||
|
|
@ -30,60 +25,10 @@ if [[ -z "$ui_test_password" ]]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf "$fallback_dir" "$tailnet_state_root"
|
mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path"
|
||||||
mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path"
|
|
||||||
rm -f "$socket_path"
|
rm -f "$socket_path"
|
||||||
|
|
||||||
resolve_simulator_id() {
|
|
||||||
xcrun simctl list devices available -j | python3 -c '
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
target_name = sys.argv[1]
|
|
||||||
target_os = sys.argv[2]
|
|
||||||
target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-")
|
|
||||||
devices = json.load(sys.stdin).get("devices", {})
|
|
||||||
healthy = []
|
|
||||||
for runtime, entries in devices.items():
|
|
||||||
if runtime != target_runtime:
|
|
||||||
continue
|
|
||||||
for entry in entries:
|
|
||||||
if not entry.get("isAvailable", False):
|
|
||||||
continue
|
|
||||||
if not os.path.isdir(entry.get("dataPath", "")):
|
|
||||||
continue
|
|
||||||
healthy.append(entry)
|
|
||||||
for entry in healthy:
|
|
||||||
if entry.get("name") == target_name:
|
|
||||||
print(entry["udid"])
|
|
||||||
raise SystemExit(0)
|
|
||||||
for entry in healthy:
|
|
||||||
if target_name in entry.get("name", ""):
|
|
||||||
print(entry["udid"])
|
|
||||||
raise SystemExit(0)
|
|
||||||
raise SystemExit(1)
|
|
||||||
' "$simulator_name" "$simulator_os"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ -z "$simulator_id" ]]; then
|
|
||||||
simulator_id="$(resolve_simulator_id || true)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$simulator_id" ]]; then
|
|
||||||
xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true
|
|
||||||
xcrun simctl bootstatus "$simulator_id" -b
|
|
||||||
xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
|
||||||
xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
|
||||||
xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
|
||||||
xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
|
||||||
destination="id=${simulator_id}"
|
|
||||||
else
|
|
||||||
destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$ui_test_config_path"
|
|
||||||
if [[ -n "${daemon_pid:-}" ]]; then
|
if [[ -n "${daemon_pid:-}" ]]; then
|
||||||
kill "$daemon_pid" >/dev/null 2>&1 || true
|
kill "$daemon_pid" >/dev/null 2>&1 || true
|
||||||
wait "$daemon_pid" >/dev/null 2>&1 || true
|
wait "$daemon_pid" >/dev/null 2>&1 || true
|
||||||
|
|
@ -91,33 +36,11 @@ cleanup() {
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
umask 077
|
|
||||||
python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode"
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
config_path = pathlib.Path(sys.argv[1])
|
|
||||||
config_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"email": sys.argv[2],
|
|
||||||
"username": sys.argv[3],
|
|
||||||
"password": sys.argv[4],
|
|
||||||
"mode": sys.argv[5],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
PY
|
|
||||||
|
|
||||||
cargo build -p burrow --bin burrow
|
cargo build -p burrow --bin burrow
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$fallback_dir"
|
cd "$fallback_dir"
|
||||||
RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \
|
|
||||||
BURROW_SOCKET_PATH="burrow.sock" \
|
BURROW_SOCKET_PATH="burrow.sock" \
|
||||||
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
|
|
||||||
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
||||||
) &
|
) &
|
||||||
daemon_pid=$!
|
daemon_pid=$!
|
||||||
|
|
@ -133,31 +56,18 @@ if [[ ! -S "$socket_path" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
common_xcodebuild_args=(
|
|
||||||
-quiet
|
|
||||||
-skipPackagePluginValidation
|
|
||||||
-project "${repo_root}/Apple/Burrow.xcodeproj"
|
|
||||||
-scheme App
|
|
||||||
-configuration Debug
|
|
||||||
-destination "$destination"
|
|
||||||
-derivedDataPath "$derived_data_path"
|
|
||||||
-clonedSourcePackagesDirPath "$source_packages_path"
|
|
||||||
-only-testing:BurrowUITests
|
|
||||||
-parallel-testing-enabled NO
|
|
||||||
-maximum-concurrent-test-simulator-destinations 1
|
|
||||||
-maximum-parallel-testing-workers 1
|
|
||||||
CODE_SIGNING_ALLOWED=NO
|
|
||||||
)
|
|
||||||
|
|
||||||
xcodebuild \
|
|
||||||
"${common_xcodebuild_args[@]}" \
|
|
||||||
build-for-testing
|
|
||||||
|
|
||||||
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
||||||
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
||||||
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
||||||
BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \
|
|
||||||
BURROW_UI_TEST_EPHEMERAL_AUTH=1 \
|
|
||||||
xcodebuild \
|
xcodebuild \
|
||||||
"${common_xcodebuild_args[@]}" \
|
-quiet \
|
||||||
test-without-building
|
-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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2
|
usage() {
|
||||||
echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2
|
cat <<'EOF'
|
||||||
echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2
|
Usage: Scripts/sync-forgejo-nsc-config.sh [options]
|
||||||
exit 1
|
|
||||||
|
Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and
|
||||||
|
restart the dispatcher/autoscaler units.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--host <user@host> SSH target (default: root@git.burrow.net)
|
||||||
|
--ssh-key <path> SSH private key (default: intake/agent_at_burrow_net_ed25519)
|
||||||
|
--rotate-pat Re-render the intake files before syncing.
|
||||||
|
--no-restart Copy files only.
|
||||||
|
-h, --help Show this help text.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}"
|
||||||
|
SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}"
|
||||||
|
KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}"
|
||||||
|
ROTATE_PAT=0
|
||||||
|
NO_RESTART=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--host)
|
||||||
|
HOST="${2:?missing value for --host}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ssh-key)
|
||||||
|
SSH_KEY="${2:?missing value for --ssh-key}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--rotate-pat)
|
||||||
|
ROTATE_PAT=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-restart)
|
||||||
|
NO_RESTART=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")"
|
||||||
|
|
||||||
|
burrow_require_cmd() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
burrow_require_cmd ssh
|
||||||
|
burrow_require_cmd scp
|
||||||
|
|
||||||
|
if [[ ! -f "${SSH_KEY}" ]]; then
|
||||||
|
echo "forge SSH key not found: ${SSH_KEY}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ROTATE_PAT}" -eq 1 ]]; then
|
||||||
|
"${SCRIPT_DIR}/provision-forgejo-nsc.sh" --host "${HOST}" --ssh-key "${SSH_KEY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt"
|
||||||
|
dispatcher_file="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml"
|
||||||
|
autoscaler_file="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml"
|
||||||
|
|
||||||
|
for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do
|
||||||
|
if [[ ! -s "${path}" ]]; then
|
||||||
|
echo "required runtime input missing or empty: ${path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ssh_opts=(
|
||||||
|
-i "${SSH_KEY}"
|
||||||
|
-o IdentitiesOnly=yes
|
||||||
|
-o UserKnownHostsFile="${KNOWN_HOSTS_FILE}"
|
||||||
|
-o StrictHostKeyChecking=accept-new
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")"
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${remote_tmp:-}" ]]; then
|
||||||
|
ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
scp "${ssh_opts[@]}" \
|
||||||
|
"${token_file}" \
|
||||||
|
"${dispatcher_file}" \
|
||||||
|
"${autoscaler_file}" \
|
||||||
|
"${HOST}:${remote_tmp}/"
|
||||||
|
|
||||||
|
ssh "${ssh_opts[@]}" "${HOST}" "
|
||||||
|
set -euo pipefail
|
||||||
|
install -d -m 0755 /var/lib/burrow/intake
|
||||||
|
install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${token_file}")' /var/lib/burrow/intake/forgejo_nsc_token.txt
|
||||||
|
install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${dispatcher_file}")' /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml
|
||||||
|
install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${autoscaler_file}")' /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml
|
||||||
|
"
|
||||||
|
|
||||||
|
if [[ "${NO_RESTART}" -eq 0 ]]; then
|
||||||
|
ssh "${ssh_opts[@]}" "${HOST}" "
|
||||||
|
set -euo pipefail
|
||||||
|
systemctl restart forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service
|
||||||
|
systemctl is-active forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service
|
||||||
|
ls -l \
|
||||||
|
/var/lib/burrow/intake/forgejo_nsc_token.txt \
|
||||||
|
/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml \
|
||||||
|
/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "forgejo-nsc runtime sync complete (host=${HOST}, restarted=$((1 - NO_RESTART)))."
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ pub struct TailscaleLoginStartRequest {
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub control_url: Option<String>,
|
pub control_url: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub packet_socket: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -57,35 +55,23 @@ pub struct TailscaleLoginStartResponse {
|
||||||
pub status: TailscaleLoginStatus,
|
pub status: TailscaleLoginStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TailscaleLoginSession {
|
|
||||||
pub session_id: String,
|
|
||||||
pub helper: Arc<TailscaleHelperProcess>,
|
|
||||||
pub status: TailscaleLoginStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct TailscaleBridgeManager {
|
pub struct TailscaleBridgeManager {
|
||||||
client: Client,
|
client: Client,
|
||||||
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
|
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TailscaleHelperProcess {
|
struct ManagedSession {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
listen_url: String,
|
listen_url: String,
|
||||||
packet_socket: Option<PathBuf>,
|
|
||||||
control_url: Option<String>,
|
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
child: Arc<Mutex<Child>>,
|
child: Arc<Mutex<Child>>,
|
||||||
_stderr_task: JoinHandle<()>,
|
_stderr_task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedSession = TailscaleHelperProcess;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct HelperHello {
|
struct HelperHello {
|
||||||
listen_addr: String,
|
listen_addr: String,
|
||||||
#[serde(default)]
|
|
||||||
packet_socket: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TailscaleBridgeManager {
|
impl TailscaleBridgeManager {
|
||||||
|
|
@ -93,45 +79,13 @@ impl TailscaleBridgeManager {
|
||||||
&self,
|
&self,
|
||||||
request: TailscaleLoginStartRequest,
|
request: TailscaleLoginStartRequest,
|
||||||
) -> Result<TailscaleLoginStartResponse> {
|
) -> Result<TailscaleLoginStartResponse> {
|
||||||
let session = self.ensure_session(request).await?;
|
let key = session_key(&request.account_name, &request.identity_name);
|
||||||
Ok(TailscaleLoginStartResponse {
|
|
||||||
session_id: session.session_id,
|
|
||||||
status: session.status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_session(
|
|
||||||
&self,
|
|
||||||
request: TailscaleLoginStartRequest,
|
|
||||||
) -> Result<TailscaleLoginSession> {
|
|
||||||
let key = session_key_for_request(&request);
|
|
||||||
let requested_packet_socket = request
|
|
||||||
.packet_socket
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let requested_control_url = request
|
|
||||||
.control_url
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
|
|
||||||
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
||||||
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 {
|
match self.fetch_status(existing.as_ref()).await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
return Ok(TailscaleLoginSession {
|
return Ok(TailscaleLoginStartResponse {
|
||||||
session_id: existing.session_id.clone(),
|
session_id: existing.session_id.clone(),
|
||||||
helper: existing,
|
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -140,24 +94,61 @@ impl TailscaleBridgeManager {
|
||||||
"tailscale login session {} is stale, restarting: {err}",
|
"tailscale login session {} is stale, restarting: {err}",
|
||||||
existing.session_id
|
existing.session_id
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"tailscale login session {} no longer matches requested transport, restarting",
|
|
||||||
existing.session_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sessions.lock().await.remove(&key);
|
self.sessions.lock().await.remove(&key);
|
||||||
let _ = self.shutdown_session(existing.as_ref()).await;
|
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 status = self.wait_for_status(session.as_ref()).await?;
|
||||||
let response = TailscaleLoginSession {
|
let response = TailscaleLoginStartResponse {
|
||||||
session_id: session.session_id.clone(),
|
session_id: session.session_id.clone(),
|
||||||
helper: session.clone(),
|
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -201,7 +192,7 @@ impl TailscaleBridgeManager {
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
let mut last_status = None;
|
let mut last_status = None;
|
||||||
for _ in 0..40 {
|
for _ in 0..40 {
|
||||||
match 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) if status.running || status.auth_url.is_some() => return Ok(status),
|
||||||
Ok(status) => last_status = Some(status),
|
Ok(status) => last_status = Some(status),
|
||||||
Err(err) => last_error = Some(err),
|
Err(err) => last_error = Some(err),
|
||||||
|
|
@ -215,7 +206,28 @@ impl TailscaleBridgeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
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>> {
|
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<()> {
|
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
|
||||||
session.shutdown_with_client(&self.client).await
|
let _ = self
|
||||||
}
|
.client
|
||||||
}
|
.post(format!("{}/shutdown", session.listen_url))
|
||||||
|
|
||||||
impl TailscaleHelperProcess {
|
|
||||||
pub fn session_id(&self) -> &str {
|
|
||||||
&self.session_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn packet_socket(&self) -> Option<&Path> {
|
|
||||||
self.packet_socket.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn control_url(&self) -> Option<&str> {
|
|
||||||
self.control_url.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_dir(&self) -> &Path {
|
|
||||||
&self.state_dir
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status(&self) -> Result<TailscaleLoginStatus> {
|
|
||||||
self.status_with_client(&Client::new()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
|
||||||
self.shutdown_with_client(&Client::new()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status_with_client(&self, client: &Client) -> Result<TailscaleLoginStatus> {
|
|
||||||
let mut child = self.child.lock().await;
|
|
||||||
if let Some(status) = child.try_wait()? {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"tailscale helper exited with status {status} for {}",
|
|
||||||
self.state_dir.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
drop(child);
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(format!("{}/status", self.listen_url))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await;
|
||||||
.context("failed to query tailscale helper status")?
|
|
||||||
.error_for_status()
|
|
||||||
.context("tailscale helper status request failed")?;
|
|
||||||
|
|
||||||
let status = response
|
|
||||||
.json::<TailscaleLoginStatus>()
|
|
||||||
.await
|
|
||||||
.context("invalid tailscale helper status response")?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}",
|
|
||||||
self.session_id,
|
|
||||||
status.backend_state,
|
|
||||||
status.running,
|
|
||||||
status.needs_login,
|
|
||||||
status.auth_url
|
|
||||||
);
|
|
||||||
Ok(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown_with_client(&self, client: &Client) -> Result<()> {
|
|
||||||
let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await;
|
|
||||||
|
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
let mut child = self.child.lock().await;
|
let mut child = session.child.lock().await;
|
||||||
if child.try_wait()?.is_some() {
|
if child.try_wait()?.is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +254,7 @@ impl TailscaleHelperProcess {
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = self.child.lock().await;
|
let mut child = session.child.lock().await;
|
||||||
child
|
child
|
||||||
.start_kill()
|
.start_kill()
|
||||||
.context("failed to kill tailscale helper")?;
|
.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> {
|
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
|
||||||
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
|
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
|
||||||
Command::new(path)
|
Command::new(path)
|
||||||
|
|
@ -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)
|
Ok(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf {
|
fn state_root() -> PathBuf {
|
||||||
state_root().join(session_dir_name(request)).join("packet.sock")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn state_root() -> PathBuf {
|
|
||||||
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
|
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
|
||||||
return PathBuf::from(path);
|
return PathBuf::from(path);
|
||||||
}
|
}
|
||||||
|
|
@ -426,34 +315,19 @@ pub(crate) fn state_root() -> PathBuf {
|
||||||
.join("tailscale")
|
.join("tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}-{}-{}",
|
"{}-{}",
|
||||||
slug(&request.account_name),
|
slug(&request.account_name),
|
||||||
slug(&request.identity_name),
|
slug(&request.identity_name)
|
||||||
slug(control_scope(request))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String {
|
fn session_key(account_name: &str, identity_name: &str) -> String {
|
||||||
format!(
|
format!("{account_name}:{identity_name}")
|
||||||
"{}:{}:{}",
|
|
||||||
request.account_name,
|
|
||||||
request.identity_name,
|
|
||||||
control_scope(request)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn control_scope(request: &TailscaleLoginStartRequest) -> &str {
|
fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
|
||||||
request
|
|
||||||
.control_url
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or("tailscale-managed")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
|
|
||||||
request
|
request
|
||||||
.hostname
|
.hostname
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -496,24 +370,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_dir_is_scoped_by_account_identity_and_control_plane() {
|
fn state_dir_is_stable_by_account_and_identity() {
|
||||||
let request = TailscaleLoginStartRequest {
|
let request = TailscaleLoginStartRequest {
|
||||||
account_name: "default".to_owned(),
|
account_name: "default".to_owned(),
|
||||||
identity_name: "apple".to_owned(),
|
identity_name: "apple".to_owned(),
|
||||||
hostname: None,
|
hostname: None,
|
||||||
control_url: None,
|
control_url: None,
|
||||||
packet_socket: None,
|
|
||||||
};
|
};
|
||||||
assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed");
|
assert_eq!(session_dir_name(&request), "default-apple");
|
||||||
assert_eq!(default_hostname(&request), "burrow-apple");
|
assert_eq!(default_hostname(&request), "burrow-apple");
|
||||||
|
|
||||||
let custom_request = TailscaleLoginStartRequest {
|
|
||||||
control_url: Some("https://ts.burrow.net".to_owned()),
|
|
||||||
..request
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
session_dir_name(&custom_request),
|
|
||||||
"default-apple-httpstsburrownet"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use reqwest::{Client, StatusCode, Url};
|
use reqwest::{Client, StatusCode, Url};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
use super::TailnetProvider;
|
use super::TailnetProvider;
|
||||||
|
|
||||||
|
|
@ -44,7 +43,6 @@ struct WebFingerLink {
|
||||||
|
|
||||||
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
||||||
let domain = email_domain(email)?;
|
let domain = email_domain(email)?;
|
||||||
info!(%email, %domain, "tailnet discovery requested");
|
|
||||||
let base_url = Url::parse(&format!("https://{domain}"))
|
let base_url = Url::parse(&format!("https://{domain}"))
|
||||||
.with_context(|| format!("invalid discovery domain {domain}"))?;
|
.with_context(|| format!("invalid discovery domain {domain}"))?;
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
|
|
@ -118,21 +116,12 @@ pub async fn discover_tailnet_at(
|
||||||
base_url: &Url,
|
base_url: &Url,
|
||||||
) -> Result<TailnetDiscovery> {
|
) -> Result<TailnetDiscovery> {
|
||||||
let domain = email_domain(email)?;
|
let domain = email_domain(email)?;
|
||||||
debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery");
|
|
||||||
|
|
||||||
if let Some(discovery) = discover_well_known(client, base_url).await? {
|
if let Some(discovery) = discover_well_known(client, base_url).await? {
|
||||||
info!(
|
|
||||||
%email,
|
|
||||||
%domain,
|
|
||||||
authority = %discovery.authority,
|
|
||||||
provider = ?discovery.provider,
|
|
||||||
"resolved tailnet discovery from well-known document"
|
|
||||||
);
|
|
||||||
return Ok(TailnetDiscovery { domain, ..discovery });
|
return Ok(TailnetDiscovery { domain, ..discovery });
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
||||||
info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger");
|
|
||||||
return Ok(TailnetDiscovery {
|
return Ok(TailnetDiscovery {
|
||||||
domain,
|
domain,
|
||||||
provider: inferred_provider(Some(&authority), None),
|
provider: inferred_provider(Some(&authority), None),
|
||||||
|
|
@ -173,7 +162,6 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
|
||||||
let url = base_url
|
let url = base_url
|
||||||
.join(TAILNET_DISCOVERY_PATH)
|
.join(TAILNET_DISCOVERY_PATH)
|
||||||
.context("failed to build tailnet discovery URL")?;
|
.context("failed to build tailnet discovery URL")?;
|
||||||
debug!(%url, "requesting tailnet well-known document");
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
|
|
@ -199,7 +187,6 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("resource", &format!("acct:{email}"))
|
.append_pair("resource", &format!("acct:{email}"))
|
||||||
.append_pair("rel", TAILNET_DISCOVERY_REL);
|
.append_pair("rel", TAILNET_DISCOVERY_REL);
|
||||||
debug!(%email, url = %url, "requesting tailnet webfinger document");
|
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use rusqlite::Connection;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tonic::{Request, Response, Status as RspStatus};
|
use tonic::{Request, Response, Status as RspStatus};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::warn;
|
||||||
use tun::tokio::TunInterface;
|
use tun::tokio::TunInterface;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|
@ -16,15 +16,15 @@ use super::{
|
||||||
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
||||||
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
||||||
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
||||||
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
|
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
|
||||||
TunnelStatusResponse,
|
TunnelStatusResponse,
|
||||||
},
|
},
|
||||||
runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
|
runtime::{ActiveTunnel, ResolvedTunnel},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::server::tailscale::{
|
auth::server::tailscale::{
|
||||||
packet_socket_path, TailscaleBridgeManager,
|
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
|
||||||
TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus,
|
TailscaleLoginStatus,
|
||||||
},
|
},
|
||||||
control::discovery,
|
control::discovery,
|
||||||
daemon::rpc::ServerConfig,
|
daemon::rpc::ServerConfig,
|
||||||
|
|
@ -87,20 +87,11 @@ impl DaemonRPCServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
|
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
|
||||||
let config = {
|
let config = self
|
||||||
let active = self.active_tunnel.read().await;
|
|
||||||
active
|
|
||||||
.as_ref()
|
|
||||||
.map(|tunnel| tunnel.server_config().clone())
|
|
||||||
};
|
|
||||||
let config = match config {
|
|
||||||
Some(config) => config,
|
|
||||||
None => self
|
|
||||||
.resolve_tunnel()
|
.resolve_tunnel()
|
||||||
.await?
|
.await?
|
||||||
.server_config()
|
.server_config()
|
||||||
.map_err(proc_err)?,
|
.map_err(proc_err)?;
|
||||||
};
|
|
||||||
Ok(configuration_rsp(config))
|
Ok(configuration_rsp(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,18 +111,8 @@ impl DaemonRPCServer {
|
||||||
|
|
||||||
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
|
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
|
||||||
let _ = self.stop_active_tunnel().await?;
|
let _ = self.stop_active_tunnel().await?;
|
||||||
let tailnet_helper = match &desired {
|
|
||||||
ResolvedTunnel::Tailnet { identity, config } => Some(
|
|
||||||
self.tailnet_login
|
|
||||||
.ensure_session(tailnet_helper_request(identity, config))
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?
|
|
||||||
.helper,
|
|
||||||
),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let active = desired
|
let active = desired
|
||||||
.start(self.tun_interface.clone(), tailnet_helper)
|
.start(self.tun_interface.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
self.active_tunnel.write().await.replace(active);
|
self.active_tunnel.write().await.replace(active);
|
||||||
|
|
@ -156,23 +137,6 @@ impl DaemonRPCServer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tailnet_bridge_request(
|
|
||||||
account_name: String,
|
|
||||||
identity_name: String,
|
|
||||||
hostname: String,
|
|
||||||
authority: String,
|
|
||||||
) -> BridgeLoginStartRequest {
|
|
||||||
let mut request = BridgeLoginStartRequest {
|
|
||||||
account_name,
|
|
||||||
identity_name,
|
|
||||||
hostname: (!hostname.trim().is_empty()).then_some(hostname),
|
|
||||||
control_url: Self::tailnet_control_url(&authority),
|
|
||||||
packet_socket: None,
|
|
||||||
};
|
|
||||||
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tailnet_control_url(authority: &str) -> Option<String> {
|
fn tailnet_control_url(authority: &str) -> Option<String> {
|
||||||
let authority = discovery::normalize_authority(authority);
|
let authority = discovery::normalize_authority(authority);
|
||||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
||||||
|
|
@ -182,7 +146,6 @@ impl DaemonRPCServer {
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
impl Tunnel for DaemonRPCServer {
|
impl Tunnel for DaemonRPCServer {
|
||||||
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
|
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
|
||||||
type TunnelPacketsStream = ReceiverStream<Result<TunnelPacket, RspStatus>>;
|
|
||||||
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
|
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
|
||||||
|
|
||||||
async fn tunnel_configuration(
|
async fn tunnel_configuration(
|
||||||
|
|
@ -208,62 +171,6 @@ impl Tunnel for DaemonRPCServer {
|
||||||
Ok(Response::new(ReceiverStream::new(rx)))
|
Ok(Response::new(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tunnel_packets(
|
|
||||||
&self,
|
|
||||||
request: Request<tonic::Streaming<TunnelPacket>>,
|
|
||||||
) -> Result<Response<Self::TunnelPacketsStream>, RspStatus> {
|
|
||||||
let (packet_tx, mut packet_rx) = {
|
|
||||||
let guard = self.active_tunnel.read().await;
|
|
||||||
let Some(active) = guard.as_ref() else {
|
|
||||||
return Err(RspStatus::failed_precondition("no active tunnel"));
|
|
||||||
};
|
|
||||||
active.packet_stream().ok_or_else(|| {
|
|
||||||
RspStatus::failed_precondition(
|
|
||||||
"active tunnel does not support packet streaming",
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel(128);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match packet_rx.recv().await {
|
|
||||||
Ok(payload) => {
|
|
||||||
if tx.send(Ok(TunnelPacket { payload })).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut inbound = request.into_inner();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match inbound.message().await {
|
|
||||||
Ok(Some(packet)) => {
|
|
||||||
debug!(
|
|
||||||
"daemon tunnel packet stream received {} bytes from client",
|
|
||||||
packet.payload.len()
|
|
||||||
);
|
|
||||||
if packet_tx.send(packet.payload).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(error) => {
|
|
||||||
warn!("tailnet packet stream receive error: {error}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Response::new(ReceiverStream::new(rx)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
|
async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
|
||||||
let desired = self.resolve_tunnel().await?;
|
let desired = self.resolve_tunnel().await?;
|
||||||
let already_running = {
|
let already_running = {
|
||||||
|
|
@ -380,16 +287,9 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<TailnetDiscoverRequest>,
|
request: Request<TailnetDiscoverRequest>,
|
||||||
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
info!(email = %request.email, "daemon tailnet discover RPC received");
|
|
||||||
let discovery = discovery::discover_tailnet(&request.email)
|
let discovery = discovery::discover_tailnet(&request.email)
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
info!(
|
|
||||||
email = %request.email,
|
|
||||||
authority = %discovery.authority,
|
|
||||||
provider = ?discovery.provider,
|
|
||||||
"daemon tailnet discover RPC resolved"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Response::new(TailnetDiscoverResponse {
|
Ok(Response::new(TailnetDiscoverResponse {
|
||||||
domain: discovery.domain,
|
domain: discovery.domain,
|
||||||
|
|
@ -425,32 +325,17 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
info!(
|
|
||||||
account = %request.account_name,
|
|
||||||
identity = %request.identity_name,
|
|
||||||
authority = %request.authority,
|
|
||||||
"daemon tailnet login start RPC received"
|
|
||||||
);
|
|
||||||
let response = self
|
let response = self
|
||||||
.tailnet_login
|
.tailnet_login
|
||||||
.start_login(Self::tailnet_bridge_request(
|
.start_login(BridgeLoginStartRequest {
|
||||||
request.account_name,
|
account_name: request.account_name,
|
||||||
request.identity_name,
|
identity_name: request.identity_name,
|
||||||
request.hostname,
|
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
|
||||||
request.authority,
|
control_url: Self::tailnet_control_url(&request.authority),
|
||||||
))
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
|
|
||||||
info!(
|
|
||||||
session_id = %response.session_id,
|
|
||||||
backend_state = %response.status.backend_state,
|
|
||||||
running = response.status.running,
|
|
||||||
needs_login = response.status.needs_login,
|
|
||||||
auth_url = ?response.status.auth_url,
|
|
||||||
"daemon tailnet login start RPC resolved"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Response::new(tailnet_login_rsp(
|
Ok(Response::new(tailnet_login_rsp(
|
||||||
response.session_id,
|
response.session_id,
|
||||||
response.status,
|
response.status,
|
||||||
|
|
@ -462,7 +347,6 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
info!(session_id = %request.session_id, "daemon tailnet login status RPC received");
|
|
||||||
let status = self
|
let status = self
|
||||||
.tailnet_login
|
.tailnet_login
|
||||||
.status(&request.session_id)
|
.status(&request.session_id)
|
||||||
|
|
@ -471,14 +355,6 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
let Some(status) = status else {
|
let Some(status) = status else {
|
||||||
return Err(RspStatus::not_found("tailnet login session not found"));
|
return Err(RspStatus::not_found("tailnet login session not found"));
|
||||||
};
|
};
|
||||||
info!(
|
|
||||||
session_id = %request.session_id,
|
|
||||||
backend_state = %status.backend_state,
|
|
||||||
running = status.running,
|
|
||||||
needs_login = status.needs_login,
|
|
||||||
auth_url = ?status.auth_url,
|
|
||||||
"daemon tailnet login status RPC resolved"
|
|
||||||
);
|
|
||||||
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,12 +381,8 @@ fn proc_err(err: impl ToString) -> RspStatus {
|
||||||
|
|
||||||
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
|
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
|
||||||
TunnelConfigurationResponse {
|
TunnelConfigurationResponse {
|
||||||
addresses: config.address,
|
|
||||||
mtu: config.mtu.unwrap_or(1000),
|
mtu: config.mtu.unwrap_or(1000),
|
||||||
routes: config.routes,
|
addresses: config.address,
|
||||||
dns_servers: config.dns_servers,
|
|
||||||
search_domains: config.search_domains,
|
|
||||||
include_default_route: config.include_default_route,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,14 +68,6 @@ impl TryFrom<&TunInterface> for ServerInfo {
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub address: Vec<String>,
|
pub address: Vec<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub routes: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dns_servers: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub search_domains: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub include_default_route: bool,
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub mtu: Option<i32>,
|
pub mtu: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
@ -86,14 +78,6 @@ impl TryFrom<&Config> for ServerConfig {
|
||||||
fn try_from(config: &Config) -> anyhow::Result<Self> {
|
fn try_from(config: &Config) -> anyhow::Result<Self> {
|
||||||
Ok(ServerConfig {
|
Ok(ServerConfig {
|
||||||
address: config.interface.address.clone(),
|
address: config.interface.address.clone(),
|
||||||
routes: config
|
|
||||||
.peers
|
|
||||||
.iter()
|
|
||||||
.flat_map(|peer| peer.allowed_ips.iter().cloned())
|
|
||||||
.collect(),
|
|
||||||
dns_servers: config.interface.dns.clone(),
|
|
||||||
search_domains: Vec::new(),
|
|
||||||
include_default_route: false,
|
|
||||||
name: None,
|
name: None,
|
||||||
mtu: config.interface.mtu.map(|mtu| mtu as i32),
|
mtu: config.interface.mtu.map(|mtu| mtu as i32),
|
||||||
})
|
})
|
||||||
|
|
@ -104,10 +88,6 @@ impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
address: vec!["10.13.13.2".to_string()], // Dummy remote address
|
address: vec!["10.13.13.2".to_string()], // Dummy remote address
|
||||||
routes: Vec::new(),
|
|
||||||
dns_servers: Vec::new(),
|
|
||||||
search_domains: Vec::new(),
|
|
||||||
include_default_route: false,
|
|
||||||
name: None,
|
name: None,
|
||||||
mtu: None,
|
mtu: None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
source: burrow/src/daemon/rpc/response.rs
|
source: burrow/src/daemon/rpc/response.rs
|
||||||
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?"
|
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?"
|
||||||
---
|
---
|
||||||
{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"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 anyhow::{Context, Result};
|
||||||
use tokio::{
|
use tokio::{sync::RwLock, task::JoinHandle};
|
||||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
|
||||||
net::UnixStream,
|
|
||||||
sync::{broadcast, mpsc, RwLock},
|
|
||||||
task::JoinHandle,
|
|
||||||
time::{sleep, Duration},
|
|
||||||
};
|
|
||||||
use tun::{tokio::TunInterface, TunOptions};
|
use tun::{tokio::TunInterface, TunOptions};
|
||||||
|
|
||||||
use super::rpc::{
|
use super::rpc::{
|
||||||
|
|
@ -15,11 +9,7 @@ use super::rpc::{
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::server::tailscale::{
|
control::TailnetConfig,
|
||||||
default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess,
|
|
||||||
TailscaleLoginStartRequest, TailscaleLoginStatus,
|
|
||||||
},
|
|
||||||
control::{discovery, TailnetConfig},
|
|
||||||
wireguard::{Config, Interface as WireGuardInterface},
|
wireguard::{Config, Interface as WireGuardInterface},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -88,19 +78,11 @@ impl ResolvedTunnel {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { .. } => Ok(ServerConfig {
|
Self::Passthrough { .. } => Ok(ServerConfig {
|
||||||
address: Vec::new(),
|
address: Vec::new(),
|
||||||
routes: Vec::new(),
|
|
||||||
dns_servers: Vec::new(),
|
|
||||||
search_domains: Vec::new(),
|
|
||||||
include_default_route: false,
|
|
||||||
name: None,
|
name: None,
|
||||||
mtu: Some(1500),
|
mtu: Some(1500),
|
||||||
}),
|
}),
|
||||||
Self::Tailnet { .. } => Ok(ServerConfig {
|
Self::Tailnet { .. } => Ok(ServerConfig {
|
||||||
address: Vec::new(),
|
address: Vec::new(),
|
||||||
routes: tailnet_routes(),
|
|
||||||
dns_servers: tailnet_dns_servers(),
|
|
||||||
search_domains: Vec::new(),
|
|
||||||
include_default_route: false,
|
|
||||||
name: None,
|
name: None,
|
||||||
mtu: Some(1280),
|
mtu: Some(1280),
|
||||||
}),
|
}),
|
||||||
|
|
@ -111,71 +93,21 @@ impl ResolvedTunnel {
|
||||||
pub async fn start(
|
pub async fn start(
|
||||||
self,
|
self,
|
||||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||||
tailnet_helper: Option<Arc<TailscaleHelperProcess>>,
|
|
||||||
) -> Result<ActiveTunnel> {
|
) -> Result<ActiveTunnel> {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough {
|
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
|
||||||
identity,
|
Self::Tailnet { config, .. } => Err(anyhow::anyhow!(
|
||||||
server_config: ServerConfig {
|
"tailnet runtime is not wired in this checkout yet ({:?})",
|
||||||
address: Vec::new(),
|
config.provider
|
||||||
routes: Vec::new(),
|
)),
|
||||||
dns_servers: Vec::new(),
|
|
||||||
search_domains: Vec::new(),
|
|
||||||
include_default_route: false,
|
|
||||||
name: None,
|
|
||||||
mtu: Some(1500),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Self::Tailnet { identity, config } => {
|
|
||||||
let (helper, shutdown_helper_on_stop) = match tailnet_helper {
|
|
||||||
Some(helper) => (helper, false),
|
|
||||||
None => {
|
|
||||||
let helper_request = tailnet_helper_request(&identity, &config);
|
|
||||||
let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?);
|
|
||||||
(helper, true)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let status = wait_for_tailnet_ready(helper.as_ref()).await?;
|
|
||||||
let server_config = tailnet_server_config(&status);
|
|
||||||
let packet_socket = helper
|
|
||||||
.packet_socket()
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?;
|
|
||||||
let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?;
|
|
||||||
#[cfg(target_vendor = "apple")]
|
|
||||||
let tun_task = None;
|
|
||||||
#[cfg(not(target_vendor = "apple"))]
|
|
||||||
let tun_task = {
|
|
||||||
let tun = TunOptions::new().open()?;
|
|
||||||
tun_interface.write().await.replace(tun);
|
|
||||||
Some(tokio::spawn(run_tailnet_tun_bridge(
|
|
||||||
tun_interface.clone(),
|
|
||||||
packet_bridge.outbound_sender(),
|
|
||||||
packet_bridge.subscribe(),
|
|
||||||
)))
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ActiveTunnel::Tailnet {
|
|
||||||
identity,
|
|
||||||
server_config,
|
|
||||||
helper,
|
|
||||||
shutdown_helper_on_stop,
|
|
||||||
packet_bridge,
|
|
||||||
tun_task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Self::WireGuard { identity, config } => {
|
Self::WireGuard { identity, config } => {
|
||||||
let server_config = ServerConfig::try_from(&config)?;
|
|
||||||
let tun = TunOptions::new().open()?;
|
let tun = TunOptions::new().open()?;
|
||||||
tun_interface.write().await.replace(tun);
|
tun_interface.write().await.replace(tun);
|
||||||
|
|
||||||
match start_wireguard_runtime(config, tun_interface.clone()).await {
|
match start_wireguard_runtime(config, tun_interface.clone()).await {
|
||||||
Ok((interface, task)) => Ok(ActiveTunnel::WireGuard {
|
Ok((interface, task)) => {
|
||||||
identity,
|
Ok(ActiveTunnel::WireGuard { identity, interface, task })
|
||||||
server_config,
|
}
|
||||||
interface,
|
|
||||||
task,
|
|
||||||
}),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tun_interface.write().await.take();
|
tun_interface.write().await.take();
|
||||||
Err(err)
|
Err(err)
|
||||||
|
|
@ -189,19 +121,9 @@ impl ResolvedTunnel {
|
||||||
pub enum ActiveTunnel {
|
pub enum ActiveTunnel {
|
||||||
Passthrough {
|
Passthrough {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
server_config: ServerConfig,
|
|
||||||
},
|
|
||||||
Tailnet {
|
|
||||||
identity: RuntimeIdentity,
|
|
||||||
server_config: ServerConfig,
|
|
||||||
helper: Arc<TailscaleHelperProcess>,
|
|
||||||
shutdown_helper_on_stop: bool,
|
|
||||||
packet_bridge: TailnetPacketBridge,
|
|
||||||
tun_task: Option<JoinHandle<Result<()>>>,
|
|
||||||
},
|
},
|
||||||
WireGuard {
|
WireGuard {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
server_config: ServerConfig,
|
|
||||||
interface: Arc<RwLock<WireGuardInterface>>,
|
interface: Arc<RwLock<WireGuardInterface>>,
|
||||||
task: JoinHandle<Result<()>>,
|
task: JoinHandle<Result<()>>,
|
||||||
},
|
},
|
||||||
|
|
@ -210,69 +132,15 @@ pub enum ActiveTunnel {
|
||||||
impl ActiveTunnel {
|
impl ActiveTunnel {
|
||||||
pub fn identity(&self) -> &RuntimeIdentity {
|
pub fn identity(&self) -> &RuntimeIdentity {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity, .. }
|
Self::Passthrough { identity }
|
||||||
| Self::Tailnet { identity, .. }
|
|
||||||
| Self::WireGuard { identity, .. } => identity,
|
| Self::WireGuard { identity, .. } => identity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn server_config(&self) -> &ServerConfig {
|
|
||||||
match self {
|
|
||||||
Self::Passthrough { server_config, .. }
|
|
||||||
| Self::Tailnet { server_config, .. }
|
|
||||||
| Self::WireGuard { server_config, .. } => server_config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn packet_stream(
|
|
||||||
&self,
|
|
||||||
) -> Option<(mpsc::Sender<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
|
|
||||||
match self {
|
|
||||||
Self::Tailnet { packet_bridge, .. } => Some((
|
|
||||||
packet_bridge.outbound_sender(),
|
|
||||||
packet_bridge.subscribe(),
|
|
||||||
)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
|
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { .. } => Ok(()),
|
Self::Passthrough { .. } => Ok(()),
|
||||||
Self::Tailnet {
|
Self::WireGuard { interface, task, .. } => {
|
||||||
helper,
|
|
||||||
shutdown_helper_on_stop,
|
|
||||||
packet_bridge,
|
|
||||||
tun_task,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(tun_task) = tun_task {
|
|
||||||
tun_task.abort();
|
|
||||||
match tun_task.await {
|
|
||||||
Ok(Ok(())) => {}
|
|
||||||
Ok(Err(err)) => return Err(err),
|
|
||||||
Err(err) if err.is_cancelled() => {}
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packet_bridge.task.abort();
|
|
||||||
match packet_bridge.task.await {
|
|
||||||
Ok(Ok(())) => {}
|
|
||||||
Ok(Err(err)) => return Err(err),
|
|
||||||
Err(err) if err.is_cancelled() => {}
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
}
|
|
||||||
tun_interface.write().await.take();
|
|
||||||
if shutdown_helper_on_stop {
|
|
||||||
helper.shutdown().await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Self::WireGuard {
|
|
||||||
interface,
|
|
||||||
task,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
interface.read().await.remove_tun().await;
|
interface.read().await.remove_tun().await;
|
||||||
let task_result = task.await;
|
let task_result = task.await;
|
||||||
tun_interface.write().await.take();
|
tun_interface.write().await.take();
|
||||||
|
|
@ -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(
|
async fn start_wireguard_runtime(
|
||||||
config: Config,
|
config: Config,
|
||||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||||
|
|
@ -314,279 +166,6 @@ async fn start_wireguard_runtime(
|
||||||
Ok((interface, task))
|
Ok((interface, task))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tailnet_helper_request(
|
|
||||||
identity: &RuntimeIdentity,
|
|
||||||
config: &TailnetConfig,
|
|
||||||
) -> TailscaleLoginStartRequest {
|
|
||||||
let account_name = config
|
|
||||||
.account
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.unwrap_or("default")
|
|
||||||
.to_owned();
|
|
||||||
let identity_name = config
|
|
||||||
.identity
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| match identity {
|
|
||||||
RuntimeIdentity::Network { id, .. } => format!("network-{id}"),
|
|
||||||
RuntimeIdentity::Passthrough => "apple".to_owned(),
|
|
||||||
});
|
|
||||||
let control_url = config.authority.as_deref().and_then(|authority| {
|
|
||||||
let authority = discovery::normalize_authority(authority);
|
|
||||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut request = TailscaleLoginStartRequest {
|
|
||||||
account_name,
|
|
||||||
identity_name,
|
|
||||||
hostname: config.hostname.clone(),
|
|
||||||
control_url,
|
|
||||||
packet_socket: None,
|
|
||||||
};
|
|
||||||
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
|
||||||
if request
|
|
||||||
.hostname
|
|
||||||
.as_deref()
|
|
||||||
.map(|value| value.trim().is_empty())
|
|
||||||
.unwrap_or(true)
|
|
||||||
{
|
|
||||||
request.hostname = Some(default_hostname(&request));
|
|
||||||
}
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result<TailscaleLoginStatus> {
|
|
||||||
let mut last_status = None;
|
|
||||||
for _ in 0..120 {
|
|
||||||
let status = helper.status().await?;
|
|
||||||
if status.running && !status.tailscale_ips.is_empty() {
|
|
||||||
return Ok(status);
|
|
||||||
}
|
|
||||||
if status.needs_login || status.auth_url.is_some() {
|
|
||||||
bail!("tailnet runtime requires a completed login before the tunnel can start");
|
|
||||||
}
|
|
||||||
last_status = Some(status);
|
|
||||||
sleep(Duration::from_millis(250)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(status) = last_status {
|
|
||||||
bail!(
|
|
||||||
"tailnet helper never became ready (backend_state={})",
|
|
||||||
status.backend_state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bail!("tailnet helper never produced a status update")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig {
|
|
||||||
let mut search_domains = Vec::new();
|
|
||||||
if let Some(suffix) = status.magic_dns_suffix.as_deref() {
|
|
||||||
let suffix = suffix.trim().trim_end_matches('.');
|
|
||||||
if !suffix.is_empty() {
|
|
||||||
search_domains.push(suffix.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ServerConfig {
|
|
||||||
address: status
|
|
||||||
.tailscale_ips
|
|
||||||
.iter()
|
|
||||||
.map(|ip| tailnet_cidr(ip))
|
|
||||||
.collect(),
|
|
||||||
routes: tailnet_routes(),
|
|
||||||
dns_servers: tailnet_dns_servers(),
|
|
||||||
search_domains,
|
|
||||||
include_default_route: false,
|
|
||||||
name: status.self_dns_name.clone(),
|
|
||||||
mtu: Some(1280),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tailnet_routes() -> Vec<String> {
|
|
||||||
vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tailnet_dns_servers() -> Vec<String> {
|
|
||||||
vec!["100.100.100.100".to_owned()]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tailnet_cidr(ip: &str) -> String {
|
|
||||||
if ip.contains('/') {
|
|
||||||
return ip.to_owned();
|
|
||||||
}
|
|
||||||
if ip.contains(':') {
|
|
||||||
format!("{ip}/128")
|
|
||||||
} else {
|
|
||||||
format!("{ip}/32")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result<TailnetPacketBridge> {
|
|
||||||
let mut last_error = None;
|
|
||||||
let mut stream = None;
|
|
||||||
for _ in 0..50 {
|
|
||||||
match UnixStream::connect(&packet_socket).await {
|
|
||||||
Ok(connected) => {
|
|
||||||
stream = Some(connected);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
last_error = Some(err);
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let stream = if let Some(stream) = stream {
|
|
||||||
stream
|
|
||||||
} else {
|
|
||||||
return Err(last_error
|
|
||||||
.context("failed to connect to tailnet helper packet socket")?
|
|
||||||
.into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let (outbound_tx, outbound_rx) = mpsc::channel(128);
|
|
||||||
let (inbound_tx, _) = broadcast::channel(128);
|
|
||||||
let task = tokio::spawn(run_tailnet_socket_bridge(
|
|
||||||
stream,
|
|
||||||
outbound_rx,
|
|
||||||
inbound_tx.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
Ok(TailnetPacketBridge {
|
|
||||||
outbound: outbound_tx,
|
|
||||||
inbound: inbound_tx,
|
|
||||||
task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_tailnet_socket_bridge(
|
|
||||||
stream: UnixStream,
|
|
||||||
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
|
||||||
inbound_tx: broadcast::Sender<Vec<u8>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let (mut reader, mut writer) = stream.into_split();
|
|
||||||
|
|
||||||
let inbound = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let packet = read_packet_frame(&mut reader).await?;
|
|
||||||
tracing::debug!(
|
|
||||||
"tailnet packet bridge received {} bytes from helper socket",
|
|
||||||
packet.len()
|
|
||||||
);
|
|
||||||
let _ = inbound_tx.send(packet);
|
|
||||||
}
|
|
||||||
#[allow(unreachable_code)]
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let outbound = tokio::spawn(async move {
|
|
||||||
while let Some(packet) = outbound_rx.recv().await {
|
|
||||||
tracing::debug!(
|
|
||||||
"tailnet packet bridge writing {} bytes to helper socket",
|
|
||||||
packet.len()
|
|
||||||
);
|
|
||||||
write_packet_frame(&mut writer, &packet).await?;
|
|
||||||
}
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
|
|
||||||
inbound_result?;
|
|
||||||
outbound_result?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_vendor = "apple"))]
|
|
||||||
async fn run_tailnet_tun_bridge(
|
|
||||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
|
||||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
|
||||||
mut inbound_rx: broadcast::Receiver<Vec<u8>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let inbound_tun = tun_interface.clone();
|
|
||||||
let inbound = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let packet = match inbound_rx.recv().await {
|
|
||||||
Ok(packet) => packet,
|
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
|
||||||
};
|
|
||||||
let guard = inbound_tun.read().await;
|
|
||||||
let Some(tun) = guard.as_ref() else {
|
|
||||||
bail!("tailnet tun interface unavailable");
|
|
||||||
};
|
|
||||||
tun.send(&packet)
|
|
||||||
.await
|
|
||||||
.context("failed to write tailnet packet to tun")?;
|
|
||||||
}
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let outbound_tun = tun_interface.clone();
|
|
||||||
let outbound = tokio::spawn(async move {
|
|
||||||
let mut buf = vec![0u8; 65_535];
|
|
||||||
loop {
|
|
||||||
let len = {
|
|
||||||
let guard = outbound_tun.read().await;
|
|
||||||
let Some(tun) = guard.as_ref() else {
|
|
||||||
bail!("tailnet tun interface unavailable");
|
|
||||||
};
|
|
||||||
tun.recv(&mut buf)
|
|
||||||
.await
|
|
||||||
.context("failed to read packet from tailnet tun")?
|
|
||||||
};
|
|
||||||
outbound_tx
|
|
||||||
.send(buf[..len].to_vec())
|
|
||||||
.await
|
|
||||||
.context("failed to forward packet to tailnet helper")?;
|
|
||||||
}
|
|
||||||
#[allow(unreachable_code)]
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
|
|
||||||
inbound_result?;
|
|
||||||
outbound_result?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_packet_frame<R>(reader: &mut R) -> Result<Vec<u8>>
|
|
||||||
where
|
|
||||||
R: AsyncRead + Unpin,
|
|
||||||
{
|
|
||||||
let mut len_buf = [0u8; 4];
|
|
||||||
reader
|
|
||||||
.read_exact(&mut len_buf)
|
|
||||||
.await
|
|
||||||
.context("failed to read tailnet packet frame length")?;
|
|
||||||
let len = u32::from_be_bytes(len_buf) as usize;
|
|
||||||
let mut packet = vec![0u8; len];
|
|
||||||
reader
|
|
||||||
.read_exact(&mut packet)
|
|
||||||
.await
|
|
||||||
.context("failed to read tailnet packet frame payload")?;
|
|
||||||
Ok(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_packet_frame<W>(writer: &mut W, packet: &[u8]) -> Result<()>
|
|
||||||
where
|
|
||||||
W: AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
writer
|
|
||||||
.write_all(&(packet.len() as u32).to_be_bytes())
|
|
||||||
.await
|
|
||||||
.context("failed to write tailnet packet frame length")?;
|
|
||||||
writer
|
|
||||||
.write_all(packet)
|
|
||||||
.await
|
|
||||||
.context("failed to write tailnet packet frame payload")?;
|
|
||||||
writer
|
|
||||||
.flush()
|
|
||||||
.await
|
|
||||||
.context("failed to flush tailnet packet frame")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -600,19 +179,4 @@ mod tests {
|
||||||
Vec::<String>::new()
|
Vec::<String>::new()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tailnet_server_config_uses_host_prefixes() {
|
|
||||||
let status = TailscaleLoginStatus {
|
|
||||||
running: true,
|
|
||||||
tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let config = tailnet_server_config(&status);
|
|
||||||
assert_eq!(
|
|
||||||
config.address,
|
|
||||||
vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"]
|
|
||||||
);
|
|
||||||
assert_eq!(config.mtu, Some(1280));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,9 @@ async fn try_tailnet_discover(email: &str) -> Result<()> {
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
let mut client = BurrowClient::from_uds().await?;
|
||||||
let response = client
|
let response = client
|
||||||
.tailnet_client
|
.tailnet_client
|
||||||
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() })
|
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest {
|
||||||
|
email: email.to_owned(),
|
||||||
|
})
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
println!("Tailnet Discover Response: {:?}", response);
|
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",
|
"tailnet ping received {} bytes from daemon packet stream",
|
||||||
packet.payload.len()
|
packet.payload.len()
|
||||||
);
|
);
|
||||||
if let Some(reply) =
|
if let Some(reply) = parse_icmp_echo_reply(
|
||||||
parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)?
|
&packet.payload,
|
||||||
{
|
local_ip,
|
||||||
|
remote_ip,
|
||||||
|
identifier,
|
||||||
|
sequence,
|
||||||
|
)? {
|
||||||
break Ok::<_, anyhow::Error>(reply);
|
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 {
|
let egress_task = tokio::spawn(async move {
|
||||||
while let Some(packet) = stack_stream.next().await {
|
while let Some(packet) = stack_stream.next().await {
|
||||||
let payload = packet.context("failed to read outbound packet from userspace stack")?;
|
let payload =
|
||||||
|
packet.context("failed to read outbound packet from userspace stack")?;
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"tailnet udp echo sending {} bytes into daemon packet stream",
|
"tailnet udp echo sending {} bytes into daemon packet stream",
|
||||||
payload.len()
|
payload.len()
|
||||||
|
|
@ -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))
|
.send((message.as_bytes().to_vec(), local_addr, remote_addr))
|
||||||
.await
|
.await
|
||||||
.context("failed to send UDP echo probe into userspace stack")?;
|
.context("failed to send UDP echo probe into userspace stack")?;
|
||||||
log::debug!("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())
|
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
|
||||||
.await
|
.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"))]
|
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
||||||
fn select_tailnet_local_ip(
|
fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result<std::net::IpAddr> {
|
||||||
addresses: &[String],
|
|
||||||
remote_ip: std::net::IpAddr,
|
|
||||||
) -> Result<std::net::IpAddr> {
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
let family_is_v4 = remote_ip.is_ipv4();
|
let family_is_v4 = remote_ip.is_ipv4();
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,10 @@ pub fn initialize() {
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let subscriber = {
|
let subscriber = {
|
||||||
// `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on
|
let system_log = Some(tracing_oslog::OsLogger::new(
|
||||||
// current macOS. Keep logging on stderr by default and allow opt-in OSLog
|
"com.hackclub.burrow",
|
||||||
// only when explicitly requested for local debugging.
|
"tracing",
|
||||||
let enable_oslog = matches!(
|
));
|
||||||
std::env::var("BURROW_ENABLE_OSLOG").as_deref(),
|
|
||||||
Ok("1" | "true" | "TRUE" | "yes" | "YES")
|
|
||||||
);
|
|
||||||
let system_log = enable_oslog.then(|| {
|
|
||||||
tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")
|
|
||||||
});
|
|
||||||
let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
|
let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
|
||||||
Registry::default().with(stderr).with(system_log)
|
Registry::default().with(stderr).with(system_log)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
32
flake.nix
32
flake.nix
|
|
@ -94,7 +94,6 @@
|
||||||
pkgs.stdenvNoCC.mkDerivation {
|
pkgs.stdenvNoCC.mkDerivation {
|
||||||
pname = "nsc";
|
pname = "nsc";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
meta.mainProgram = "nsc";
|
|
||||||
dontConfigure = true;
|
dontConfigure = true;
|
||||||
dontBuild = true;
|
dontBuild = true;
|
||||||
unpackPhase = ''
|
unpackPhase = ''
|
||||||
|
|
@ -145,35 +144,6 @@
|
||||||
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
|
subPackages = [ "./cmd/forgejo-nsc-autoscaler" ];
|
||||||
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs=";
|
vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs=";
|
||||||
};
|
};
|
||||||
burrowSrc = lib.cleanSourceWith {
|
|
||||||
src = ./.;
|
|
||||||
filter = path: type:
|
|
||||||
let
|
|
||||||
p = toString path;
|
|
||||||
name = builtins.baseNameOf path;
|
|
||||||
hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p;
|
|
||||||
in
|
|
||||||
!(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result");
|
|
||||||
};
|
|
||||||
burrowPkg = pkgs.rustPlatform.buildRustPackage {
|
|
||||||
pname = "burrow";
|
|
||||||
version = "0.1.0";
|
|
||||||
src = burrowSrc;
|
|
||||||
cargoLock = {
|
|
||||||
lockFile = ./Cargo.lock;
|
|
||||||
outputHashes = {
|
|
||||||
"tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
cargoBuildFlags = [
|
|
||||||
"-p"
|
|
||||||
"burrow"
|
|
||||||
"--bin"
|
|
||||||
"burrow"
|
|
||||||
];
|
|
||||||
nativeBuildInputs = [ pkgs.protobuf ];
|
|
||||||
meta.mainProgram = "burrow";
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
|
|
@ -201,7 +171,6 @@
|
||||||
packages =
|
packages =
|
||||||
{
|
{
|
||||||
agenix = agenix.packages.${system}.agenix;
|
agenix = agenix.packages.${system}.agenix;
|
||||||
burrow = burrowPkg;
|
|
||||||
hcloud-upload-image = hcloudUploadImagePkg;
|
hcloud-upload-image = hcloudUploadImagePkg;
|
||||||
forgejo-nsc-dispatcher = forgejoNscDispatcher;
|
forgejo-nsc-dispatcher = forgejoNscDispatcher;
|
||||||
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
|
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
|
||||||
|
|
@ -214,6 +183,7 @@
|
||||||
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
||||||
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
||||||
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
|
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
|
||||||
|
|
||||||
nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
specialArgs = {
|
specialArgs = {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
- `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers
|
- `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers
|
||||||
- `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host
|
- `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host
|
||||||
- `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists
|
- `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists
|
||||||
- `../Scripts/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
|
## 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/`.
|
3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`.
|
||||||
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
||||||
5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
|
5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
|
||||||
6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`.
|
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. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`.
|
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. 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/`.
|
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/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. 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`.
|
||||||
11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`.
|
|
||||||
|
|
||||||
## Current Constraints
|
## Current Constraints
|
||||||
|
|
||||||
- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`.
|
- `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.
|
||||||
- `services.forgejo-nsc` now expects agenix-backed runtime inputs at `/run/agenix/burrowForgejoNscToken`, `/run/agenix/burrowForgejoNscDispatcherConfig`, and `/run/agenix/burrowForgejoNscAutoscalerConfig`.
|
|
||||||
- Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host.
|
- Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host.
|
||||||
- Public Burrow forge cutover completed on March 15, 2026:
|
- Public Burrow forge cutover completed on March 15, 2026:
|
||||||
- `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21`
|
- `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21`
|
||||||
|
|
|
||||||
|
|
@ -87,24 +87,6 @@ in
|
||||||
group = "root";
|
group = "root";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
};
|
};
|
||||||
age.secrets.burrowForgejoNscToken = {
|
|
||||||
file = ../../../secrets/infra/forgejo-nsc-token.age;
|
|
||||||
owner = "forgejo-nsc";
|
|
||||||
group = "forgejo-nsc";
|
|
||||||
mode = "0400";
|
|
||||||
};
|
|
||||||
age.secrets.burrowForgejoNscDispatcherConfig = {
|
|
||||||
file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age;
|
|
||||||
owner = "forgejo-nsc";
|
|
||||||
group = "forgejo-nsc";
|
|
||||||
mode = "0400";
|
|
||||||
};
|
|
||||||
age.secrets.burrowForgejoNscAutoscalerConfig = {
|
|
||||||
file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age;
|
|
||||||
owner = "forgejo-nsc";
|
|
||||||
group = "forgejo-nsc";
|
|
||||||
mode = "0400";
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.extraHosts = ''
|
networking.extraHosts = ''
|
||||||
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
||||||
|
|
@ -130,13 +112,13 @@ in
|
||||||
|
|
||||||
services.forgejo-nsc = {
|
services.forgejo-nsc = {
|
||||||
enable = true;
|
enable = true;
|
||||||
nscTokenFile = config.age.secrets.burrowForgejoNscToken.path;
|
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
||||||
dispatcher = {
|
dispatcher = {
|
||||||
configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path;
|
configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml";
|
||||||
};
|
};
|
||||||
autoscaler = {
|
autoscaler = {
|
||||||
enable = true;
|
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 {
|
service Tunnel {
|
||||||
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
|
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
|
||||||
rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket);
|
|
||||||
rpc TunnelStart (Empty) returns (Empty);
|
rpc TunnelStart (Empty) returns (Empty);
|
||||||
rpc TunnelStop (Empty) returns (Empty);
|
rpc TunnelStop (Empty) returns (Empty);
|
||||||
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
|
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
|
||||||
|
|
@ -129,12 +128,4 @@ message TunnelStatusResponse {
|
||||||
message TunnelConfigurationResponse {
|
message TunnelConfigurationResponse {
|
||||||
repeated string addresses = 1;
|
repeated string addresses = 1;
|
||||||
int32 mtu = 2;
|
int32 mtu = 2;
|
||||||
repeated string routes = 3;
|
|
||||||
repeated string dns_servers = 4;
|
|
||||||
repeated string search_domains = 5;
|
|
||||||
bool include_default_route = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TunnelPacket {
|
|
||||||
bytes payload = 1;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ in
|
||||||
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
||||||
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients;
|
|
||||||
"secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients;
|
|
||||||
"secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients;
|
|
||||||
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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