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,90 @@ 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"],
|
||||
app.buttons["Continue"],
|
||||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.exists && button.isHittable {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||
} while Date() < deadline
|
||||
|
||||
let promptCandidates = [
|
||||
springboard.buttons["Continue"],
|
||||
springboard.buttons["Allow"],
|
||||
|
|
@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
||||
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
|
||||
timeout: 12
|
||||
)
|
||||
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
|
||||
replaceText(in: usernameField, with: username)
|
||||
|
||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||
if immediatePasswordField.exists {
|
||||
replaceSecureText(in: immediatePasswordField, with: password)
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
if !usernameField.exists {
|
||||
return
|
||||
}
|
||||
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"]
|
||||
)
|
||||
settings.ipv6Settings = NEIPv6Settings(
|
||||
addresses: ipv6Addresses,
|
||||
networkPrefixLengths: ipv6Addresses.map { _ in 64 }
|
||||
)
|
||||
if !ipv4Addresses.isEmpty {
|
||||
let ipv4Settings = NEIPv4Settings(
|
||||
addresses: ipv4Addresses.map(\.address),
|
||||
subnetMasks: ipv4Addresses.map(\.subnetMask)
|
||||
)
|
||||
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)
|
||||
|
||||
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()
|
||||
if showsIdentitySection {
|
||||
Section("Identity") {
|
||||
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)
|
||||
.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")
|
||||
if showsAdvancedTailnetSettings {
|
||||
if usesCustomTailnetAuthority {
|
||||
TextField("Server URL", text: $draft.authority)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("tailnet-authority")
|
||||
} else {
|
||||
TextField("Tailnet", text: $draft.tailnet)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("tailnet-name")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
|
||||
.accessibilityIdentifier("tailnet-check-connection")
|
||||
|
||||
if let authorityProbeStatus {
|
||||
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
|
||||
} else if let authorityProbeError {
|
||||
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
|
||||
}
|
||||
|
||||
TextField("Tailnet", text: $draft.tailnet)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("tailnet-name")
|
||||
}
|
||||
|
||||
Section("Authentication") {
|
||||
Picker("Authentication", selection: $draft.authMode) {
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
if showsAdvancedTailnetSettings {
|
||||
Picker("Authentication", selection: $draft.authMode) {
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
} else if let authorityProbeError {
|
||||
Text("Connection failed")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.red)
|
||||
Text(authorityProbeError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.foregroundStyle(tailnetConnectionSummaryColor)
|
||||
}
|
||||
}
|
||||
|
||||
if sheet == .tailnet {
|
||||
HStack(spacing: 8) {
|
||||
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
||||
summaryBadge(draft.authMode.title)
|
||||
if tailnetLoginStatus?.running == true {
|
||||
if tailnetLoginStatus?.running == true {
|
||||
HStack(spacing: 8) {
|
||||
summaryBadge("Signed In")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Text(account.kind.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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue