From 64103abbea58979a360c5be7976be313a8c0d1e4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 02:10:49 -0700 Subject: [PATCH] Refocus Tailnet flow on Tailscale --- Apple/App/AppDelegate.swift | 2 +- Apple/AppUITests/BurrowUITests.swift | 317 +++++++++--- Apple/Configuration/Constants/Constants.swift | 10 +- Apple/Core/Client.swift | 50 ++ Apple/Core/Client/Generated/burrow.pb.swift | 32 ++ .../PacketTunnelProvider.swift | 250 +++++++++- Apple/UI/BurrowView.swift | 393 ++++++++++----- Apple/UI/Networks/Network.swift | 6 +- Scripts/run-ios-tailnet-ui-tests.sh | 116 ++++- burrow/src/auth/server/tailscale.rs | 338 +++++++++---- burrow/src/control/discovery.rs | 13 + burrow/src/daemon/instance.rs | 164 ++++++- burrow/src/daemon/rpc/response.rs | 20 + burrow/src/daemon/runtime.rs | 464 +++++++++++++++++- burrow/src/tracing.rs | 14 +- proto/burrow.proto | 9 + 16 files changed, 1856 insertions(+), 342 deletions(-) diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 12fe52c..c3cb4cb 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -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 }() diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift index f9dbeae..b7d8111 100644 --- a/Apple/AppUITests/BurrowUITests.swift +++ b/Apple/AppUITests/BurrowUITests.swift @@ -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, + ] + } + } } diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 8844564..95d8c78 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -36,13 +36,9 @@ public enum Constants { private static func fallbackContainerURL() -> Result { #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) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index e44ebcd..7d4cfc7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -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(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(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 { + self.makeAsyncBidirectionalStreamingCall( + path: "/burrow.Tunnel/TunnelPackets", + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift index bba0f16..fccd769 100644 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -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(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 } diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 4f29543..3f3d8b4 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -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? + private var inboundPacketTask: Task? + private var outboundPacketTask: Task? 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: ".") + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 2128ec3..e15d3f7 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -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? + @State private var tailnetDiscoveryTask: Task? + @State private var tailnetProbeTask: Task? @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 diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 32f0b8c..35bd0e1 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -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." } } } diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh index 5086bd1..5170a1e 100755 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -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 diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index 55516e1..d08c807 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest { pub hostname: Option, #[serde(default)] pub control_url: Option, + #[serde(default)] + pub packet_socket: Option, } #[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, + pub status: TailscaleLoginStatus, +} + #[derive(Clone, Default)] pub struct TailscaleBridgeManager { client: Client, sessions: Arc>>>, } -struct ManagedSession { +pub struct TailscaleHelperProcess { session_id: String, listen_url: String, + packet_socket: Option, + control_url: Option, state_dir: PathBuf, child: Arc>, _stderr_task: JoinHandle<()>, } +type ManagedSession = TailscaleHelperProcess; + #[derive(Debug, Deserialize)] struct HelperHello { listen_addr: String, + #[serde(default)] + packet_socket: Option, } impl TailscaleBridgeManager { @@ -79,76 +93,71 @@ impl TailscaleBridgeManager { &self, request: TailscaleLoginStartRequest, ) -> Result { - 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 { + 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() { - match self.fetch_status(existing.as_ref()).await { - Ok(status) => { - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); - } - Err(err) => { - log::warn!( - "tailscale login session {} is stale, restarting: {err}", - existing.session_id - ); - self.sessions.lock().await.remove(&key); - let _ = self.shutdown_session(existing.as_ref()).await; + let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket()) + { + (Some(requested), Some(current)) => current != Path::new(requested), + (Some(_), None) => true, + _ => false, + }; + let needs_restart_for_control_url = + requested_control_url != existing.control_url().map(|value| value.trim()); + + if !needs_restart_for_socket && !needs_restart_for_control_url { + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginSession { + session_id: existing.session_id.clone(), + helper: existing, + status, + }); + } + Err(err) => { + log::warn!( + "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 { - 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::() - .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> { @@ -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 { + 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 { + 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::() + .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 { + 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 { 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" + ); } } diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 5fc7add..d044a62 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -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 { 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 { 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 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) diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 0a23ddc..9b2e138 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -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 { - let config = self - .resolve_tunnel() - .await? - .server_config() - .map_err(proc_err)?; + 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)?, + }; 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 { 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>; + type TunnelPacketsStream = ReceiverStream>; type TunnelStatusStream = ReceiverStream>; 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>, + ) -> Result, 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) -> Result, RspStatus> { let desired = self.resolve_tunnel().await?; let already_running = { @@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, 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, ) -> Result, 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, ) -> Result, 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, } } diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 8948ca4..6d03581 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { pub address: Vec, + #[serde(default)] + pub routes: Vec, + #[serde(default)] + pub dns_servers: Vec, + #[serde(default)] + pub search_domains: Vec, + #[serde(default)] + pub include_default_route: bool, pub name: Option, pub mtu: Option, } @@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig { fn try_from(config: &Config) -> anyhow::Result { 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, } diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 84dfd2b..31821a2 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -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>>, + tailnet_helper: Option>, ) -> Result { 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, + shutdown_helper_on_stop: bool, + packet_bridge: TailnetPacketBridge, + tun_task: Option>>, }, WireGuard { identity: RuntimeIdentity, + server_config: ServerConfig, interface: Arc>, task: JoinHandle>, }, @@ -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>, broadcast::Receiver>)> { + match self { + Self::Tailnet { packet_bridge, .. } => Some(( + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + )), + _ => None, + } + } + pub async fn shutdown(self, tun_interface: &Arc>>) -> 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>, + inbound: broadcast::Sender>, + task: JoinHandle>, +} + +impl TailnetPacketBridge { + fn outbound_sender(&self) -> mpsc::Sender> { + self.outbound.clone() + } + + fn subscribe(&self) -> broadcast::Receiver> { + self.inbound.subscribe() + } +} + async fn start_wireguard_runtime( config: Config, tun_interface: Arc>>, @@ -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 { + 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 { + vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()] +} + +fn tailnet_dns_servers() -> Vec { + 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 { + 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>, + inbound_tx: broadcast::Sender>, +) -> 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>>, + outbound_tx: mpsc::Sender>, + mut inbound_rx: broadcast::Receiver>, +) -> 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(reader: &mut R) -> Result> +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(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::::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)); + } } diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 21e16ae..8a245ef 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -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) }; diff --git a/proto/burrow.proto b/proto/burrow.proto index a590cb1..ed1f89e 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -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; }