Refocus Tailnet flow on Tailscale
This commit is contained in:
parent
3ebb0a8e61
commit
64103abbea
16 changed files with 1856 additions and 342 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: "network.badge.shield.half.filled", accessibilityDescription: nil)
|
button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil)
|
||||||
}
|
}
|
||||||
return statusItem
|
return statusItem
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BurrowTailnetLoginUITests: XCTestCase {
|
final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
|
private enum TailnetLoginMode: String, Decodable {
|
||||||
|
case tailscale
|
||||||
|
case discovered
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TestConfig: Decodable {
|
||||||
|
let email: String
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
let mode: TailnetLoginMode?
|
||||||
|
}
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTailnetLoginThroughAuthentikWebSession() throws {
|
func testTailnetLoginThroughAuthentikWebSession() throws {
|
||||||
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
|
let config = try loadTestConfig()
|
||||||
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
|
let email = config.email
|
||||||
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
|
let username = config.username
|
||||||
|
let password = config.password
|
||||||
|
let mode = config.mode ?? .tailscale
|
||||||
|
let browserIdentity = mode == .tailscale ? email : username
|
||||||
|
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
@ -18,51 +34,75 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
||||||
tailnetButton.tap()
|
tailnetButton.tap()
|
||||||
|
|
||||||
|
configureTailnetIfNeeded(in: app, mode: mode)
|
||||||
|
|
||||||
let discoveryField = app.textFields["tailnet-discovery-email"]
|
let discoveryField = app.textFields["tailnet-discovery-email"]
|
||||||
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
||||||
replaceText(in: discoveryField, with: email)
|
replaceText(in: discoveryField, with: email)
|
||||||
|
|
||||||
let findServerButton = app.buttons["tailnet-find-server"]
|
let serverCard = app.descendants(matching: .any)
|
||||||
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
|
.matching(identifier: "tailnet-server-card")
|
||||||
findServerButton.tap()
|
.firstMatch
|
||||||
|
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
|
||||||
let discoveryCard = app.otherElements["tailnet-discovery-card"]
|
|
||||||
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
|
|
||||||
|
|
||||||
let authorityField = app.textFields["tailnet-authority"]
|
|
||||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
|
||||||
XCTAssertTrue(
|
|
||||||
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
|
|
||||||
"Tailnet authority was not populated from discovery"
|
|
||||||
)
|
|
||||||
|
|
||||||
let probeButton = app.buttons["tailnet-check-connection"]
|
|
||||||
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
|
|
||||||
probeButton.tap()
|
|
||||||
|
|
||||||
let probeCard = app.otherElements["tailnet-authority-probe-card"]
|
|
||||||
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
|
|
||||||
|
|
||||||
let signInButton = app.buttons["tailnet-start-sign-in"]
|
let signInButton = app.buttons["tailnet-start-sign-in"]
|
||||||
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
||||||
signInButton.tap()
|
signInButton.tap()
|
||||||
|
|
||||||
acceptAuthenticationPromptIfNeeded(in: app)
|
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
|
||||||
|
|
||||||
let webSession = webAuthenticationSession()
|
let webSession = webAuthenticationSession()
|
||||||
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
||||||
|
|
||||||
signIntoAuthentik(in: webSession, username: username, password: password)
|
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
|
||||||
|
|
||||||
app.activate()
|
app.activate()
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
|
waitForTailnetSignedIn(in: app, timeout: 60),
|
||||||
"Tailnet sign-in never reached the running state"
|
"Tailnet sign-in never reached the running state"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
|
private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) {
|
||||||
|
guard mode == .discovered else { return }
|
||||||
|
|
||||||
|
openTailnetMenu(in: app)
|
||||||
|
tapMenuButton(named: "Edit Custom Server", in: app)
|
||||||
|
|
||||||
|
openTailnetMenu(in: app)
|
||||||
|
tapMenuButton(named: "Show Advanced Settings", in: app)
|
||||||
|
|
||||||
|
let authorityField = app.textFields["tailnet-authority"]
|
||||||
|
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
||||||
|
replaceText(in: authorityField, with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openTailnetMenu(in app: XCUIApplication) {
|
||||||
|
let moreButton = app.buttons["More"]
|
||||||
|
XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear")
|
||||||
|
moreButton.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapMenuButton(named title: String, in app: XCUIApplication) {
|
||||||
|
let menuButton = firstExistingElement(
|
||||||
|
from: [
|
||||||
|
app.buttons[title],
|
||||||
|
app.descendants(matching: .button)[title],
|
||||||
|
],
|
||||||
|
timeout: 5
|
||||||
|
)
|
||||||
|
XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear")
|
||||||
|
menuButton.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acceptAuthenticationPromptIfNeeded(
|
||||||
|
in app: XCUIApplication,
|
||||||
|
timeout: TimeInterval
|
||||||
|
) {
|
||||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
repeat {
|
||||||
let promptCandidates = [
|
let promptCandidates = [
|
||||||
springboard.buttons["Continue"],
|
springboard.buttons["Continue"],
|
||||||
springboard.buttons["Allow"],
|
springboard.buttons["Allow"],
|
||||||
|
|
@ -70,7 +110,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
app.buttons["Allow"],
|
app.buttons["Allow"],
|
||||||
]
|
]
|
||||||
|
|
||||||
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
for button in promptCandidates where button.exists && button.isHittable {
|
||||||
|
button.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||||
|
} while Date() < deadline
|
||||||
|
|
||||||
|
let promptCandidates = [
|
||||||
|
springboard.buttons["Continue"],
|
||||||
|
springboard.buttons["Allow"],
|
||||||
|
app.buttons["Continue"],
|
||||||
|
app.buttons["Allow"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for button in promptCandidates where button.exists {
|
||||||
button.tap()
|
button.tap()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
||||||
|
followTailnetRedirectIfNeeded(in: webSession)
|
||||||
|
|
||||||
|
if !webSession.exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||||
|
if immediatePasswordField.exists {
|
||||||
|
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
|
||||||
|
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let usernameField = firstExistingElement(
|
let usernameField = firstExistingElement(
|
||||||
in: webSession,
|
in: webSession,
|
||||||
queries: [
|
queries: [
|
||||||
|
|
@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
{ $0.webViews.textFields["Email or Username"] },
|
{ $0.webViews.textFields["Email or Username"] },
|
||||||
{ $0.descendants(matching: .textField).firstMatch },
|
{ $0.descendants(matching: .textField).firstMatch },
|
||||||
],
|
],
|
||||||
timeout: 25
|
timeout: 12
|
||||||
)
|
|
||||||
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
|
|
||||||
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,
|
||||||
|
|
@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
|
|
||||||
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
||||||
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
||||||
replaceSecureText(in: passwordField, with: password)
|
replaceSecureText(in: passwordField, within: webSession, with: password)
|
||||||
tapFirstExistingButton(
|
submitAuthenticationForm(in: webSession, focusedField: passwordField)
|
||||||
in: webSession,
|
}
|
||||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
|
||||||
timeout: 5
|
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
|
||||||
)
|
let redirectCandidates = [
|
||||||
|
webSession.links["Found"],
|
||||||
|
webSession.webViews.links["Found"],
|
||||||
|
webSession.buttons["Found"],
|
||||||
|
webSession.webViews.buttons["Found"],
|
||||||
|
]
|
||||||
|
|
||||||
|
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
|
||||||
|
if redirectLink.exists {
|
||||||
|
redirectLink.tap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
|
app.descendants(matching: .secureTextField).firstMatch,
|
||||||
app.secureTextFields["Password"],
|
app.secureTextFields["Password"],
|
||||||
app.secureTextFields["Password or Token"],
|
app.secureTextFields["Password or Token"],
|
||||||
app.webViews.secureTextFields["Password"],
|
app.webViews.secureTextFields["Password"],
|
||||||
app.webViews.secureTextFields["Password or Token"],
|
app.webViews.secureTextFields["Password or Token"],
|
||||||
app.descendants(matching: .secureTextField).firstMatch,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return firstExistingElement(from: candidates, timeout: timeout)
|
return firstExistingElement(from: candidates, timeout: timeout)
|
||||||
|
|
@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
button.tap()
|
button.tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requiredEnvironment(_ key: String) throws -> String {
|
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
|
||||||
guard let value = ProcessInfo.processInfo.environment[key],
|
focus(focusedField)
|
||||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
focusedField.typeText("\n")
|
||||||
|
if waitForAny(
|
||||||
|
[
|
||||||
|
{ !focusedField.exists },
|
||||||
|
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
|
||||||
|
],
|
||||||
|
timeout: 1.5
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyboard = app.keyboards.firstMatch
|
||||||
|
if keyboard.waitForExistence(timeout: 2) {
|
||||||
|
let keyboardCandidates = [
|
||||||
|
"Return",
|
||||||
|
"return",
|
||||||
|
"Go",
|
||||||
|
"go",
|
||||||
|
"Continue",
|
||||||
|
"continue",
|
||||||
|
"Done",
|
||||||
|
"done",
|
||||||
|
"Join",
|
||||||
|
"join",
|
||||||
|
"Sign In",
|
||||||
|
"Log In",
|
||||||
|
"Login",
|
||||||
|
]
|
||||||
|
for title in keyboardCandidates {
|
||||||
|
let key = keyboard.buttons[title]
|
||||||
|
if key.exists && key.isHittable {
|
||||||
|
key.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
|
||||||
|
lastKey.exists,
|
||||||
|
lastKey.isHittable
|
||||||
|
{
|
||||||
|
lastKey.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tapFirstExistingButton(
|
||||||
|
in: app,
|
||||||
|
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||||
|
timeout: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadTestConfig() throws -> TestConfig {
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
|
||||||
|
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||||
|
{
|
||||||
|
return TestConfig(
|
||||||
|
email: email,
|
||||||
|
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
|
||||||
|
password: password,
|
||||||
|
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
|
||||||
|
.flatMap(TailnetLoginMode.init(rawValue:))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
|
||||||
|
let configURL = URL(fileURLWithPath: configPath)
|
||||||
|
guard FileManager.default.fileExists(atPath: configURL.path) else {
|
||||||
|
throw XCTSkip(
|
||||||
|
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: configURL)
|
||||||
|
return try JSONDecoder().decode(TestConfig.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nonEmptyEnvironment(_ key: String) -> String? {
|
||||||
|
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!value.isEmpty
|
||||||
else {
|
else {
|
||||||
throw XCTSkip("Missing required UI test environment variable \(key)")
|
return nil
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||||
|
let button = app.buttons["tailnet-start-sign-in"]
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
|
||||||
|
if button.exists, button.label == "Signed In" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
} while Date() < deadline
|
||||||
|
|
||||||
|
return button.exists && button.label == "Signed In"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
repeat {
|
||||||
|
if conditions.contains(where: { $0() }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
} while Date() < deadline
|
||||||
|
return conditions.contains(where: { $0() })
|
||||||
|
}
|
||||||
|
|
||||||
private func firstExistingElement(
|
private func firstExistingElement(
|
||||||
in app: XCUIApplication,
|
in app: XCUIApplication,
|
||||||
queries: [(XCUIApplication) -> XCUIElement],
|
queries: [(XCUIApplication) -> XCUIElement],
|
||||||
|
|
@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceText(in element: XCUIElement, with value: String) {
|
private func replaceText(in element: XCUIElement, with value: String) {
|
||||||
element.tap()
|
focus(element)
|
||||||
clearText(in: element)
|
clearText(in: element)
|
||||||
element.typeText(value)
|
element.typeText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceSecureText(in element: XCUIElement, with value: String) {
|
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
|
||||||
element.tap()
|
UIPasteboard.general.string = value
|
||||||
clearText(in: element)
|
focus(element)
|
||||||
|
for revealMenu in [
|
||||||
|
{ element.doubleTap() },
|
||||||
|
{ element.press(forDuration: 1.2) },
|
||||||
|
] {
|
||||||
|
revealMenu()
|
||||||
|
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
|
||||||
|
if pasteButton.exists {
|
||||||
|
pasteButton.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element)
|
||||||
element.typeText(value)
|
element.typeText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
||||||
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||||
element.typeText(deleteSequence)
|
element.typeText(deleteSequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func focus(_ element: XCUIElement) {
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
|
||||||
|
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
|
||||||
|
return pasteLabels.flatMap { label in
|
||||||
|
[
|
||||||
|
app.menuItems[label],
|
||||||
|
app.buttons[label],
|
||||||
|
app.webViews.buttons[label],
|
||||||
|
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||||
|
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,9 @@ public enum Constants {
|
||||||
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
|
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
Result {
|
Result {
|
||||||
let baseURL = try FileManager.default.url(
|
// The simulator app's Application Support path lives inside its sandbox container,
|
||||||
for: .applicationSupportDirectory,
|
// so the host daemon cannot reach it. Use a shared host temp location instead.
|
||||||
in: .userDomainMask,
|
let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let url = baseURL
|
|
||||||
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
|
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
|
||||||
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
|
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
|
||||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
|
||||||
public init() {}
|
public init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Burrow_TunnelPacket: Sendable {
|
||||||
|
public var payload = Data()
|
||||||
|
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||||
|
|
@ -387,6 +394,29 @@ extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
|
public static let protoMessageName: String = "burrow.TunnelPacket"
|
||||||
|
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||||
|
1: .same(proto: "payload")
|
||||||
|
]
|
||||||
|
|
||||||
|
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||||
|
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||||
|
switch fieldNumber {
|
||||||
|
case 1: try decoder.decodeSingularBytesField(value: &self.payload)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||||
|
if !self.payload.isEmpty {
|
||||||
|
try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1)
|
||||||
|
}
|
||||||
|
try unknownFields.traverse(visitor: &visitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct TailnetClient: Client, GRPCClient {
|
public struct TailnetClient: Client, GRPCClient {
|
||||||
public let channel: GRPCChannel
|
public let channel: GRPCChannel
|
||||||
public var defaultCallOptions: CallOptions
|
public var defaultCallOptions: CallOptions
|
||||||
|
|
@ -456,3 +486,23 @@ public struct TailnetClient: Client, GRPCClient {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct TunnelPacketClient: Client, GRPCClient {
|
||||||
|
public let channel: GRPCChannel
|
||||||
|
public var defaultCallOptions: CallOptions
|
||||||
|
|
||||||
|
public init(channel: any GRPCChannel) {
|
||||||
|
self.channel = channel
|
||||||
|
self.defaultCallOptions = .init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeTunnelPacketsCall(
|
||||||
|
callOptions: CallOptions? = nil
|
||||||
|
) -> GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket> {
|
||||||
|
self.makeAsyncBidirectionalStreamingCall(
|
||||||
|
path: "/burrow.Tunnel/TunnelPackets",
|
||||||
|
callOptions: callOptions ?? self.defaultCallOptions,
|
||||||
|
interceptors: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
|
||||||
|
|
||||||
public var mtu: Int32 = 0
|
public var mtu: Int32 = 0
|
||||||
|
|
||||||
|
public var routes: [String] = []
|
||||||
|
|
||||||
|
public var dnsServers: [String] = []
|
||||||
|
|
||||||
|
public var searchDomains: [String] = []
|
||||||
|
|
||||||
|
public var includeDefaultRoute: Bool = false
|
||||||
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||||
1: .same(proto: "addresses"),
|
1: .same(proto: "addresses"),
|
||||||
2: .same(proto: "mtu"),
|
2: .same(proto: "mtu"),
|
||||||
|
3: .same(proto: "routes"),
|
||||||
|
4: .standard(proto: "dns_servers"),
|
||||||
|
5: .standard(proto: "search_domains"),
|
||||||
|
6: .standard(proto: "include_default_route"),
|
||||||
]
|
]
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||||
|
|
@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
switch fieldNumber {
|
switch fieldNumber {
|
||||||
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
|
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
|
||||||
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
|
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
|
||||||
|
case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }()
|
||||||
|
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
|
||||||
|
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
|
||||||
|
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
|
||||||
if self.mtu != 0 {
|
if self.mtu != 0 {
|
||||||
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
|
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
|
||||||
}
|
}
|
||||||
|
if !self.routes.isEmpty {
|
||||||
|
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
|
||||||
|
}
|
||||||
|
if !self.dnsServers.isEmpty {
|
||||||
|
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
|
||||||
|
}
|
||||||
|
if !self.searchDomains.isEmpty {
|
||||||
|
try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5)
|
||||||
|
}
|
||||||
|
if self.includeDefaultRoute {
|
||||||
|
try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6)
|
||||||
|
}
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
try unknownFields.traverse(visitor: &visitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
|
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
|
||||||
if lhs.addresses != rhs.addresses {return false}
|
if lhs.addresses != rhs.addresses {return false}
|
||||||
if lhs.mtu != rhs.mtu {return false}
|
if lhs.mtu != rhs.mtu {return false}
|
||||||
|
if lhs.routes != rhs.routes {return false}
|
||||||
|
if lhs.dnsServers != rhs.dnsServers {return false}
|
||||||
|
if lhs.searchDomains != rhs.searchDomains {return false}
|
||||||
|
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}
|
||||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
import BurrowConfiguration
|
import BurrowConfiguration
|
||||||
import BurrowCore
|
import BurrowCore
|
||||||
|
import GRPC
|
||||||
import libburrow
|
import libburrow
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import os
|
import os
|
||||||
|
|
@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private let logger = Logger.logger(for: PacketTunnelProvider.self)
|
private let logger = Logger.logger(for: PacketTunnelProvider.self)
|
||||||
|
private var packetCall: GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket>?
|
||||||
|
private var inboundPacketTask: Task<Void, Never>?
|
||||||
|
private var outboundPacketTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var client: TunnelClient {
|
private var client: TunnelClient {
|
||||||
get throws { try _client.get() }
|
get throws { try _client.get() }
|
||||||
|
|
@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
let completion = SendableCallbackBox(completionHandler)
|
let completion = SendableCallbackBox(completionHandler)
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
_ = try await client.tunnelStart(.init())
|
||||||
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
|
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
|
||||||
guard let settings = configuration?.settings else {
|
guard let settings = configuration?.settings else {
|
||||||
throw Error.missingTunnelConfiguration
|
throw Error.missingTunnelConfiguration
|
||||||
}
|
}
|
||||||
try await setTunnelNetworkSettings(settings)
|
try await setTunnelNetworkSettings(settings)
|
||||||
_ = try await client.tunnelStart(.init())
|
try startPacketBridge()
|
||||||
logger.log("Started tunnel with network settings: \(settings)")
|
logger.log("Started tunnel with network settings: \(settings)")
|
||||||
completion.callback(nil)
|
completion.callback(nil)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to start tunnel: \(error)")
|
logger.error("Failed to start tunnel: \(error)")
|
||||||
|
stopPacketBridge()
|
||||||
completion.callback(error)
|
completion.callback(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
) {
|
) {
|
||||||
let completion = SendableCallbackBox(completionHandler)
|
let completion = SendableCallbackBox(completionHandler)
|
||||||
Task {
|
Task {
|
||||||
|
stopPacketBridge()
|
||||||
do {
|
do {
|
||||||
_ = try await client.tunnelStop(.init())
|
_ = try await client.tunnelStop(.init())
|
||||||
logger.log("Stopped client")
|
logger.log("Stopped client")
|
||||||
|
|
@ -77,20 +84,243 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PacketTunnelProvider {
|
||||||
|
private func startPacketBridge() throws {
|
||||||
|
stopPacketBridge()
|
||||||
|
|
||||||
|
let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL)
|
||||||
|
let call = packetClient.makeTunnelPacketsCall()
|
||||||
|
self.packetCall = call
|
||||||
|
|
||||||
|
inboundPacketTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
for try await packet in call.responseStream {
|
||||||
|
let payload = packet.payload
|
||||||
|
self.packetFlow.writePackets(
|
||||||
|
[payload],
|
||||||
|
withProtocols: [Self.protocolNumber(for: payload)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.logger.error("Tunnel packet receive loop failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundPacketTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { call.requestStream.finish() }
|
||||||
|
do {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let packets = await self.readPacketsBatch()
|
||||||
|
for (payload, _) in packets {
|
||||||
|
var packet = Burrow_TunnelPacket()
|
||||||
|
packet.payload = payload
|
||||||
|
try await call.requestStream.send(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.logger.error("Tunnel packet send loop failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPacketBridge() {
|
||||||
|
inboundPacketTask?.cancel()
|
||||||
|
inboundPacketTask = nil
|
||||||
|
outboundPacketTask?.cancel()
|
||||||
|
outboundPacketTask = nil
|
||||||
|
packetCall?.cancel()
|
||||||
|
packetCall = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readPacketsBatch() async -> [(Data, NSNumber)] {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
packetFlow.readPackets { packets, protocols in
|
||||||
|
continuation.resume(returning: Array(zip(packets, protocols)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func protocolNumber(for payload: Data) -> NSNumber {
|
||||||
|
guard let version = payload.first.map({ $0 >> 4 }) else {
|
||||||
|
return NSNumber(value: AF_INET)
|
||||||
|
}
|
||||||
|
switch version {
|
||||||
|
case 6:
|
||||||
|
return NSNumber(value: AF_INET6)
|
||||||
|
default:
|
||||||
|
return NSNumber(value: AF_INET)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Burrow_TunnelConfigurationResponse {
|
extension Burrow_TunnelConfigurationResponse {
|
||||||
fileprivate var settings: NEPacketTunnelNetworkSettings {
|
fileprivate var settings: NEPacketTunnelNetworkSettings {
|
||||||
let ipv6Addresses = addresses.filter { IPv6Address($0) != nil }
|
let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:))
|
||||||
|
let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address)
|
||||||
|
let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address)
|
||||||
|
let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:))
|
||||||
|
var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route)
|
||||||
|
var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route)
|
||||||
|
if includeDefaultRoute {
|
||||||
|
ipv4Routes.append(.default())
|
||||||
|
ipv6Routes.append(.default())
|
||||||
|
}
|
||||||
|
|
||||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
|
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
|
||||||
settings.mtu = NSNumber(value: mtu)
|
settings.mtu = NSNumber(value: mtu)
|
||||||
settings.ipv4Settings = NEIPv4Settings(
|
if !ipv4Addresses.isEmpty {
|
||||||
addresses: addresses.filter { IPv4Address($0) != nil },
|
let ipv4Settings = NEIPv4Settings(
|
||||||
subnetMasks: ["255.255.255.0"]
|
addresses: ipv4Addresses.map(\.address),
|
||||||
|
subnetMasks: ipv4Addresses.map(\.subnetMask)
|
||||||
)
|
)
|
||||||
settings.ipv6Settings = NEIPv6Settings(
|
if !ipv4Routes.isEmpty {
|
||||||
addresses: ipv6Addresses,
|
ipv4Settings.includedRoutes = ipv4Routes
|
||||||
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 a Tailnet provider to keep network identities ready on this device.")
|
description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.")
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, minHeight: 180)
|
.frame(maxWidth: .infinity, minHeight: 180)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -135,7 +135,7 @@ public struct BurrowView: View {
|
||||||
private func runAutomationIfNeeded() {
|
private func runAutomationIfNeeded() {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
let automation = BurrowAutomationConfig.current,
|
let automation = BurrowAutomationConfig.current,
|
||||||
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View {
|
||||||
@State private var isStartingTailnetLogin = false
|
@State private var isStartingTailnetLogin = false
|
||||||
@State private var tailnetPresentedAuthURL: URL?
|
@State private var tailnetPresentedAuthURL: URL?
|
||||||
@State private var preserveTailnetLoginSession = false
|
@State private var preserveTailnetLoginSession = false
|
||||||
|
@State private var usesCustomTailnetAuthority = false
|
||||||
|
@State private var showsAdvancedTailnetSettings = false
|
||||||
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
||||||
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
||||||
|
@State private var tailnetDiscoveryTask: Task<Void, Never>?
|
||||||
|
@State private var tailnetProbeTask: Task<Void, Never>?
|
||||||
@State private var didRunAutomation = false
|
@State private var didRunAutomation = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
|
@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View {
|
||||||
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
|
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
|
if showsIdentitySection {
|
||||||
Section("Identity") {
|
Section("Identity") {
|
||||||
TextField("Title", text: $draft.title)
|
identityFields
|
||||||
TextField("Account", text: $draft.accountName)
|
|
||||||
TextField("Identity", text: $draft.identityName)
|
|
||||||
if sheet == .tailnet {
|
|
||||||
TextField("Hostname", text: $draft.hostname)
|
|
||||||
.burrowLoginField()
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: draft.authority) { _, _ in
|
.onChange(of: draft.authority) { _, _ in
|
||||||
resetAuthorityProbe()
|
resetAuthorityProbe()
|
||||||
|
if sheet == .tailnet, usesCustomTailnetAuthority {
|
||||||
|
scheduleTailnetAuthorityProbe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
|
if sheet == .tailnet, !usesCustomTailnetAuthority {
|
||||||
|
scheduleTailnetDiscovery()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: draft.authMode) { _, newMode in
|
.onChange(of: draft.authMode) { _, newMode in
|
||||||
guard newMode != .web else { return }
|
guard newMode != .web else { return }
|
||||||
|
|
@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
tailnetLoginPollTask?.cancel()
|
tailnetLoginPollTask?.cancel()
|
||||||
|
tailnetDiscoveryTask?.cancel()
|
||||||
|
tailnetProbeTask?.cancel()
|
||||||
browserAuthenticator.cancel()
|
browserAuthenticator.cancel()
|
||||||
if !preserveTailnetLoginSession {
|
if !preserveTailnetLoginSession {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|
@ -479,6 +486,18 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var identityFields: some View {
|
||||||
|
TextField("Title", text: $draft.title)
|
||||||
|
TextField("Account", text: $draft.accountName)
|
||||||
|
TextField("Identity", text: $draft.identityName)
|
||||||
|
if sheet == .tailnet {
|
||||||
|
TextField("Hostname", text: $draft.hostname)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var tailnetSections: some View {
|
private var tailnetSections: some View {
|
||||||
Section("Connection") {
|
Section("Connection") {
|
||||||
|
|
@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View {
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-discovery-email")
|
.accessibilityIdentifier("tailnet-discovery-email")
|
||||||
|
.submitLabel(.continue)
|
||||||
Button {
|
.onSubmit {
|
||||||
discoverTailnetAuthority()
|
if !usesCustomTailnetAuthority {
|
||||||
} label: {
|
scheduleTailnetDiscovery(immediate: true)
|
||||||
Label {
|
|
||||||
Text(isDiscoveringTailnet ? "Finding Server" : "Find Server")
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
|
|
||||||
.accessibilityIdentifier("tailnet-find-server")
|
|
||||||
|
|
||||||
if let discoveryStatus {
|
tailnetServerCard
|
||||||
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
|
|
||||||
} else if let discoveryError {
|
|
||||||
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("Authority URL", text: $draft.authority)
|
if showsAdvancedTailnetSettings {
|
||||||
|
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 {
|
||||||
|
|
@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
|
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
|
||||||
.accessibilityIdentifier("tailnet-start-sign-in")
|
.accessibilityIdentifier("tailnet-start-sign-in")
|
||||||
|
|
||||||
if let tailnetLoginStatus {
|
if let tailnetLoginStatus {
|
||||||
|
|
@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if sheet == .tailnet {
|
if sheet == .tailnet {
|
||||||
if let authorityProbeStatus {
|
labeledValue("Server", tailnetServerDisplayLabel)
|
||||||
Text(authorityProbeStatus.summary)
|
if let connectionSummary = tailnetConnectionSummary {
|
||||||
|
Text(connectionSummary)
|
||||||
.font(.footnote.weight(.medium))
|
.font(.footnote.weight(.medium))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(tailnetConnectionSummaryColor)
|
||||||
if let detail = authorityProbeStatus.detail {
|
|
||||||
Text(detail)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
}
|
||||||
} else if let authorityProbeError {
|
|
||||||
Text("Connection failed")
|
|
||||||
.font(.footnote.weight(.medium))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
Text(authorityProbeError)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sheet == .tailnet {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
|
||||||
summaryBadge(draft.authMode.title)
|
|
||||||
if tailnetLoginStatus?.running == true {
|
if tailnetLoginStatus?.running == true {
|
||||||
|
HStack(spacing: 8) {
|
||||||
summaryBadge("Signed In")
|
summaryBadge("Signed In")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -654,6 +627,44 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetServerCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(usesCustomTailnetAuthority ? "Custom Server" : "Server")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
Text(tailnetServerDisplayLabel)
|
||||||
|
.font(.footnote.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isDiscoveringTailnet || isProbingAuthority {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else if let summary = tailnetConnectionSummary {
|
||||||
|
Text(summary)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(tailnetConnectionSummaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let detail = tailnetServerDetail {
|
||||||
|
Text(detail)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier("tailnet-server-card")
|
||||||
|
}
|
||||||
|
|
||||||
private func tailnetAuthorityProbeCard(
|
private func tailnetAuthorityProbeCard(
|
||||||
status: TailnetAuthorityProbeStatus?,
|
status: TailnetAuthorityProbeStatus?,
|
||||||
failure: String?
|
failure: String?
|
||||||
|
|
@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
Button("Use Tailscale Managed Server") {
|
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
|
||||||
applyTailnetDefaults(for: .tailscale)
|
toggleTailnetAuthorityMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
if availableTailnetAuthModes.count > 1 {
|
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
|
||||||
|
showsAdvancedTailnetSettings.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
|
||||||
Menu("Authentication") {
|
Menu("Authentication") {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
Button(mode.title) {
|
Button(mode.title) {
|
||||||
|
|
@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Clear Discovery Result") {
|
Button("Refresh Server Lookup") {
|
||||||
resetTailnetDiscoveryFeedback()
|
scheduleTailnetDiscovery(immediate: true)
|
||||||
}
|
}
|
||||||
|
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
private var showsBottomActionButton: Bool {
|
private var showsBottomActionButton: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
true
|
return true
|
||||||
#else
|
#else
|
||||||
false
|
return false
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var showsIdentitySection: Bool {
|
||||||
|
switch sheet {
|
||||||
|
case .wireGuard, .tor:
|
||||||
|
return true
|
||||||
|
case .tailnet:
|
||||||
|
return showsAdvancedTailnetSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var wireGuardEditorHeight: CGFloat {
|
private var wireGuardEditorHeight: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
180
|
180
|
||||||
|
|
@ -910,6 +935,18 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetLoginActionDisabled: Bool {
|
||||||
|
switch sheet {
|
||||||
|
case .tailnet:
|
||||||
|
if usesCustomTailnetAuthority {
|
||||||
|
return normalizedOptional(draft.authority) == nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case .wireGuard, .tor:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var submissionDisabled: Bool {
|
private var submissionDisabled: Bool {
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .wireGuard:
|
case .wireGuard:
|
||||||
|
|
@ -933,6 +970,50 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetServerDisplayLabel: String {
|
||||||
|
if usesCustomTailnetAuthority {
|
||||||
|
return normalizedOptional(draft.authority)
|
||||||
|
?? "Enter a custom Tailnet server"
|
||||||
|
}
|
||||||
|
return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tailnetServerDetail: String? {
|
||||||
|
if usesCustomTailnetAuthority {
|
||||||
|
if let discovery = discoveryStatus {
|
||||||
|
return "Discovered from \(discovery.domain)."
|
||||||
|
}
|
||||||
|
if let discoveryError {
|
||||||
|
return discoveryError
|
||||||
|
}
|
||||||
|
return "Use a custom Tailnet authority when your domain does not advertise one."
|
||||||
|
}
|
||||||
|
return "Continue with Tailscale, or open advanced settings to use a custom server."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tailnetConnectionSummary: String? {
|
||||||
|
if isDiscoveringTailnet {
|
||||||
|
return "Finding server"
|
||||||
|
}
|
||||||
|
if isProbingAuthority {
|
||||||
|
return "Checking"
|
||||||
|
}
|
||||||
|
if let authorityProbeStatus {
|
||||||
|
return authorityProbeStatus.summary
|
||||||
|
}
|
||||||
|
if authorityProbeError != nil {
|
||||||
|
return "Unavailable"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tailnetConnectionSummaryColor: Color {
|
||||||
|
if authorityProbeError != nil {
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
sheet == .tailnet,
|
sheet == .tailnet,
|
||||||
let automation = BurrowAutomationConfig.current,
|
let automation = BurrowAutomationConfig.current,
|
||||||
automation.action == .tailnetLogin || automation.action == .headscaleProbe
|
automation.action == .tailnetLogin || automation.action == .tailnetProbe
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View {
|
||||||
case .tailnetLogin:
|
case .tailnetLogin:
|
||||||
applyTailnetDefaults(for: .tailscale)
|
applyTailnetDefaults(for: .tailscale)
|
||||||
startTailnetLogin()
|
startTailnetLogin()
|
||||||
case .headscaleProbe:
|
case .tailnetProbe:
|
||||||
|
usesCustomTailnetAuthority = true
|
||||||
|
showsAdvancedTailnetSettings = true
|
||||||
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
}
|
}
|
||||||
|
|
@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
|
|
||||||
var noteParts: [String] = [
|
var noteParts: [String] = [
|
||||||
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
|
"Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
|
||||||
"Auth: \(draft.authMode.title)",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if showsAdvancedTailnetSettings || draft.authMode != .web {
|
||||||
|
noteParts.append("Auth: \(draft.authMode.title)")
|
||||||
|
}
|
||||||
|
|
||||||
if draft.authMode == .web, tailnetLoginStatus?.running == true {
|
if draft.authMode == .web, tailnetLoginStatus?.running == true {
|
||||||
noteParts.append("Browser sign-in complete")
|
noteParts.append("Browser sign-in complete")
|
||||||
}
|
}
|
||||||
|
|
@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
|
usesCustomTailnetAuthority = provider != .tailscale
|
||||||
draft.authority = provider.defaultAuthority ?? ""
|
draft.authority = provider.defaultAuthority ?? ""
|
||||||
if !availableTailnetAuthModes.contains(draft.authMode) {
|
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||||
draft.authMode = .web
|
draft.authMode = .web
|
||||||
|
|
@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTailnetLogin() {
|
private func startTailnetLogin() {
|
||||||
guard let authority = normalizedOptional(draft.authority) else {
|
|
||||||
tailnetLoginStatus = nil
|
|
||||||
tailnetLoginError = "Enter a server URL first."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isStartingTailnetLogin = true
|
isStartingTailnetLogin = true
|
||||||
tailnetLoginError = nil
|
tailnetLoginError = nil
|
||||||
preserveTailnetLoginSession = false
|
preserveTailnetLoginSession = false
|
||||||
|
|
@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isStartingTailnetLogin = false }
|
defer { isStartingTailnetLogin = false }
|
||||||
do {
|
do {
|
||||||
|
let authority = try await resolveTailnetAuthorityForLogin()
|
||||||
let status = try await networkViewModel.startTailnetLogin(
|
let status = try await networkViewModel.startTailnetLogin(
|
||||||
accountName: normalized(draft.accountName, fallback: "default"),
|
accountName: normalized(draft.accountName, fallback: "default"),
|
||||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||||
|
|
@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetAuthorityProbe() {
|
private func resetAuthorityProbe() {
|
||||||
|
tailnetProbeTask?.cancel()
|
||||||
authorityProbeStatus = nil
|
authorityProbeStatus = nil
|
||||||
authorityProbeError = nil
|
authorityProbeError = nil
|
||||||
tailnetLoginError = nil
|
tailnetLoginError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetTailnetDiscoveryFeedback() {
|
private func resetTailnetDiscoveryFeedback() {
|
||||||
|
tailnetDiscoveryTask?.cancel()
|
||||||
discoveryStatus = nil
|
discoveryStatus = nil
|
||||||
discoveryError = nil
|
discoveryError = nil
|
||||||
}
|
}
|
||||||
|
|
@ -1210,6 +1294,83 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scheduleTailnetDiscovery(immediate: Bool = false) {
|
||||||
|
guard sheet == .tailnet else { return }
|
||||||
|
tailnetDiscoveryTask?.cancel()
|
||||||
|
|
||||||
|
guard !usesCustomTailnetAuthority else {
|
||||||
|
discoveryStatus = nil
|
||||||
|
discoveryError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard normalizedOptional(draft.discoveryEmail) != nil else {
|
||||||
|
discoveryStatus = nil
|
||||||
|
discoveryError = nil
|
||||||
|
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tailnetDiscoveryTask = Task { @MainActor in
|
||||||
|
if !immediate {
|
||||||
|
try? await Task.sleep(for: .milliseconds(450))
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
discoverTailnetAuthority()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleTailnetAuthorityProbe() {
|
||||||
|
guard sheet == .tailnet else { return }
|
||||||
|
tailnetProbeTask?.cancel()
|
||||||
|
guard normalizedOptional(draft.authority) != nil else { return }
|
||||||
|
|
||||||
|
tailnetProbeTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
probeTailnetAuthority()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleTailnetAuthorityMode() {
|
||||||
|
let discoveredAuthority = discoveryStatus?.authority
|
||||||
|
usesCustomTailnetAuthority.toggle()
|
||||||
|
resetTailnetDiscoveryFeedback()
|
||||||
|
resetAuthorityProbe()
|
||||||
|
if usesCustomTailnetAuthority {
|
||||||
|
draft.authority = discoveredAuthority ?? draft.authority
|
||||||
|
} else {
|
||||||
|
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||||
|
scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveTailnetAuthorityForLogin() async throws -> String {
|
||||||
|
if !usesCustomTailnetAuthority {
|
||||||
|
let authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||||
|
draft.authority = authority
|
||||||
|
scheduleTailnetAuthorityProbe()
|
||||||
|
return authority
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authority = normalizedOptional(draft.authority) {
|
||||||
|
return authority
|
||||||
|
}
|
||||||
|
|
||||||
|
if let email = normalizedOptional(draft.discoveryEmail) {
|
||||||
|
let discovery = try await networkViewModel.discoverTailnet(email: email)
|
||||||
|
discoveryStatus = discovery
|
||||||
|
discoveryError = nil
|
||||||
|
draft.authority = discovery.authority
|
||||||
|
scheduleTailnetAuthorityProbe()
|
||||||
|
return discovery.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first."
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
private func beginTailnetLoginPolling(sessionID: String) {
|
private func beginTailnetLoginPolling(sessionID: String) {
|
||||||
tailnetLoginPollTask?.cancel()
|
tailnetLoginPollTask?.cancel()
|
||||||
tailnetLoginPollTask = Task { @MainActor in
|
tailnetLoginPollTask = Task { @MainActor in
|
||||||
|
|
@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View {
|
||||||
if tailnetLoginSessionID != nil {
|
if tailnetLoginSessionID != nil {
|
||||||
return "Resume Sign-In"
|
return "Resume Sign-In"
|
||||||
}
|
}
|
||||||
return "Start Sign-In"
|
return "Continue with Tailscale"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tailnetAuthenticationFootnote: String {
|
private var tailnetAuthenticationFootnote: String {
|
||||||
switch draft.authMode {
|
switch draft.authMode {
|
||||||
case .web:
|
case .web:
|
||||||
return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running."
|
if usesCustomTailnetAuthority {
|
||||||
|
return "Burrow signs in through the daemon using your custom Tailnet server."
|
||||||
|
}
|
||||||
|
return "Burrow signs in through the daemon using Tailscale's managed browser flow."
|
||||||
case .none:
|
case .none:
|
||||||
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
||||||
case .password, .preauthKey:
|
case .password, .preauthKey:
|
||||||
|
|
@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isManagedTailnetAuthority: Bool {
|
|
||||||
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
|
@ -1383,12 +1543,7 @@ private struct AccountRowView: View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(account.title)
|
Text(account.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(account.kind.title)
|
Text(account.kind.title)
|
||||||
if let provider = account.provider {
|
|
||||||
Text(provider.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(account.kind.accentColor)
|
.foregroundStyle(account.kind.accentColor)
|
||||||
}
|
}
|
||||||
|
|
@ -1470,6 +1625,12 @@ private extension View {
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class TailnetBrowserAuthenticator: NSObject {
|
private final class TailnetBrowserAuthenticator: NSObject {
|
||||||
private var session: ASWebAuthenticationSession?
|
private var session: ASWebAuthenticationSession?
|
||||||
|
private static var prefersEphemeralSessionForCurrentProcess: Bool {
|
||||||
|
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
session.presentationContextProvider = self
|
session.presentationContextProvider = self
|
||||||
session.prefersEphemeralWebBrowserSession = false
|
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
|
||||||
self.session = session
|
self.session = session
|
||||||
_ = session.start()
|
_ = session.start()
|
||||||
}
|
}
|
||||||
|
|
@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator {
|
||||||
private struct BurrowAutomationConfig {
|
private struct BurrowAutomationConfig {
|
||||||
enum Action: String {
|
enum Action: String {
|
||||||
case tailnetLogin = "tailnet-login"
|
case tailnetLogin = "tailnet-login"
|
||||||
case headscaleProbe = "headscale-probe"
|
case tailnetProbe = "tailnet-probe"
|
||||||
}
|
}
|
||||||
|
|
||||||
let action: Action
|
let action: Action
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale: "Tailscale"
|
case .tailscale: "Tailscale"
|
||||||
case .headscale: "Headscale"
|
case .headscale: "Custom Tailnet"
|
||||||
case .burrow: "Burrow"
|
case .burrow: "Burrow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||||
case .tor: "Store Arti account and identity preferences."
|
case .tor: "Store Arti account and identity preferences."
|
||||||
case .tailnet: "Save Tailnet authority, identity, and login material."
|
case .tailnet: "Save Tailnet authority, identity defaults, and login material."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,7 +402,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
case .tor:
|
case .tor:
|
||||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
|
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,18 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
||||||
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
||||||
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
||||||
|
simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}"
|
||||||
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
||||||
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
||||||
fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback"
|
fallback_dir="/tmp/${bundle_id}/SimulatorFallback"
|
||||||
socket_path="${fallback_dir}/burrow.sock"
|
socket_path="${fallback_dir}/burrow.sock"
|
||||||
|
tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState"
|
||||||
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
||||||
|
ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}"
|
||||||
|
ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner"
|
||||||
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
||||||
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
||||||
|
ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}"
|
||||||
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
||||||
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
||||||
|
|
||||||
|
|
@ -25,10 +30,60 @@ if [[ -z "$ui_test_password" ]]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path"
|
rm -rf "$fallback_dir" "$tailnet_state_root"
|
||||||
|
mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path"
|
||||||
rm -f "$socket_path"
|
rm -f "$socket_path"
|
||||||
|
|
||||||
|
resolve_simulator_id() {
|
||||||
|
xcrun simctl list devices available -j | python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
target_name = sys.argv[1]
|
||||||
|
target_os = sys.argv[2]
|
||||||
|
target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-")
|
||||||
|
devices = json.load(sys.stdin).get("devices", {})
|
||||||
|
healthy = []
|
||||||
|
for runtime, entries in devices.items():
|
||||||
|
if runtime != target_runtime:
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.get("isAvailable", False):
|
||||||
|
continue
|
||||||
|
if not os.path.isdir(entry.get("dataPath", "")):
|
||||||
|
continue
|
||||||
|
healthy.append(entry)
|
||||||
|
for entry in healthy:
|
||||||
|
if entry.get("name") == target_name:
|
||||||
|
print(entry["udid"])
|
||||||
|
raise SystemExit(0)
|
||||||
|
for entry in healthy:
|
||||||
|
if target_name in entry.get("name", ""):
|
||||||
|
print(entry["udid"])
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
' "$simulator_name" "$simulator_os"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$simulator_id" ]]; then
|
||||||
|
simulator_id="$(resolve_simulator_id || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$simulator_id" ]]; then
|
||||||
|
xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl bootstatus "$simulator_id" -b
|
||||||
|
xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true
|
||||||
|
destination="id=${simulator_id}"
|
||||||
|
else
|
||||||
|
destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}"
|
||||||
|
fi
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
rm -f "$ui_test_config_path"
|
||||||
if [[ -n "${daemon_pid:-}" ]]; then
|
if [[ -n "${daemon_pid:-}" ]]; then
|
||||||
kill "$daemon_pid" >/dev/null 2>&1 || true
|
kill "$daemon_pid" >/dev/null 2>&1 || true
|
||||||
wait "$daemon_pid" >/dev/null 2>&1 || true
|
wait "$daemon_pid" >/dev/null 2>&1 || true
|
||||||
|
|
@ -36,11 +91,33 @@ cleanup() {
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode"
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
config_path = pathlib.Path(sys.argv[1])
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"email": sys.argv[2],
|
||||||
|
"username": sys.argv[3],
|
||||||
|
"password": sys.argv[4],
|
||||||
|
"mode": sys.argv[5],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
cargo build -p burrow --bin burrow
|
cargo build -p burrow --bin burrow
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$fallback_dir"
|
cd "$fallback_dir"
|
||||||
|
RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \
|
||||||
BURROW_SOCKET_PATH="burrow.sock" \
|
BURROW_SOCKET_PATH="burrow.sock" \
|
||||||
|
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
|
||||||
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
||||||
) &
|
) &
|
||||||
daemon_pid=$!
|
daemon_pid=$!
|
||||||
|
|
@ -56,18 +133,31 @@ if [[ ! -S "$socket_path" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
common_xcodebuild_args=(
|
||||||
|
-quiet
|
||||||
|
-skipPackagePluginValidation
|
||||||
|
-project "${repo_root}/Apple/Burrow.xcodeproj"
|
||||||
|
-scheme App
|
||||||
|
-configuration Debug
|
||||||
|
-destination "$destination"
|
||||||
|
-derivedDataPath "$derived_data_path"
|
||||||
|
-clonedSourcePackagesDirPath "$source_packages_path"
|
||||||
|
-only-testing:BurrowUITests
|
||||||
|
-parallel-testing-enabled NO
|
||||||
|
-maximum-concurrent-test-simulator-destinations 1
|
||||||
|
-maximum-parallel-testing-workers 1
|
||||||
|
CODE_SIGNING_ALLOWED=NO
|
||||||
|
)
|
||||||
|
|
||||||
|
xcodebuild \
|
||||||
|
"${common_xcodebuild_args[@]}" \
|
||||||
|
build-for-testing
|
||||||
|
|
||||||
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
||||||
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
||||||
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
||||||
|
BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \
|
||||||
|
BURROW_UI_TEST_EPHEMERAL_AUTH=1 \
|
||||||
xcodebuild \
|
xcodebuild \
|
||||||
-quiet \
|
"${common_xcodebuild_args[@]}" \
|
||||||
-skipPackagePluginValidation \
|
test-without-building
|
||||||
-project "${repo_root}/Apple/Burrow.xcodeproj" \
|
|
||||||
-scheme App \
|
|
||||||
-configuration Debug \
|
|
||||||
-destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \
|
|
||||||
-derivedDataPath "$derived_data_path" \
|
|
||||||
-clonedSourcePackagesDirPath "$source_packages_path" \
|
|
||||||
-only-testing:BurrowUITests \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
test
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest {
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub control_url: Option<String>,
|
pub control_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub packet_socket: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -55,23 +57,35 @@ pub struct TailscaleLoginStartResponse {
|
||||||
pub status: TailscaleLoginStatus,
|
pub status: TailscaleLoginStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct TailscaleLoginSession {
|
||||||
|
pub session_id: String,
|
||||||
|
pub helper: Arc<TailscaleHelperProcess>,
|
||||||
|
pub status: TailscaleLoginStatus,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct TailscaleBridgeManager {
|
pub struct TailscaleBridgeManager {
|
||||||
client: Client,
|
client: Client,
|
||||||
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
|
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ManagedSession {
|
pub struct TailscaleHelperProcess {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
listen_url: String,
|
listen_url: String,
|
||||||
|
packet_socket: Option<PathBuf>,
|
||||||
|
control_url: Option<String>,
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
child: Arc<Mutex<Child>>,
|
child: Arc<Mutex<Child>>,
|
||||||
_stderr_task: JoinHandle<()>,
|
_stderr_task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedSession = TailscaleHelperProcess;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct HelperHello {
|
struct HelperHello {
|
||||||
listen_addr: String,
|
listen_addr: String,
|
||||||
|
#[serde(default)]
|
||||||
|
packet_socket: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TailscaleBridgeManager {
|
impl TailscaleBridgeManager {
|
||||||
|
|
@ -79,13 +93,45 @@ impl TailscaleBridgeManager {
|
||||||
&self,
|
&self,
|
||||||
request: TailscaleLoginStartRequest,
|
request: TailscaleLoginStartRequest,
|
||||||
) -> Result<TailscaleLoginStartResponse> {
|
) -> Result<TailscaleLoginStartResponse> {
|
||||||
let key = session_key(&request.account_name, &request.identity_name);
|
let session = self.ensure_session(request).await?;
|
||||||
|
Ok(TailscaleLoginStartResponse {
|
||||||
|
session_id: session.session_id,
|
||||||
|
status: session.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_session(
|
||||||
|
&self,
|
||||||
|
request: TailscaleLoginStartRequest,
|
||||||
|
) -> Result<TailscaleLoginSession> {
|
||||||
|
let key = session_key_for_request(&request);
|
||||||
|
let requested_packet_socket = request
|
||||||
|
.packet_socket
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let requested_control_url = request
|
||||||
|
.control_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
||||||
|
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(TailscaleLoginStartResponse {
|
return Ok(TailscaleLoginSession {
|
||||||
session_id: existing.session_id.clone(),
|
session_id: existing.session_id.clone(),
|
||||||
|
helper: existing,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -94,61 +140,24 @@ 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 = TailscaleLoginStartResponse {
|
let response = TailscaleLoginSession {
|
||||||
session_id: session.session_id.clone(),
|
session_id: session.session_id.clone(),
|
||||||
|
helper: session.clone(),
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -192,7 +201,7 @@ impl TailscaleBridgeManager {
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
let mut last_status = None;
|
let mut last_status = None;
|
||||||
for _ in 0..40 {
|
for _ in 0..40 {
|
||||||
match self.fetch_status(session).await {
|
match session.status_with_client(&self.client).await {
|
||||||
Ok(status) if status.running || status.auth_url.is_some() => return Ok(status),
|
Ok(status) if status.running || status.auth_url.is_some() => return Ok(status),
|
||||||
Ok(status) => last_status = Some(status),
|
Ok(status) => last_status = Some(status),
|
||||||
Err(err) => last_error = Some(err),
|
Err(err) => last_error = Some(err),
|
||||||
|
|
@ -206,28 +215,7 @@ impl TailscaleBridgeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
||||||
let mut child = session.child.lock().await;
|
session.status_with_client(&self.client).await
|
||||||
if let Some(status) = child.try_wait()? {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"tailscale helper exited with status {status} for {}",
|
|
||||||
session.state_dir.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
drop(child);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(format!("{}/status", session.listen_url))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to query tailscale helper status")?
|
|
||||||
.error_for_status()
|
|
||||||
.context("tailscale helper status request failed")?;
|
|
||||||
|
|
||||||
response
|
|
||||||
.json::<TailscaleLoginStatus>()
|
|
||||||
.await
|
|
||||||
.context("invalid tailscale helper status response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
|
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
|
||||||
|
|
@ -239,14 +227,74 @@ impl TailscaleBridgeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
|
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
|
||||||
let _ = self
|
session.shutdown_with_client(&self.client).await
|
||||||
.client
|
}
|
||||||
.post(format!("{}/shutdown", session.listen_url))
|
}
|
||||||
|
|
||||||
|
impl TailscaleHelperProcess {
|
||||||
|
pub fn session_id(&self) -> &str {
|
||||||
|
&self.session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn packet_socket(&self) -> Option<&Path> {
|
||||||
|
self.packet_socket.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control_url(&self) -> Option<&str> {
|
||||||
|
self.control_url.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_dir(&self) -> &Path {
|
||||||
|
&self.state_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> Result<TailscaleLoginStatus> {
|
||||||
|
self.status_with_client(&Client::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
|
self.shutdown_with_client(&Client::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_with_client(&self, client: &Client) -> Result<TailscaleLoginStatus> {
|
||||||
|
let mut child = self.child.lock().await;
|
||||||
|
if let Some(status) = child.try_wait()? {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"tailscale helper exited with status {status} for {}",
|
||||||
|
self.state_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
drop(child);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/status", self.listen_url))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.context("failed to query tailscale helper status")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("tailscale helper status request failed")?;
|
||||||
|
|
||||||
|
let status = response
|
||||||
|
.json::<TailscaleLoginStatus>()
|
||||||
|
.await
|
||||||
|
.context("invalid tailscale helper status response")?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}",
|
||||||
|
self.session_id,
|
||||||
|
status.backend_state,
|
||||||
|
status.running,
|
||||||
|
status.needs_login,
|
||||||
|
status.auth_url
|
||||||
|
);
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_with_client(&self, client: &Client) -> Result<()> {
|
||||||
|
let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await;
|
||||||
|
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
let mut child = session.child.lock().await;
|
let mut child = self.child.lock().await;
|
||||||
if child.try_wait()?.is_some() {
|
if child.try_wait()?.is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +302,7 @@ impl TailscaleBridgeManager {
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = session.child.lock().await;
|
let mut child = self.child.lock().await;
|
||||||
child
|
child
|
||||||
.start_kill()
|
.start_kill()
|
||||||
.context("failed to kill tailscale helper")?;
|
.context("failed to kill tailscale helper")?;
|
||||||
|
|
@ -263,6 +311,58 @@ impl TailscaleBridgeManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_tailscale_helper(
|
||||||
|
request: &TailscaleLoginStartRequest,
|
||||||
|
) -> Result<TailscaleHelperProcess> {
|
||||||
|
let state_dir = state_root().join(session_dir_name(request));
|
||||||
|
tokio::fs::create_dir_all(&state_dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to create {}", state_dir.display()))?;
|
||||||
|
|
||||||
|
let mut child = helper_command(request, &state_dir)?
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn tailscale login helper")?;
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.context("tailscale helper stdout unavailable")?;
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.context("tailscale helper stderr unavailable")?;
|
||||||
|
|
||||||
|
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
lines.next_line().await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("timed out waiting for tailscale helper startup")??
|
||||||
|
.context("tailscale helper exited before reporting listen address")?;
|
||||||
|
|
||||||
|
let hello: HelperHello =
|
||||||
|
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
|
||||||
|
|
||||||
|
let stderr_task = tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stderr).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
log::info!("tailscale-login-bridge: {line}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(TailscaleHelperProcess {
|
||||||
|
session_id: random_session_id(),
|
||||||
|
listen_url: format!("http://{}", hello.listen_addr),
|
||||||
|
packet_socket: hello.packet_socket.map(PathBuf::from),
|
||||||
|
control_url: request.control_url.clone(),
|
||||||
|
state_dir,
|
||||||
|
child: Arc::new(Mutex::new(child)),
|
||||||
|
_stderr_task: stderr_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
|
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
|
||||||
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
|
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
|
||||||
Command::new(path)
|
Command::new(path)
|
||||||
|
|
@ -291,10 +391,21 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(packet_socket) = request.packet_socket.as_deref() {
|
||||||
|
let trimmed = packet_socket.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
command.arg("--packet-socket").arg(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(command)
|
Ok(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_root() -> PathBuf {
|
pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf {
|
||||||
|
state_root().join(session_dir_name(request)).join("packet.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn state_root() -> PathBuf {
|
||||||
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
|
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
|
||||||
return PathBuf::from(path);
|
return PathBuf::from(path);
|
||||||
}
|
}
|
||||||
|
|
@ -315,19 +426,34 @@ fn state_root() -> PathBuf {
|
||||||
.join("tailscale")
|
.join("tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}-{}",
|
"{}-{}-{}",
|
||||||
slug(&request.account_name),
|
slug(&request.account_name),
|
||||||
slug(&request.identity_name)
|
slug(&request.identity_name),
|
||||||
|
slug(control_scope(request))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_key(account_name: &str, identity_name: &str) -> String {
|
fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String {
|
||||||
format!("{account_name}:{identity_name}")
|
format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
request.account_name,
|
||||||
|
request.identity_name,
|
||||||
|
control_scope(request)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
|
fn control_scope(request: &TailscaleLoginStartRequest) -> &str {
|
||||||
|
request
|
||||||
|
.control_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("tailscale-managed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
|
||||||
request
|
request
|
||||||
.hostname
|
.hostname
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -370,14 +496,24 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_dir_is_stable_by_account_and_identity() {
|
fn state_dir_is_scoped_by_account_identity_and_control_plane() {
|
||||||
let request = TailscaleLoginStartRequest {
|
let request = TailscaleLoginStartRequest {
|
||||||
account_name: "default".to_owned(),
|
account_name: "default".to_owned(),
|
||||||
identity_name: "apple".to_owned(),
|
identity_name: "apple".to_owned(),
|
||||||
hostname: None,
|
hostname: None,
|
||||||
control_url: None,
|
control_url: None,
|
||||||
|
packet_socket: None,
|
||||||
};
|
};
|
||||||
assert_eq!(session_dir_name(&request), "default-apple");
|
assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed");
|
||||||
assert_eq!(default_hostname(&request), "burrow-apple");
|
assert_eq!(default_hostname(&request), "burrow-apple");
|
||||||
|
|
||||||
|
let custom_request = TailscaleLoginStartRequest {
|
||||||
|
control_url: Some("https://ts.burrow.net".to_owned()),
|
||||||
|
..request
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
session_dir_name(&custom_request),
|
||||||
|
"default-apple-httpstsburrownet"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use reqwest::{Client, StatusCode, Url};
|
use reqwest::{Client, StatusCode, Url};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use super::TailnetProvider;
|
use super::TailnetProvider;
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ struct WebFingerLink {
|
||||||
|
|
||||||
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
||||||
let domain = email_domain(email)?;
|
let domain = email_domain(email)?;
|
||||||
|
info!(%email, %domain, "tailnet discovery requested");
|
||||||
let base_url = Url::parse(&format!("https://{domain}"))
|
let base_url = Url::parse(&format!("https://{domain}"))
|
||||||
.with_context(|| format!("invalid discovery domain {domain}"))?;
|
.with_context(|| format!("invalid discovery domain {domain}"))?;
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
|
|
@ -116,12 +118,21 @@ pub async fn discover_tailnet_at(
|
||||||
base_url: &Url,
|
base_url: &Url,
|
||||||
) -> Result<TailnetDiscovery> {
|
) -> Result<TailnetDiscovery> {
|
||||||
let domain = email_domain(email)?;
|
let domain = email_domain(email)?;
|
||||||
|
debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery");
|
||||||
|
|
||||||
if let Some(discovery) = discover_well_known(client, base_url).await? {
|
if let Some(discovery) = discover_well_known(client, base_url).await? {
|
||||||
|
info!(
|
||||||
|
%email,
|
||||||
|
%domain,
|
||||||
|
authority = %discovery.authority,
|
||||||
|
provider = ?discovery.provider,
|
||||||
|
"resolved tailnet discovery from well-known document"
|
||||||
|
);
|
||||||
return Ok(TailnetDiscovery { domain, ..discovery });
|
return Ok(TailnetDiscovery { domain, ..discovery });
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
||||||
|
info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger");
|
||||||
return Ok(TailnetDiscovery {
|
return Ok(TailnetDiscovery {
|
||||||
domain,
|
domain,
|
||||||
provider: inferred_provider(Some(&authority), None),
|
provider: inferred_provider(Some(&authority), None),
|
||||||
|
|
@ -162,6 +173,7 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
|
||||||
let url = base_url
|
let url = base_url
|
||||||
.join(TAILNET_DISCOVERY_PATH)
|
.join(TAILNET_DISCOVERY_PATH)
|
||||||
.context("failed to build tailnet discovery URL")?;
|
.context("failed to build tailnet discovery URL")?;
|
||||||
|
debug!(%url, "requesting tailnet well-known document");
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
|
|
@ -187,6 +199,7 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("resource", &format!("acct:{email}"))
|
.append_pair("resource", &format!("acct:{email}"))
|
||||||
.append_pair("rel", TAILNET_DISCOVERY_REL);
|
.append_pair("rel", TAILNET_DISCOVERY_REL);
|
||||||
|
debug!(%email, url = %url, "requesting tailnet webfinger document");
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use rusqlite::Connection;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tonic::{Request, Response, Status as RspStatus};
|
use tonic::{Request, Response, Status as RspStatus};
|
||||||
use tracing::warn;
|
use tracing::{debug, info, warn};
|
||||||
use tun::tokio::TunInterface;
|
use tun::tokio::TunInterface;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|
@ -16,15 +16,15 @@ use super::{
|
||||||
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
||||||
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
||||||
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
||||||
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
|
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
|
||||||
TunnelStatusResponse,
|
TunnelStatusResponse,
|
||||||
},
|
},
|
||||||
runtime::{ActiveTunnel, ResolvedTunnel},
|
runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::server::tailscale::{
|
auth::server::tailscale::{
|
||||||
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
|
packet_socket_path, TailscaleBridgeManager,
|
||||||
TailscaleLoginStatus,
|
TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus,
|
||||||
},
|
},
|
||||||
control::discovery,
|
control::discovery,
|
||||||
daemon::rpc::ServerConfig,
|
daemon::rpc::ServerConfig,
|
||||||
|
|
@ -87,11 +87,20 @@ impl DaemonRPCServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
|
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
|
||||||
let config = self
|
let config = {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,8 +120,18 @@ impl DaemonRPCServer {
|
||||||
|
|
||||||
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
|
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
|
||||||
let _ = self.stop_active_tunnel().await?;
|
let _ = self.stop_active_tunnel().await?;
|
||||||
|
let tailnet_helper = match &desired {
|
||||||
|
ResolvedTunnel::Tailnet { identity, config } => Some(
|
||||||
|
self.tailnet_login
|
||||||
|
.ensure_session(tailnet_helper_request(identity, config))
|
||||||
|
.await
|
||||||
|
.map_err(proc_err)?
|
||||||
|
.helper,
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let active = desired
|
let active = desired
|
||||||
.start(self.tun_interface.clone())
|
.start(self.tun_interface.clone(), tailnet_helper)
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
self.active_tunnel.write().await.replace(active);
|
self.active_tunnel.write().await.replace(active);
|
||||||
|
|
@ -137,6 +156,23 @@ impl DaemonRPCServer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tailnet_bridge_request(
|
||||||
|
account_name: String,
|
||||||
|
identity_name: String,
|
||||||
|
hostname: String,
|
||||||
|
authority: String,
|
||||||
|
) -> BridgeLoginStartRequest {
|
||||||
|
let mut request = BridgeLoginStartRequest {
|
||||||
|
account_name,
|
||||||
|
identity_name,
|
||||||
|
hostname: (!hostname.trim().is_empty()).then_some(hostname),
|
||||||
|
control_url: Self::tailnet_control_url(&authority),
|
||||||
|
packet_socket: None,
|
||||||
|
};
|
||||||
|
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
fn tailnet_control_url(authority: &str) -> Option<String> {
|
fn tailnet_control_url(authority: &str) -> Option<String> {
|
||||||
let authority = discovery::normalize_authority(authority);
|
let authority = discovery::normalize_authority(authority);
|
||||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
||||||
|
|
@ -146,6 +182,7 @@ impl DaemonRPCServer {
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
impl Tunnel for DaemonRPCServer {
|
impl Tunnel for DaemonRPCServer {
|
||||||
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
|
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
|
||||||
|
type TunnelPacketsStream = ReceiverStream<Result<TunnelPacket, RspStatus>>;
|
||||||
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
|
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
|
||||||
|
|
||||||
async fn tunnel_configuration(
|
async fn tunnel_configuration(
|
||||||
|
|
@ -171,6 +208,62 @@ impl Tunnel for DaemonRPCServer {
|
||||||
Ok(Response::new(ReceiverStream::new(rx)))
|
Ok(Response::new(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn tunnel_packets(
|
||||||
|
&self,
|
||||||
|
request: Request<tonic::Streaming<TunnelPacket>>,
|
||||||
|
) -> Result<Response<Self::TunnelPacketsStream>, RspStatus> {
|
||||||
|
let (packet_tx, mut packet_rx) = {
|
||||||
|
let guard = self.active_tunnel.read().await;
|
||||||
|
let Some(active) = guard.as_ref() else {
|
||||||
|
return Err(RspStatus::failed_precondition("no active tunnel"));
|
||||||
|
};
|
||||||
|
active.packet_stream().ok_or_else(|| {
|
||||||
|
RspStatus::failed_precondition(
|
||||||
|
"active tunnel does not support packet streaming",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel(128);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match packet_rx.recv().await {
|
||||||
|
Ok(payload) => {
|
||||||
|
if tx.send(Ok(TunnelPacket { payload })).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut inbound = request.into_inner();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match inbound.message().await {
|
||||||
|
Ok(Some(packet)) => {
|
||||||
|
debug!(
|
||||||
|
"daemon tunnel packet stream received {} bytes from client",
|
||||||
|
packet.payload.len()
|
||||||
|
);
|
||||||
|
if packet_tx.send(packet.payload).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("tailnet packet stream receive error: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Response::new(ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
|
async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
|
||||||
let desired = self.resolve_tunnel().await?;
|
let desired = self.resolve_tunnel().await?;
|
||||||
let already_running = {
|
let already_running = {
|
||||||
|
|
@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<TailnetDiscoverRequest>,
|
request: Request<TailnetDiscoverRequest>,
|
||||||
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
|
info!(email = %request.email, "daemon tailnet discover RPC received");
|
||||||
let discovery = discovery::discover_tailnet(&request.email)
|
let discovery = discovery::discover_tailnet(&request.email)
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
|
info!(
|
||||||
|
email = %request.email,
|
||||||
|
authority = %discovery.authority,
|
||||||
|
provider = ?discovery.provider,
|
||||||
|
"daemon tailnet discover RPC resolved"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Response::new(TailnetDiscoverResponse {
|
Ok(Response::new(TailnetDiscoverResponse {
|
||||||
domain: discovery.domain,
|
domain: discovery.domain,
|
||||||
|
|
@ -325,17 +425,32 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
|
info!(
|
||||||
|
account = %request.account_name,
|
||||||
|
identity = %request.identity_name,
|
||||||
|
authority = %request.authority,
|
||||||
|
"daemon tailnet login start RPC received"
|
||||||
|
);
|
||||||
let response = self
|
let response = self
|
||||||
.tailnet_login
|
.tailnet_login
|
||||||
.start_login(BridgeLoginStartRequest {
|
.start_login(Self::tailnet_bridge_request(
|
||||||
account_name: request.account_name,
|
request.account_name,
|
||||||
identity_name: request.identity_name,
|
request.identity_name,
|
||||||
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
|
request.hostname,
|
||||||
control_url: Self::tailnet_control_url(&request.authority),
|
request.authority,
|
||||||
})
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(proc_err)?;
|
.map_err(proc_err)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
session_id = %response.session_id,
|
||||||
|
backend_state = %response.status.backend_state,
|
||||||
|
running = response.status.running,
|
||||||
|
needs_login = response.status.needs_login,
|
||||||
|
auth_url = ?response.status.auth_url,
|
||||||
|
"daemon tailnet login start RPC resolved"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Response::new(tailnet_login_rsp(
|
Ok(Response::new(tailnet_login_rsp(
|
||||||
response.session_id,
|
response.session_id,
|
||||||
response.status,
|
response.status,
|
||||||
|
|
@ -347,6 +462,7 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
|
info!(session_id = %request.session_id, "daemon tailnet login status RPC received");
|
||||||
let status = self
|
let status = self
|
||||||
.tailnet_login
|
.tailnet_login
|
||||||
.status(&request.session_id)
|
.status(&request.session_id)
|
||||||
|
|
@ -355,6 +471,14 @@ impl TailnetControl for DaemonRPCServer {
|
||||||
let Some(status) = status else {
|
let Some(status) = status else {
|
||||||
return Err(RspStatus::not_found("tailnet login session not found"));
|
return Err(RspStatus::not_found("tailnet login session not found"));
|
||||||
};
|
};
|
||||||
|
info!(
|
||||||
|
session_id = %request.session_id,
|
||||||
|
backend_state = %status.backend_state,
|
||||||
|
running = status.running,
|
||||||
|
needs_login = status.needs_login,
|
||||||
|
auth_url = ?status.auth_url,
|
||||||
|
"daemon tailnet login status RPC resolved"
|
||||||
|
);
|
||||||
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,8 +505,12 @@ fn proc_err(err: impl ToString) -> RspStatus {
|
||||||
|
|
||||||
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
|
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
|
||||||
TunnelConfigurationResponse {
|
TunnelConfigurationResponse {
|
||||||
mtu: config.mtu.unwrap_or(1000),
|
|
||||||
addresses: config.address,
|
addresses: config.address,
|
||||||
|
mtu: config.mtu.unwrap_or(1000),
|
||||||
|
routes: config.routes,
|
||||||
|
dns_servers: config.dns_servers,
|
||||||
|
search_domains: config.search_domains,
|
||||||
|
include_default_route: config.include_default_route,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo {
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub address: Vec<String>,
|
pub address: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub routes: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dns_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_domains: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_default_route: bool,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub mtu: Option<i32>,
|
pub mtu: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig {
|
||||||
fn try_from(config: &Config) -> anyhow::Result<Self> {
|
fn try_from(config: &Config) -> anyhow::Result<Self> {
|
||||||
Ok(ServerConfig {
|
Ok(ServerConfig {
|
||||||
address: config.interface.address.clone(),
|
address: config.interface.address.clone(),
|
||||||
|
routes: config
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.flat_map(|peer| peer.allowed_ips.iter().cloned())
|
||||||
|
.collect(),
|
||||||
|
dns_servers: config.interface.dns.clone(),
|
||||||
|
search_domains: Vec::new(),
|
||||||
|
include_default_route: false,
|
||||||
name: None,
|
name: None,
|
||||||
mtu: config.interface.mtu.map(|mtu| mtu as i32),
|
mtu: config.interface.mtu.map(|mtu| mtu as i32),
|
||||||
})
|
})
|
||||||
|
|
@ -88,6 +104,10 @@ impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
address: vec!["10.13.13.2".to_string()], // Dummy remote address
|
address: vec!["10.13.13.2".to_string()], // Dummy remote address
|
||||||
|
routes: Vec::new(),
|
||||||
|
dns_servers: Vec::new(),
|
||||||
|
search_domains: Vec::new(),
|
||||||
|
include_default_route: false,
|
||||||
name: None,
|
name: None,
|
||||||
mtu: None,
|
mtu: None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
use std::sync::Arc;
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use tokio::{sync::RwLock, task::JoinHandle};
|
use tokio::{
|
||||||
|
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||||
|
net::UnixStream,
|
||||||
|
sync::{broadcast, mpsc, RwLock},
|
||||||
|
task::JoinHandle,
|
||||||
|
time::{sleep, Duration},
|
||||||
|
};
|
||||||
use tun::{tokio::TunInterface, TunOptions};
|
use tun::{tokio::TunInterface, TunOptions};
|
||||||
|
|
||||||
use super::rpc::{
|
use super::rpc::{
|
||||||
|
|
@ -9,7 +15,11 @@ use super::rpc::{
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
control::TailnetConfig,
|
auth::server::tailscale::{
|
||||||
|
default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess,
|
||||||
|
TailscaleLoginStartRequest, TailscaleLoginStatus,
|
||||||
|
},
|
||||||
|
control::{discovery, TailnetConfig},
|
||||||
wireguard::{Config, Interface as WireGuardInterface},
|
wireguard::{Config, Interface as WireGuardInterface},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -78,11 +88,19 @@ impl ResolvedTunnel {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { .. } => Ok(ServerConfig {
|
Self::Passthrough { .. } => Ok(ServerConfig {
|
||||||
address: Vec::new(),
|
address: Vec::new(),
|
||||||
|
routes: Vec::new(),
|
||||||
|
dns_servers: Vec::new(),
|
||||||
|
search_domains: Vec::new(),
|
||||||
|
include_default_route: false,
|
||||||
name: None,
|
name: None,
|
||||||
mtu: Some(1500),
|
mtu: Some(1500),
|
||||||
}),
|
}),
|
||||||
Self::Tailnet { .. } => Ok(ServerConfig {
|
Self::Tailnet { .. } => Ok(ServerConfig {
|
||||||
address: Vec::new(),
|
address: Vec::new(),
|
||||||
|
routes: tailnet_routes(),
|
||||||
|
dns_servers: tailnet_dns_servers(),
|
||||||
|
search_domains: Vec::new(),
|
||||||
|
include_default_route: false,
|
||||||
name: None,
|
name: None,
|
||||||
mtu: Some(1280),
|
mtu: Some(1280),
|
||||||
}),
|
}),
|
||||||
|
|
@ -93,21 +111,71 @@ impl ResolvedTunnel {
|
||||||
pub async fn start(
|
pub async fn start(
|
||||||
self,
|
self,
|
||||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||||
|
tailnet_helper: Option<Arc<TailscaleHelperProcess>>,
|
||||||
) -> Result<ActiveTunnel> {
|
) -> Result<ActiveTunnel> {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
|
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough {
|
||||||
Self::Tailnet { config, .. } => Err(anyhow::anyhow!(
|
identity,
|
||||||
"tailnet runtime is not wired in this checkout yet ({:?})",
|
server_config: ServerConfig {
|
||||||
config.provider
|
address: Vec::new(),
|
||||||
)),
|
routes: Vec::new(),
|
||||||
|
dns_servers: Vec::new(),
|
||||||
|
search_domains: Vec::new(),
|
||||||
|
include_default_route: false,
|
||||||
|
name: None,
|
||||||
|
mtu: Some(1500),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Self::Tailnet { identity, config } => {
|
||||||
|
let (helper, shutdown_helper_on_stop) = match tailnet_helper {
|
||||||
|
Some(helper) => (helper, false),
|
||||||
|
None => {
|
||||||
|
let helper_request = tailnet_helper_request(&identity, &config);
|
||||||
|
let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?);
|
||||||
|
(helper, true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = wait_for_tailnet_ready(helper.as_ref()).await?;
|
||||||
|
let server_config = tailnet_server_config(&status);
|
||||||
|
let packet_socket = helper
|
||||||
|
.packet_socket()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?;
|
||||||
|
let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?;
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
let tun_task = None;
|
||||||
|
#[cfg(not(target_vendor = "apple"))]
|
||||||
|
let tun_task = {
|
||||||
|
let tun = TunOptions::new().open()?;
|
||||||
|
tun_interface.write().await.replace(tun);
|
||||||
|
Some(tokio::spawn(run_tailnet_tun_bridge(
|
||||||
|
tun_interface.clone(),
|
||||||
|
packet_bridge.outbound_sender(),
|
||||||
|
packet_bridge.subscribe(),
|
||||||
|
)))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ActiveTunnel::Tailnet {
|
||||||
|
identity,
|
||||||
|
server_config,
|
||||||
|
helper,
|
||||||
|
shutdown_helper_on_stop,
|
||||||
|
packet_bridge,
|
||||||
|
tun_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
Self::WireGuard { identity, config } => {
|
Self::WireGuard { identity, config } => {
|
||||||
|
let server_config = ServerConfig::try_from(&config)?;
|
||||||
let tun = TunOptions::new().open()?;
|
let tun = TunOptions::new().open()?;
|
||||||
tun_interface.write().await.replace(tun);
|
tun_interface.write().await.replace(tun);
|
||||||
|
|
||||||
match start_wireguard_runtime(config, tun_interface.clone()).await {
|
match start_wireguard_runtime(config, tun_interface.clone()).await {
|
||||||
Ok((interface, task)) => {
|
Ok((interface, task)) => Ok(ActiveTunnel::WireGuard {
|
||||||
Ok(ActiveTunnel::WireGuard { identity, interface, task })
|
identity,
|
||||||
}
|
server_config,
|
||||||
|
interface,
|
||||||
|
task,
|
||||||
|
}),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tun_interface.write().await.take();
|
tun_interface.write().await.take();
|
||||||
Err(err)
|
Err(err)
|
||||||
|
|
@ -121,9 +189,19 @@ impl ResolvedTunnel {
|
||||||
pub enum ActiveTunnel {
|
pub enum ActiveTunnel {
|
||||||
Passthrough {
|
Passthrough {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
|
server_config: ServerConfig,
|
||||||
|
},
|
||||||
|
Tailnet {
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
server_config: ServerConfig,
|
||||||
|
helper: Arc<TailscaleHelperProcess>,
|
||||||
|
shutdown_helper_on_stop: bool,
|
||||||
|
packet_bridge: TailnetPacketBridge,
|
||||||
|
tun_task: Option<JoinHandle<Result<()>>>,
|
||||||
},
|
},
|
||||||
WireGuard {
|
WireGuard {
|
||||||
identity: RuntimeIdentity,
|
identity: RuntimeIdentity,
|
||||||
|
server_config: ServerConfig,
|
||||||
interface: Arc<RwLock<WireGuardInterface>>,
|
interface: Arc<RwLock<WireGuardInterface>>,
|
||||||
task: JoinHandle<Result<()>>,
|
task: JoinHandle<Result<()>>,
|
||||||
},
|
},
|
||||||
|
|
@ -132,15 +210,69 @@ pub enum ActiveTunnel {
|
||||||
impl ActiveTunnel {
|
impl ActiveTunnel {
|
||||||
pub fn identity(&self) -> &RuntimeIdentity {
|
pub fn identity(&self) -> &RuntimeIdentity {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { identity }
|
Self::Passthrough { identity, .. }
|
||||||
|
| Self::Tailnet { identity, .. }
|
||||||
| Self::WireGuard { identity, .. } => identity,
|
| Self::WireGuard { identity, .. } => identity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn server_config(&self) -> &ServerConfig {
|
||||||
|
match self {
|
||||||
|
Self::Passthrough { server_config, .. }
|
||||||
|
| Self::Tailnet { server_config, .. }
|
||||||
|
| Self::WireGuard { server_config, .. } => server_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn packet_stream(
|
||||||
|
&self,
|
||||||
|
) -> Option<(mpsc::Sender<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
|
||||||
|
match self {
|
||||||
|
Self::Tailnet { packet_bridge, .. } => Some((
|
||||||
|
packet_bridge.outbound_sender(),
|
||||||
|
packet_bridge.subscribe(),
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
|
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Self::Passthrough { .. } => Ok(()),
|
Self::Passthrough { .. } => Ok(()),
|
||||||
Self::WireGuard { interface, task, .. } => {
|
Self::Tailnet {
|
||||||
|
helper,
|
||||||
|
shutdown_helper_on_stop,
|
||||||
|
packet_bridge,
|
||||||
|
tun_task,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(tun_task) = tun_task {
|
||||||
|
tun_task.abort();
|
||||||
|
match tun_task.await {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => return Err(err),
|
||||||
|
Err(err) if err.is_cancelled() => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packet_bridge.task.abort();
|
||||||
|
match packet_bridge.task.await {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => return Err(err),
|
||||||
|
Err(err) if err.is_cancelled() => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
tun_interface.write().await.take();
|
||||||
|
if shutdown_helper_on_stop {
|
||||||
|
helper.shutdown().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Self::WireGuard {
|
||||||
|
interface,
|
||||||
|
task,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
interface.read().await.remove_tun().await;
|
interface.read().await.remove_tun().await;
|
||||||
let task_result = task.await;
|
let task_result = task.await;
|
||||||
tun_interface.write().await.take();
|
tun_interface.write().await.take();
|
||||||
|
|
@ -151,6 +283,22 @@ impl ActiveTunnel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct TailnetPacketBridge {
|
||||||
|
outbound: mpsc::Sender<Vec<u8>>,
|
||||||
|
inbound: broadcast::Sender<Vec<u8>>,
|
||||||
|
task: JoinHandle<Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TailnetPacketBridge {
|
||||||
|
fn outbound_sender(&self) -> mpsc::Sender<Vec<u8>> {
|
||||||
|
self.outbound.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||||
|
self.inbound.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_wireguard_runtime(
|
async fn start_wireguard_runtime(
|
||||||
config: Config,
|
config: Config,
|
||||||
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||||
|
|
@ -166,6 +314,279 @@ async fn start_wireguard_runtime(
|
||||||
Ok((interface, task))
|
Ok((interface, task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tailnet_helper_request(
|
||||||
|
identity: &RuntimeIdentity,
|
||||||
|
config: &TailnetConfig,
|
||||||
|
) -> TailscaleLoginStartRequest {
|
||||||
|
let account_name = config
|
||||||
|
.account
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("default")
|
||||||
|
.to_owned();
|
||||||
|
let identity_name = config
|
||||||
|
.identity
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| match identity {
|
||||||
|
RuntimeIdentity::Network { id, .. } => format!("network-{id}"),
|
||||||
|
RuntimeIdentity::Passthrough => "apple".to_owned(),
|
||||||
|
});
|
||||||
|
let control_url = config.authority.as_deref().and_then(|authority| {
|
||||||
|
let authority = discovery::normalize_authority(authority);
|
||||||
|
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut request = TailscaleLoginStartRequest {
|
||||||
|
account_name,
|
||||||
|
identity_name,
|
||||||
|
hostname: config.hostname.clone(),
|
||||||
|
control_url,
|
||||||
|
packet_socket: None,
|
||||||
|
};
|
||||||
|
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
|
||||||
|
if request
|
||||||
|
.hostname
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| value.trim().is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
request.hostname = Some(default_hostname(&request));
|
||||||
|
}
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result<TailscaleLoginStatus> {
|
||||||
|
let mut last_status = None;
|
||||||
|
for _ in 0..120 {
|
||||||
|
let status = helper.status().await?;
|
||||||
|
if status.running && !status.tailscale_ips.is_empty() {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
if status.needs_login || status.auth_url.is_some() {
|
||||||
|
bail!("tailnet runtime requires a completed login before the tunnel can start");
|
||||||
|
}
|
||||||
|
last_status = Some(status);
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = last_status {
|
||||||
|
bail!(
|
||||||
|
"tailnet helper never became ready (backend_state={})",
|
||||||
|
status.backend_state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
bail!("tailnet helper never produced a status update")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig {
|
||||||
|
let mut search_domains = Vec::new();
|
||||||
|
if let Some(suffix) = status.magic_dns_suffix.as_deref() {
|
||||||
|
let suffix = suffix.trim().trim_end_matches('.');
|
||||||
|
if !suffix.is_empty() {
|
||||||
|
search_domains.push(suffix.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerConfig {
|
||||||
|
address: status
|
||||||
|
.tailscale_ips
|
||||||
|
.iter()
|
||||||
|
.map(|ip| tailnet_cidr(ip))
|
||||||
|
.collect(),
|
||||||
|
routes: tailnet_routes(),
|
||||||
|
dns_servers: tailnet_dns_servers(),
|
||||||
|
search_domains,
|
||||||
|
include_default_route: false,
|
||||||
|
name: status.self_dns_name.clone(),
|
||||||
|
mtu: Some(1280),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tailnet_routes() -> Vec<String> {
|
||||||
|
vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tailnet_dns_servers() -> Vec<String> {
|
||||||
|
vec!["100.100.100.100".to_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tailnet_cidr(ip: &str) -> String {
|
||||||
|
if ip.contains('/') {
|
||||||
|
return ip.to_owned();
|
||||||
|
}
|
||||||
|
if ip.contains(':') {
|
||||||
|
format!("{ip}/128")
|
||||||
|
} else {
|
||||||
|
format!("{ip}/32")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result<TailnetPacketBridge> {
|
||||||
|
let mut last_error = None;
|
||||||
|
let mut stream = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
match UnixStream::connect(&packet_socket).await {
|
||||||
|
Ok(connected) => {
|
||||||
|
stream = Some(connected);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_error = Some(err);
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stream = if let Some(stream) = stream {
|
||||||
|
stream
|
||||||
|
} else {
|
||||||
|
return Err(last_error
|
||||||
|
.context("failed to connect to tailnet helper packet socket")?
|
||||||
|
.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (outbound_tx, outbound_rx) = mpsc::channel(128);
|
||||||
|
let (inbound_tx, _) = broadcast::channel(128);
|
||||||
|
let task = tokio::spawn(run_tailnet_socket_bridge(
|
||||||
|
stream,
|
||||||
|
outbound_rx,
|
||||||
|
inbound_tx.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(TailnetPacketBridge {
|
||||||
|
outbound: outbound_tx,
|
||||||
|
inbound: inbound_tx,
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_tailnet_socket_bridge(
|
||||||
|
stream: UnixStream,
|
||||||
|
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
inbound_tx: broadcast::Sender<Vec<u8>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (mut reader, mut writer) = stream.into_split();
|
||||||
|
|
||||||
|
let inbound = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let packet = read_packet_frame(&mut reader).await?;
|
||||||
|
tracing::debug!(
|
||||||
|
"tailnet packet bridge received {} bytes from helper socket",
|
||||||
|
packet.len()
|
||||||
|
);
|
||||||
|
let _ = inbound_tx.send(packet);
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Result::<()>::Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let outbound = tokio::spawn(async move {
|
||||||
|
while let Some(packet) = outbound_rx.recv().await {
|
||||||
|
tracing::debug!(
|
||||||
|
"tailnet packet bridge writing {} bytes to helper socket",
|
||||||
|
packet.len()
|
||||||
|
);
|
||||||
|
write_packet_frame(&mut writer, &packet).await?;
|
||||||
|
}
|
||||||
|
Result::<()>::Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
|
||||||
|
inbound_result?;
|
||||||
|
outbound_result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_vendor = "apple"))]
|
||||||
|
async fn run_tailnet_tun_bridge(
|
||||||
|
tun_interface: Arc<RwLock<Option<TunInterface>>>,
|
||||||
|
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
mut inbound_rx: broadcast::Receiver<Vec<u8>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let inbound_tun = tun_interface.clone();
|
||||||
|
let inbound = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let packet = match inbound_rx.recv().await {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
};
|
||||||
|
let guard = inbound_tun.read().await;
|
||||||
|
let Some(tun) = guard.as_ref() else {
|
||||||
|
bail!("tailnet tun interface unavailable");
|
||||||
|
};
|
||||||
|
tun.send(&packet)
|
||||||
|
.await
|
||||||
|
.context("failed to write tailnet packet to tun")?;
|
||||||
|
}
|
||||||
|
Result::<()>::Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let outbound_tun = tun_interface.clone();
|
||||||
|
let outbound = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 65_535];
|
||||||
|
loop {
|
||||||
|
let len = {
|
||||||
|
let guard = outbound_tun.read().await;
|
||||||
|
let Some(tun) = guard.as_ref() else {
|
||||||
|
bail!("tailnet tun interface unavailable");
|
||||||
|
};
|
||||||
|
tun.recv(&mut buf)
|
||||||
|
.await
|
||||||
|
.context("failed to read packet from tailnet tun")?
|
||||||
|
};
|
||||||
|
outbound_tx
|
||||||
|
.send(buf[..len].to_vec())
|
||||||
|
.await
|
||||||
|
.context("failed to forward packet to tailnet helper")?;
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Result::<()>::Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
|
||||||
|
inbound_result?;
|
||||||
|
outbound_result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_packet_frame<R>(reader: &mut R) -> Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut len_buf)
|
||||||
|
.await
|
||||||
|
.context("failed to read tailnet packet frame length")?;
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut packet = vec![0u8; len];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut packet)
|
||||||
|
.await
|
||||||
|
.context("failed to read tailnet packet frame payload")?;
|
||||||
|
Ok(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_packet_frame<W>(writer: &mut W, packet: &[u8]) -> Result<()>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
writer
|
||||||
|
.write_all(&(packet.len() as u32).to_be_bytes())
|
||||||
|
.await
|
||||||
|
.context("failed to write tailnet packet frame length")?;
|
||||||
|
writer
|
||||||
|
.write_all(packet)
|
||||||
|
.await
|
||||||
|
.context("failed to write tailnet packet frame payload")?;
|
||||||
|
writer
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.context("failed to flush tailnet packet frame")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -179,4 +600,19 @@ mod tests {
|
||||||
Vec::<String>::new()
|
Vec::<String>::new()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tailnet_server_config_uses_host_prefixes() {
|
||||||
|
let status = TailscaleLoginStatus {
|
||||||
|
running: true,
|
||||||
|
tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config = tailnet_server_config(&status);
|
||||||
|
assert_eq!(
|
||||||
|
config.address,
|
||||||
|
vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"]
|
||||||
|
);
|
||||||
|
assert_eq!(config.mtu, Some(1280));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,16 @@ pub fn initialize() {
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let subscriber = {
|
let subscriber = {
|
||||||
let system_log = Some(tracing_oslog::OsLogger::new(
|
// `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on
|
||||||
"com.hackclub.burrow",
|
// current macOS. Keep logging on stderr by default and allow opt-in OSLog
|
||||||
"tracing",
|
// only when explicitly requested for local debugging.
|
||||||
));
|
let enable_oslog = matches!(
|
||||||
|
std::env::var("BURROW_ENABLE_OSLOG").as_deref(),
|
||||||
|
Ok("1" | "true" | "TRUE" | "yes" | "YES")
|
||||||
|
);
|
||||||
|
let system_log = enable_oslog.then(|| {
|
||||||
|
tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")
|
||||||
|
});
|
||||||
let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
|
let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
|
||||||
Registry::default().with(stderr).with(system_log)
|
Registry::default().with(stderr).with(system_log)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
service Tunnel {
|
service Tunnel {
|
||||||
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
|
rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse);
|
||||||
|
rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket);
|
||||||
rpc TunnelStart (Empty) returns (Empty);
|
rpc TunnelStart (Empty) returns (Empty);
|
||||||
rpc TunnelStop (Empty) returns (Empty);
|
rpc TunnelStop (Empty) returns (Empty);
|
||||||
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
|
rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse);
|
||||||
|
|
@ -128,4 +129,12 @@ message TunnelStatusResponse {
|
||||||
message TunnelConfigurationResponse {
|
message TunnelConfigurationResponse {
|
||||||
repeated string addresses = 1;
|
repeated string addresses = 1;
|
||||||
int32 mtu = 2;
|
int32 mtu = 2;
|
||||||
|
repeated string routes = 3;
|
||||||
|
repeated string dns_servers = 4;
|
||||||
|
repeated string search_domains = 5;
|
||||||
|
bool include_default_route = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TunnelPacket {
|
||||||
|
bytes payload = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue