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