Refocus Tailnet flow on Tailscale

This commit is contained in:
Conrad Kramer 2026-04-05 02:10:49 -07:00
parent 3ebb0a8e61
commit 64103abbea
16 changed files with 1856 additions and 342 deletions

View file

@ -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
}()

View file

@ -1,15 +1,31 @@
import XCTest
import UIKit
@MainActor
final class BurrowTailnetLoginUITests: XCTestCase {
private enum TailnetLoginMode: String, Decodable {
case tailscale
case discovered
}
private struct TestConfig: Decodable {
let email: String
let username: String
let password: String
let mode: TailnetLoginMode?
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTailnetLoginThroughAuthentikWebSession() throws {
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
let config = try loadTestConfig()
let email = config.email
let username = config.username
let password = config.password
let mode = config.mode ?? .tailscale
let browserIdentity = mode == .tailscale ? email : username
let app = XCUIApplication()
app.launch()
@ -18,51 +34,75 @@ final class BurrowTailnetLoginUITests: XCTestCase {
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
tailnetButton.tap()
configureTailnetIfNeeded(in: app, mode: mode)
let discoveryField = app.textFields["tailnet-discovery-email"]
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
replaceText(in: discoveryField, with: email)
let findServerButton = app.buttons["tailnet-find-server"]
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
findServerButton.tap()
let discoveryCard = app.otherElements["tailnet-discovery-card"]
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
let authorityField = app.textFields["tailnet-authority"]
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
XCTAssertTrue(
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
"Tailnet authority was not populated from discovery"
)
let probeButton = app.buttons["tailnet-check-connection"]
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
probeButton.tap()
let probeCard = app.otherElements["tailnet-authority-probe-card"]
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
let serverCard = app.descendants(matching: .any)
.matching(identifier: "tailnet-server-card")
.firstMatch
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
let signInButton = app.buttons["tailnet-start-sign-in"]
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
signInButton.tap()
acceptAuthenticationPromptIfNeeded(in: app)
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
let webSession = webAuthenticationSession()
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
signIntoAuthentik(in: webSession, username: username, password: password)
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
app.activate()
XCTAssertTrue(
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
waitForTailnetSignedIn(in: app, timeout: 60),
"Tailnet sign-in never reached the running state"
)
}
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) {
guard mode == .discovered else { return }
openTailnetMenu(in: app)
tapMenuButton(named: "Edit Custom Server", in: app)
openTailnetMenu(in: app)
tapMenuButton(named: "Show Advanced Settings", in: app)
let authorityField = app.textFields["tailnet-authority"]
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
replaceText(in: authorityField, with: "")
}
private func openTailnetMenu(in app: XCUIApplication) {
let moreButton = app.buttons["More"]
XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear")
moreButton.tap()
}
private func tapMenuButton(named title: String, in app: XCUIApplication) {
let menuButton = firstExistingElement(
from: [
app.buttons[title],
app.descendants(matching: .button)[title],
],
timeout: 5
)
XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear")
menuButton.tap()
}
private func acceptAuthenticationPromptIfNeeded(
in app: XCUIApplication,
timeout: TimeInterval
) {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let deadline = Date().addingTimeInterval(timeout)
repeat {
let promptCandidates = [
springboard.buttons["Continue"],
springboard.buttons["Allow"],
@ -70,7 +110,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
app.buttons["Allow"],
]
for button in promptCandidates where button.waitForExistence(timeout: 3) {
for button in promptCandidates where button.exists && button.isHittable {
button.tap()
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} while Date() < deadline
let promptCandidates = [
springboard.buttons["Continue"],
springboard.buttons["Allow"],
app.buttons["Continue"],
app.buttons["Allow"],
]
for button in promptCandidates where button.exists {
button.tap()
return
}
@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase {
}
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
followTailnetRedirectIfNeeded(in: webSession)
if !webSession.exists {
return
}
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
if immediatePasswordField.exists {
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
return
}
let usernameField = firstExistingElement(
in: webSession,
queries: [
@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase {
{ $0.webViews.textFields["Email or Username"] },
{ $0.descendants(matching: .textField).firstMatch },
],
timeout: 25
)
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
replaceText(in: usernameField, with: username)
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
if immediatePasswordField.exists {
replaceSecureText(in: immediatePasswordField, with: password)
tapFirstExistingButton(
in: webSession,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
timeout: 12
)
if !usernameField.exists {
return
}
replaceText(in: usernameField, with: username)
tapFirstExistingButton(
in: webSession,
@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
replaceSecureText(in: passwordField, with: password)
tapFirstExistingButton(
in: webSession,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
)
replaceSecureText(in: passwordField, within: webSession, with: password)
submitAuthenticationForm(in: webSession, focusedField: passwordField)
}
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
let redirectCandidates = [
webSession.links["Found"],
webSession.webViews.links["Found"],
webSession.buttons["Found"],
webSession.webViews.buttons["Found"],
]
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
if redirectLink.exists {
redirectLink.tap()
}
}
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
let candidates = [
app.descendants(matching: .secureTextField).firstMatch,
app.secureTextFields["Password"],
app.secureTextFields["Password or Token"],
app.webViews.secureTextFields["Password"],
app.webViews.secureTextFields["Password or Token"],
app.descendants(matching: .secureTextField).firstMatch,
]
return firstExistingElement(from: candidates, timeout: timeout)
@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase {
button.tap()
}
private func requiredEnvironment(_ key: String) throws -> String {
guard let value = ProcessInfo.processInfo.environment[key],
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
focus(focusedField)
focusedField.typeText("\n")
if waitForAny(
[
{ !focusedField.exists },
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
],
timeout: 1.5
) {
return
}
let keyboard = app.keyboards.firstMatch
if keyboard.waitForExistence(timeout: 2) {
let keyboardCandidates = [
"Return",
"return",
"Go",
"go",
"Continue",
"continue",
"Done",
"done",
"Join",
"join",
"Sign In",
"Log In",
"Login",
]
for title in keyboardCandidates {
let key = keyboard.buttons[title]
if key.exists && key.isHittable {
key.tap()
return
}
}
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
lastKey.exists,
lastKey.isHittable
{
lastKey.tap()
return
}
}
tapFirstExistingButton(
in: app,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
)
}
private func loadTestConfig() throws -> TestConfig {
let environment = ProcessInfo.processInfo.environment
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
{
return TestConfig(
email: email,
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
password: password,
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
.flatMap(TailnetLoginMode.init(rawValue:))
)
}
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
let configURL = URL(fileURLWithPath: configPath)
guard FileManager.default.fileExists(atPath: configURL.path) else {
throw XCTSkip(
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
)
}
let data = try Data(contentsOf: configURL)
return try JSONDecoder().decode(TestConfig.self, from: data)
}
private func nonEmptyEnvironment(_ key: String) -> String? {
guard let value = ProcessInfo.processInfo.environment[key]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
throw XCTSkip("Missing required UI test environment variable \(key)")
return nil
}
return value
}
@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase {
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
let button = app.buttons["tailnet-start-sign-in"]
let deadline = Date().addingTimeInterval(timeout)
repeat {
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
if button.exists, button.label == "Signed In" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
} while Date() < deadline
return button.exists && button.label == "Signed In"
}
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
repeat {
if conditions.contains(where: { $0() }) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
} while Date() < deadline
return conditions.contains(where: { $0() })
}
private func firstExistingElement(
in app: XCUIApplication,
queries: [(XCUIApplication) -> XCUIElement],
@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase {
}
private func replaceText(in element: XCUIElement, with value: String) {
element.tap()
focus(element)
clearText(in: element)
element.typeText(value)
}
private func replaceSecureText(in element: XCUIElement, with value: String) {
element.tap()
clearText(in: element)
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
UIPasteboard.general.string = value
focus(element)
for revealMenu in [
{ element.doubleTap() },
{ element.press(forDuration: 1.2) },
] {
revealMenu()
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
if pasteButton.exists {
pasteButton.tap()
return
}
}
focus(element)
element.typeText(value)
}
@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
element.typeText(deleteSequence)
}
private func focus(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
return pasteLabels.flatMap { label in
[
app.menuItems[label],
app.buttons[label],
app.webViews.buttons[label],
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
]
}
}
}

View file

@ -36,13 +36,9 @@ public enum Constants {
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
#if targetEnvironment(simulator)
Result {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let url = baseURL
// The simulator app's Application Support path lives inside its sandbox container,
// so the host daemon cannot reach it. Use a shared host temp location instead.
let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)

View file

@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
public init() {}
}
public struct Burrow_TunnelPacket: Sendable {
public var payload = Data()
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
@ -387,6 +394,29 @@ extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobu
}
}
extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TunnelPacket"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "payload")
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularBytesField(value: &self.payload)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.payload.isEmpty {
try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
public struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel
public var defaultCallOptions: CallOptions
@ -456,3 +486,23 @@ public struct TailnetClient: Client, GRPCClient {
)
}
}
public struct TunnelPacketClient: Client, GRPCClient {
public let channel: GRPCChannel
public var defaultCallOptions: CallOptions
public init(channel: any GRPCChannel) {
self.channel = channel
self.defaultCallOptions = .init()
}
public func makeTunnelPacketsCall(
callOptions: CallOptions? = nil
) -> GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket> {
self.makeAsyncBidirectionalStreamingCall(
path: "/burrow.Tunnel/TunnelPackets",
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
}

View file

@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
public var mtu: Int32 = 0
public var routes: [String] = []
public var dnsServers: [String] = []
public var searchDomains: [String] = []
public var includeDefaultRoute: Bool = false
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "addresses"),
2: .same(proto: "mtu"),
3: .same(proto: "routes"),
4: .standard(proto: "dns_servers"),
5: .standard(proto: "search_domains"),
6: .standard(proto: "include_default_route"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }()
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
default: break
}
}
@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
if self.mtu != 0 {
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
}
if !self.routes.isEmpty {
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
}
if !self.dnsServers.isEmpty {
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
}
if !self.searchDomains.isEmpty {
try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5)
}
if self.includeDefaultRoute {
try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6)
}
try unknownFields.traverse(visitor: &visitor)
}
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
if lhs.addresses != rhs.addresses {return false}
if lhs.mtu != rhs.mtu {return false}
if lhs.routes != rhs.routes {return false}
if lhs.dnsServers != rhs.dnsServers {return false}
if lhs.searchDomains != rhs.searchDomains {return false}
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}

View file

@ -1,6 +1,7 @@
import AsyncAlgorithms
import BurrowConfiguration
import BurrowCore
import GRPC
import libburrow
import NetworkExtension
import os
@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
private let logger = Logger.logger(for: PacketTunnelProvider.self)
private var packetCall: GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket>?
private var inboundPacketTask: Task<Void, Never>?
private var outboundPacketTask: Task<Void, Never>?
private var client: TunnelClient {
get throws { try _client.get() }
@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
let completion = SendableCallbackBox(completionHandler)
Task {
do {
_ = try await client.tunnelStart(.init())
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
guard let settings = configuration?.settings else {
throw Error.missingTunnelConfiguration
}
try await setTunnelNetworkSettings(settings)
_ = try await client.tunnelStart(.init())
try startPacketBridge()
logger.log("Started tunnel with network settings: \(settings)")
completion.callback(nil)
} catch {
logger.error("Failed to start tunnel: \(error)")
stopPacketBridge()
completion.callback(error)
}
}
@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
) {
let completion = SendableCallbackBox(completionHandler)
Task {
stopPacketBridge()
do {
_ = try await client.tunnelStop(.init())
logger.log("Stopped client")
@ -77,20 +84,243 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
}
extension PacketTunnelProvider {
private func startPacketBridge() throws {
stopPacketBridge()
let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL)
let call = packetClient.makeTunnelPacketsCall()
self.packetCall = call
inboundPacketTask = Task { [weak self] in
guard let self else { return }
do {
for try await packet in call.responseStream {
let payload = packet.payload
self.packetFlow.writePackets(
[payload],
withProtocols: [Self.protocolNumber(for: payload)]
)
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Tunnel packet receive loop failed: \(error)")
}
}
outboundPacketTask = Task { [weak self] in
guard let self else { return }
defer { call.requestStream.finish() }
do {
while !Task.isCancelled {
let packets = await self.readPacketsBatch()
for (payload, _) in packets {
var packet = Burrow_TunnelPacket()
packet.payload = payload
try await call.requestStream.send(packet)
}
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Tunnel packet send loop failed: \(error)")
}
}
}
private func stopPacketBridge() {
inboundPacketTask?.cancel()
inboundPacketTask = nil
outboundPacketTask?.cancel()
outboundPacketTask = nil
packetCall?.cancel()
packetCall = nil
}
private func readPacketsBatch() async -> [(Data, NSNumber)] {
await withCheckedContinuation { continuation in
packetFlow.readPackets { packets, protocols in
continuation.resume(returning: Array(zip(packets, protocols)))
}
}
}
private static func protocolNumber(for payload: Data) -> NSNumber {
guard let version = payload.first.map({ $0 >> 4 }) else {
return NSNumber(value: AF_INET)
}
switch version {
case 6:
return NSNumber(value: AF_INET6)
default:
return NSNumber(value: AF_INET)
}
}
}
extension Burrow_TunnelConfigurationResponse {
fileprivate var settings: NEPacketTunnelNetworkSettings {
let ipv6Addresses = addresses.filter { IPv6Address($0) != nil }
let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:))
let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address)
let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address)
let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:))
var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route)
var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route)
if includeDefaultRoute {
ipv4Routes.append(.default())
ipv6Routes.append(.default())
}
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
settings.mtu = NSNumber(value: mtu)
settings.ipv4Settings = NEIPv4Settings(
addresses: addresses.filter { IPv4Address($0) != nil },
subnetMasks: ["255.255.255.0"]
if !ipv4Addresses.isEmpty {
let ipv4Settings = NEIPv4Settings(
addresses: ipv4Addresses.map(\.address),
subnetMasks: ipv4Addresses.map(\.subnetMask)
)
settings.ipv6Settings = NEIPv6Settings(
addresses: ipv6Addresses,
networkPrefixLengths: ipv6Addresses.map { _ in 64 }
if !ipv4Routes.isEmpty {
ipv4Settings.includedRoutes = ipv4Routes
}
settings.ipv4Settings = ipv4Settings
}
if !ipv6Addresses.isEmpty {
let ipv6Settings = NEIPv6Settings(
addresses: ipv6Addresses.map(\.address),
networkPrefixLengths: ipv6Addresses.map(\.prefixLength)
)
if !ipv6Routes.isEmpty {
ipv6Settings.includedRoutes = ipv6Routes
}
settings.ipv6Settings = ipv6Settings
}
if !dnsServers.isEmpty {
let dnsSettings = NEDNSSettings(servers: dnsServers)
if !searchDomains.isEmpty {
dnsSettings.matchDomains = searchDomains
}
settings.dnsSettings = dnsSettings
}
return settings
}
}
private struct ParsedTunnelAddress {
struct IPv4AddressSetting {
let address: String
let subnetMask: String
}
struct IPv6AddressSetting {
let address: String
let prefixLength: NSNumber
}
let ipv4Address: IPv4AddressSetting?
let ipv6Address: IPv6AddressSetting?
init?(rawValue: String) {
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !address.isEmpty else {
return nil
}
let prefix = components.count == 2 ? Int(components[1]) : nil
if IPv4Address(address) != nil {
let prefixLength = prefix ?? 32
guard (0 ... 32).contains(prefixLength) else {
return nil
}
ipv4Address = IPv4AddressSetting(
address: address,
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
)
ipv6Address = nil
return
}
if IPv6Address(address) != nil {
let prefixLength = prefix ?? 128
guard (0 ... 128).contains(prefixLength) else {
return nil
}
ipv4Address = nil
ipv6Address = IPv6AddressSetting(
address: address,
prefixLength: NSNumber(value: prefixLength)
)
return
}
return nil
}
private static func ipv4SubnetMask(prefixLength: Int) -> String {
guard prefixLength > 0 else {
return "0.0.0.0"
}
let mask = UInt32.max << (32 - prefixLength)
let octets = [
(mask >> 24) & 0xff,
(mask >> 16) & 0xff,
(mask >> 8) & 0xff,
mask & 0xff,
]
return octets.map(String.init).joined(separator: ".")
}
}
private struct ParsedTunnelRoute {
let ipv4Route: NEIPv4Route?
let ipv6Route: NEIPv6Route?
init?(rawValue: String) {
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !address.isEmpty else {
return nil
}
let prefix = components.count == 2 ? Int(components[1]) : nil
if IPv4Address(address) != nil {
let prefixLength = prefix ?? 32
guard (0 ... 32).contains(prefixLength) else {
return nil
}
ipv4Route = NEIPv4Route(
destinationAddress: address,
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
)
ipv6Route = nil
return
}
if IPv6Address(address) != nil {
let prefixLength = prefix ?? 128
guard (0 ... 128).contains(prefixLength) else {
return nil
}
ipv4Route = nil
ipv6Route = NEIPv6Route(
destinationAddress: address,
networkPrefixLength: NSNumber(value: prefixLength)
)
return
}
return nil
}
private static func ipv4SubnetMask(prefixLength: Int) -> String {
var mask = UInt32.max << (32 - prefixLength)
if prefixLength == 0 {
mask = 0
}
let octets = [
String((mask >> 24) & 0xff),
String((mask >> 16) & 0xff),
String((mask >> 8) & 0xff),
String(mask & 0xff),
]
return octets.joined(separator: ".")
}
}

View file

@ -83,7 +83,7 @@ public struct BurrowView: View {
ContentUnavailableView(
"No Accounts Yet",
systemImage: "person.crop.circle.badge.plus",
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.")
)
.frame(maxWidth: .infinity, minHeight: 180)
} else {
@ -135,7 +135,7 @@ public struct BurrowView: View {
private func runAutomationIfNeeded() {
guard !didRunAutomation,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
automation.action == .tailnetLogin || automation.action == .tailnetProbe
else {
return
}
@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View {
@State private var isStartingTailnetLogin = false
@State private var tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false
@State private var usesCustomTailnetAuthority = false
@State private var showsAdvancedTailnetSettings = false
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
@State private var tailnetLoginPollTask: Task<Void, Never>?
@State private var tailnetDiscoveryTask: Task<Void, Never>?
@State private var tailnetProbeTask: Task<Void, Never>?
@State private var didRunAutomation = false
init(
@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View {
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
if showsIdentitySection {
Section("Identity") {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
identityFields
}
}
@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View {
}
.onChange(of: draft.authority) { _, _ in
resetAuthorityProbe()
if sheet == .tailnet, usesCustomTailnetAuthority {
scheduleTailnetAuthorityProbe()
}
}
.onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback()
if sheet == .tailnet, !usesCustomTailnetAuthority {
scheduleTailnetDiscovery()
}
}
.onChange(of: draft.authMode) { _, newMode in
guard newMode != .web else { return }
@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View {
}
.onDisappear {
tailnetLoginPollTask?.cancel()
tailnetDiscoveryTask?.cancel()
tailnetProbeTask?.cancel()
browserAuthenticator.cancel()
if !preserveTailnetLoginSession {
Task { @MainActor in
@ -479,6 +486,18 @@ private struct ConfigurationSheetView: View {
}
}
@ViewBuilder
private var identityFields: some View {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
}
}
@ViewBuilder
private var tailnetSections: some View {
Section("Connection") {
@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View {
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-discovery-email")
Button {
discoverTailnetAuthority()
} label: {
Label {
Text(isDiscoveringTailnet ? "Finding Server" : "Find Server")
} icon: {
Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle")
.submitLabel(.continue)
.onSubmit {
if !usesCustomTailnetAuthority {
scheduleTailnetDiscovery(immediate: true)
}
}
.buttonStyle(.borderless)
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
.accessibilityIdentifier("tailnet-find-server")
if let discoveryStatus {
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
} else if let discoveryError {
tailnetDiscoveryCard(status: nil, failure: discoveryError)
}
tailnetServerCard
TextField("Authority URL", text: $draft.authority)
if showsAdvancedTailnetSettings {
if usesCustomTailnetAuthority {
TextField("Server URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-authority")
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
.font(.footnote)
.foregroundStyle(.secondary)
Button {
probeTailnetAuthority()
} label: {
Label {
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
} icon: {
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
}
}
.buttonStyle(.borderless)
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
.accessibilityIdentifier("tailnet-check-connection")
if let authorityProbeStatus {
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
} else if let authorityProbeError {
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
}
} else {
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-name")
}
}
}
Section("Authentication") {
if showsAdvancedTailnetSettings {
Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.menu)
}
if draft.authMode == .web {
Button {
@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View {
}
}
.buttonStyle(.borderless)
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
.accessibilityIdentifier("tailnet-start-sign-in")
if let tailnetLoginStatus {
@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View {
}
if sheet == .tailnet {
if let authorityProbeStatus {
Text(authorityProbeStatus.summary)
labeledValue("Server", tailnetServerDisplayLabel)
if let connectionSummary = tailnetConnectionSummary {
Text(connectionSummary)
.font(.footnote.weight(.medium))
.foregroundStyle(.primary)
if let detail = authorityProbeStatus.detail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
.foregroundStyle(tailnetConnectionSummaryColor)
}
} else if let authorityProbeError {
Text("Connection failed")
.font(.footnote.weight(.medium))
.foregroundStyle(.red)
Text(authorityProbeError)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
if sheet == .tailnet {
HStack(spacing: 8) {
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
summaryBadge(draft.authMode.title)
if tailnetLoginStatus?.running == true {
HStack(spacing: 8) {
summaryBadge("Signed In")
}
}
@ -654,6 +627,44 @@ private struct ConfigurationSheetView: View {
)
}
private var tailnetServerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(usesCustomTailnetAuthority ? "Custom Server" : "Server")
.font(.subheadline.weight(.medium))
Text(tailnetServerDisplayLabel)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer()
if isDiscoveringTailnet || isProbingAuthority {
ProgressView()
.controlSize(.small)
} else if let summary = tailnetConnectionSummary {
Text(summary)
.font(.caption.weight(.medium))
.foregroundStyle(tailnetConnectionSummaryColor)
}
}
if let detail = tailnetServerDetail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-server-card")
}
private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?,
failure: String?
@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View {
}
case .tailnet:
Button("Use Tailscale Managed Server") {
applyTailnetDefaults(for: .tailscale)
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
toggleTailnetAuthorityMode()
}
if availableTailnetAuthModes.count > 1 {
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
showsAdvancedTailnetSettings.toggle()
}
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
Menu("Authentication") {
ForEach(availableTailnetAuthModes) { mode in
Button(mode.title) {
@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View {
}
}
Button("Clear Discovery Result") {
resetTailnetDiscoveryFeedback()
Button("Refresh Server Lookup") {
scheduleTailnetDiscovery(immediate: true)
}
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
}
}
@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View {
private var showsBottomActionButton: Bool {
#if os(iOS)
true
return true
#else
false
return false
#endif
}
private var showsIdentitySection: Bool {
switch sheet {
case .wireGuard, .tor:
return true
case .tailnet:
return showsAdvancedTailnetSettings
}
}
private var wireGuardEditorHeight: CGFloat {
#if os(iOS)
180
@ -910,6 +935,18 @@ private struct ConfigurationSheetView: View {
}
}
private var tailnetLoginActionDisabled: Bool {
switch sheet {
case .tailnet:
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority) == nil
}
return false
case .wireGuard, .tor:
return true
}
}
private var submissionDisabled: Bool {
switch sheet {
case .wireGuard:
@ -933,6 +970,50 @@ private struct ConfigurationSheetView: View {
}
}
private var tailnetServerDisplayLabel: String {
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority)
?? "Enter a custom Tailnet server"
}
return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed"
}
private var tailnetServerDetail: String? {
if usesCustomTailnetAuthority {
if let discovery = discoveryStatus {
return "Discovered from \(discovery.domain)."
}
if let discoveryError {
return discoveryError
}
return "Use a custom Tailnet authority when your domain does not advertise one."
}
return "Continue with Tailscale, or open advanced settings to use a custom server."
}
private var tailnetConnectionSummary: String? {
if isDiscoveringTailnet {
return "Finding server"
}
if isProbingAuthority {
return "Checking"
}
if let authorityProbeStatus {
return authorityProbeStatus.summary
}
if authorityProbeError != nil {
return "Unavailable"
}
return nil
}
private var tailnetConnectionSummaryColor: Color {
if authorityProbeError != nil {
return .red
}
return .secondary
}
private func submit() {
isSubmitting = true
errorMessage = nil
@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
automation.action == .tailnetLogin || automation.action == .tailnetProbe
else {
return
}
@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View {
case .tailnetLogin:
applyTailnetDefaults(for: .tailscale)
startTailnetLogin()
case .headscaleProbe:
case .tailnetProbe:
usesCustomTailnetAuthority = true
showsAdvancedTailnetSettings = true
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority()
}
@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View {
)
var noteParts: [String] = [
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
"Auth: \(draft.authMode.title)",
"Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
]
if showsAdvancedTailnetSettings || draft.authMode != .web {
noteParts.append("Auth: \(draft.authMode.title)")
}
if draft.authMode == .web, tailnetLoginStatus?.running == true {
noteParts.append("Browser sign-in complete")
}
@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View {
private func applyTailnetDefaults(for provider: TailnetProvider) {
resetTailnetDiscoveryFeedback()
usesCustomTailnetAuthority = provider != .tailscale
draft.authority = provider.defaultAuthority ?? ""
if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = .web
@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View {
}
private func startTailnetLogin() {
guard let authority = normalizedOptional(draft.authority) else {
tailnetLoginStatus = nil
tailnetLoginError = "Enter a server URL first."
return
}
isStartingTailnetLogin = true
tailnetLoginError = nil
preserveTailnetLoginSession = false
@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in
defer { isStartingTailnetLogin = false }
do {
let authority = try await resolveTailnetAuthorityForLogin()
let status = try await networkViewModel.startTailnetLogin(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View {
}
private func resetAuthorityProbe() {
tailnetProbeTask?.cancel()
authorityProbeStatus = nil
authorityProbeError = nil
tailnetLoginError = nil
}
private func resetTailnetDiscoveryFeedback() {
tailnetDiscoveryTask?.cancel()
discoveryStatus = nil
discoveryError = nil
}
@ -1210,6 +1294,83 @@ private struct ConfigurationSheetView: View {
}
}
private func scheduleTailnetDiscovery(immediate: Bool = false) {
guard sheet == .tailnet else { return }
tailnetDiscoveryTask?.cancel()
guard !usesCustomTailnetAuthority else {
discoveryStatus = nil
discoveryError = nil
return
}
guard normalizedOptional(draft.discoveryEmail) != nil else {
discoveryStatus = nil
discoveryError = nil
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
return
}
tailnetDiscoveryTask = Task { @MainActor in
if !immediate {
try? await Task.sleep(for: .milliseconds(450))
}
guard !Task.isCancelled else { return }
discoverTailnetAuthority()
}
}
private func scheduleTailnetAuthorityProbe() {
guard sheet == .tailnet else { return }
tailnetProbeTask?.cancel()
guard normalizedOptional(draft.authority) != nil else { return }
tailnetProbeTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
probeTailnetAuthority()
}
}
private func toggleTailnetAuthorityMode() {
let discoveredAuthority = discoveryStatus?.authority
usesCustomTailnetAuthority.toggle()
resetTailnetDiscoveryFeedback()
resetAuthorityProbe()
if usesCustomTailnetAuthority {
draft.authority = discoveredAuthority ?? draft.authority
} else {
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil)
}
}
private func resolveTailnetAuthorityForLogin() async throws -> String {
if !usesCustomTailnetAuthority {
let authority = TailnetProvider.tailscale.defaultAuthority ?? ""
draft.authority = authority
scheduleTailnetAuthorityProbe()
return authority
}
if let authority = normalizedOptional(draft.authority) {
return authority
}
if let email = normalizedOptional(draft.discoveryEmail) {
let discovery = try await networkViewModel.discoverTailnet(email: email)
discoveryStatus = discovery
discoveryError = nil
draft.authority = discovery.authority
scheduleTailnetAuthorityProbe()
return discovery.authority
}
throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first."
])
}
private func beginTailnetLoginPolling(sessionID: String) {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = Task { @MainActor in
@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View {
if tailnetLoginSessionID != nil {
return "Resume Sign-In"
}
return "Start Sign-In"
return "Continue with Tailscale"
}
private var tailnetAuthenticationFootnote: String {
switch draft.authMode {
case .web:
return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running."
if usesCustomTailnetAuthority {
return "Burrow signs in through the daemon using your custom Tailnet server."
}
return "Burrow signs in through the daemon using Tailscale's managed browser flow."
case .none:
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
case .password, .preauthKey:
@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View {
)
}
private var isManagedTailnetAuthority: Bool {
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
@ -1383,12 +1543,7 @@ private struct AccountRowView: View {
VStack(alignment: .leading, spacing: 4) {
Text(account.title)
.font(.headline)
HStack(spacing: 8) {
Text(account.kind.title)
if let provider = account.provider {
Text(provider.title)
}
}
.font(.subheadline)
.foregroundStyle(account.kind.accentColor)
}
@ -1470,6 +1625,12 @@ private extension View {
@MainActor
private final class TailnetBrowserAuthenticator: NSObject {
private var session: ASWebAuthenticationSession?
private static var prefersEphemeralSessionForCurrentProcess: Bool {
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
}
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
cancel()
@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
onDismiss()
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
self.session = session
_ = session.start()
}
@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator {
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
case headscaleProbe = "headscale-probe"
case tailnetProbe = "tailnet-probe"
}
let action: Action

View file

@ -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."
}
}
}

View file

@ -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

View file

@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest {
pub hostname: Option<String>,
#[serde(default)]
pub control_url: Option<String>,
#[serde(default)]
pub packet_socket: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
@ -55,23 +57,35 @@ pub struct TailscaleLoginStartResponse {
pub status: TailscaleLoginStatus,
}
pub struct TailscaleLoginSession {
pub session_id: String,
pub helper: Arc<TailscaleHelperProcess>,
pub status: TailscaleLoginStatus,
}
#[derive(Clone, Default)]
pub struct TailscaleBridgeManager {
client: Client,
sessions: Arc<Mutex<HashMap<String, Arc<ManagedSession>>>>,
}
struct ManagedSession {
pub struct TailscaleHelperProcess {
session_id: String,
listen_url: String,
packet_socket: Option<PathBuf>,
control_url: Option<String>,
state_dir: PathBuf,
child: Arc<Mutex<Child>>,
_stderr_task: JoinHandle<()>,
}
type ManagedSession = TailscaleHelperProcess;
#[derive(Debug, Deserialize)]
struct HelperHello {
listen_addr: String,
#[serde(default)]
packet_socket: Option<String>,
}
impl TailscaleBridgeManager {
@ -79,13 +93,45 @@ impl TailscaleBridgeManager {
&self,
request: TailscaleLoginStartRequest,
) -> Result<TailscaleLoginStartResponse> {
let key = session_key(&request.account_name, &request.identity_name);
let session = self.ensure_session(request).await?;
Ok(TailscaleLoginStartResponse {
session_id: session.session_id,
status: session.status,
})
}
pub async fn ensure_session(
&self,
request: TailscaleLoginStartRequest,
) -> Result<TailscaleLoginSession> {
let key = session_key_for_request(&request);
let requested_packet_socket = request
.packet_socket
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let requested_control_url = request
.control_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket())
{
(Some(requested), Some(current)) => current != Path::new(requested),
(Some(_), None) => true,
_ => false,
};
let needs_restart_for_control_url =
requested_control_url != existing.control_url().map(|value| value.trim());
if !needs_restart_for_socket && !needs_restart_for_control_url {
match self.fetch_status(existing.as_ref()).await {
Ok(status) => {
return Ok(TailscaleLoginStartResponse {
return Ok(TailscaleLoginSession {
session_id: existing.session_id.clone(),
helper: existing,
status,
});
}
@ -94,61 +140,24 @@ impl TailscaleBridgeManager {
"tailscale login session {} is stale, restarting: {err}",
existing.session_id
);
}
}
} else {
log::info!(
"tailscale login session {} no longer matches requested transport, restarting",
existing.session_id
);
}
self.sessions.lock().await.remove(&key);
let _ = self.shutdown_session(existing.as_ref()).await;
}
}
}
let state_dir = state_root().join(session_dir_name(&request));
tokio::fs::create_dir_all(&state_dir)
.await
.with_context(|| format!("failed to create {}", state_dir.display()))?;
let mut child = helper_command(&request, &state_dir)?
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn tailscale login helper")?;
let stdout = child
.stdout
.take()
.context("tailscale helper stdout unavailable")?;
let stderr = child
.stderr
.take()
.context("tailscale helper stderr unavailable")?;
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
let mut lines = BufReader::new(stdout).lines();
lines.next_line().await
})
.await
.context("timed out waiting for tailscale helper startup")??
.context("tailscale helper exited before reporting listen address")?;
let hello: HelperHello =
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("tailscale-login-bridge: {line}");
}
});
let session = Arc::new(ManagedSession {
session_id: random_session_id(),
listen_url: format!("http://{}", hello.listen_addr),
state_dir,
child: Arc::new(Mutex::new(child)),
_stderr_task: stderr_task,
});
let session = Arc::new(spawn_tailscale_helper(&request).await?);
let status = self.wait_for_status(session.as_ref()).await?;
let response = TailscaleLoginStartResponse {
let response = TailscaleLoginSession {
session_id: session.session_id.clone(),
helper: session.clone(),
status,
};
@ -192,7 +201,7 @@ impl TailscaleBridgeManager {
let mut last_error = None;
let mut last_status = None;
for _ in 0..40 {
match self.fetch_status(session).await {
match session.status_with_client(&self.client).await {
Ok(status) if status.running || status.auth_url.is_some() => return Ok(status),
Ok(status) => last_status = Some(status),
Err(err) => last_error = Some(err),
@ -206,28 +215,7 @@ impl TailscaleBridgeManager {
}
async fn fetch_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
let mut child = session.child.lock().await;
if let Some(status) = child.try_wait()? {
return Err(anyhow!(
"tailscale helper exited with status {status} for {}",
session.state_dir.display()
));
}
drop(child);
let response = self
.client
.get(format!("{}/status", session.listen_url))
.send()
.await
.context("failed to query tailscale helper status")?
.error_for_status()
.context("tailscale helper status request failed")?;
response
.json::<TailscaleLoginStatus>()
.await
.context("invalid tailscale helper status response")
session.status_with_client(&self.client).await
}
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
@ -239,14 +227,74 @@ impl TailscaleBridgeManager {
}
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
let _ = self
.client
.post(format!("{}/shutdown", session.listen_url))
session.shutdown_with_client(&self.client).await
}
}
impl TailscaleHelperProcess {
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn packet_socket(&self) -> Option<&Path> {
self.packet_socket.as_deref()
}
pub fn control_url(&self) -> Option<&str> {
self.control_url.as_deref()
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub async fn status(&self) -> Result<TailscaleLoginStatus> {
self.status_with_client(&Client::new()).await
}
pub async fn shutdown(&self) -> Result<()> {
self.shutdown_with_client(&Client::new()).await
}
async fn status_with_client(&self, client: &Client) -> Result<TailscaleLoginStatus> {
let mut child = self.child.lock().await;
if let Some(status) = child.try_wait()? {
return Err(anyhow!(
"tailscale helper exited with status {status} for {}",
self.state_dir.display()
));
}
drop(child);
let response = client
.get(format!("{}/status", self.listen_url))
.send()
.await;
.await
.context("failed to query tailscale helper status")?
.error_for_status()
.context("tailscale helper status request failed")?;
let status = response
.json::<TailscaleLoginStatus>()
.await
.context("invalid tailscale helper status response")?;
log::info!(
"tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}",
self.session_id,
status.backend_state,
status.running,
status.needs_login,
status.auth_url
);
Ok(status)
}
async fn shutdown_with_client(&self, client: &Client) -> Result<()> {
let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await;
for _ in 0..10 {
let mut child = session.child.lock().await;
let mut child = self.child.lock().await;
if child.try_wait()?.is_some() {
return Ok(());
}
@ -254,7 +302,7 @@ impl TailscaleBridgeManager {
tokio::time::sleep(Duration::from_millis(100)).await;
}
let mut child = session.child.lock().await;
let mut child = self.child.lock().await;
child
.start_kill()
.context("failed to kill tailscale helper")?;
@ -263,6 +311,58 @@ impl TailscaleBridgeManager {
}
}
pub async fn spawn_tailscale_helper(
request: &TailscaleLoginStartRequest,
) -> Result<TailscaleHelperProcess> {
let state_dir = state_root().join(session_dir_name(request));
tokio::fs::create_dir_all(&state_dir)
.await
.with_context(|| format!("failed to create {}", state_dir.display()))?;
let mut child = helper_command(request, &state_dir)?
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn tailscale login helper")?;
let stdout = child
.stdout
.take()
.context("tailscale helper stdout unavailable")?;
let stderr = child
.stderr
.take()
.context("tailscale helper stderr unavailable")?;
let hello_line = tokio::time::timeout(Duration::from_secs(20), async move {
let mut lines = BufReader::new(stdout).lines();
lines.next_line().await
})
.await
.context("timed out waiting for tailscale helper startup")??
.context("tailscale helper exited before reporting listen address")?;
let hello: HelperHello =
serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?;
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("tailscale-login-bridge: {line}");
}
});
Ok(TailscaleHelperProcess {
session_id: random_session_id(),
listen_url: format!("http://{}", hello.listen_addr),
packet_socket: hello.packet_socket.map(PathBuf::from),
control_url: request.control_url.clone(),
state_dir,
child: Arc::new(Mutex::new(child)),
_stderr_task: stderr_task,
})
}
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") {
Command::new(path)
@ -291,10 +391,21 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res
}
}
if let Some(packet_socket) = request.packet_socket.as_deref() {
let trimmed = packet_socket.trim();
if !trimmed.is_empty() {
command.arg("--packet-socket").arg(trimmed);
}
}
Ok(command)
}
fn state_root() -> PathBuf {
pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf {
state_root().join(session_dir_name(request)).join("packet.sock")
}
pub(crate) fn state_root() -> PathBuf {
if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") {
return PathBuf::from(path);
}
@ -315,19 +426,34 @@ fn state_root() -> PathBuf {
.join("tailscale")
}
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
format!(
"{}-{}",
"{}-{}-{}",
slug(&request.account_name),
slug(&request.identity_name)
slug(&request.identity_name),
slug(control_scope(request))
)
}
fn session_key(account_name: &str, identity_name: &str) -> String {
format!("{account_name}:{identity_name}")
fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String {
format!(
"{}:{}:{}",
request.account_name,
request.identity_name,
control_scope(request)
)
}
fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
fn control_scope(request: &TailscaleLoginStartRequest) -> &str {
request
.control_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("tailscale-managed")
}
pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String {
request
.hostname
.as_deref()
@ -370,14 +496,24 @@ mod tests {
}
#[test]
fn state_dir_is_stable_by_account_and_identity() {
fn state_dir_is_scoped_by_account_identity_and_control_plane() {
let request = TailscaleLoginStartRequest {
account_name: "default".to_owned(),
identity_name: "apple".to_owned(),
hostname: None,
control_url: None,
packet_socket: None,
};
assert_eq!(session_dir_name(&request), "default-apple");
assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed");
assert_eq!(default_hostname(&request), "burrow-apple");
let custom_request = TailscaleLoginStartRequest {
control_url: Some("https://ts.burrow.net".to_owned()),
..request
};
assert_eq!(
session_dir_name(&custom_request),
"default-apple-httpstsburrownet"
);
}
}

View file

@ -1,6 +1,7 @@
use anyhow::{anyhow, Context, Result};
use reqwest::{Client, StatusCode, Url};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use super::TailnetProvider;
@ -43,6 +44,7 @@ struct WebFingerLink {
pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
let domain = email_domain(email)?;
info!(%email, %domain, "tailnet discovery requested");
let base_url = Url::parse(&format!("https://{domain}"))
.with_context(|| format!("invalid discovery domain {domain}"))?;
let client = Client::builder()
@ -116,12 +118,21 @@ pub async fn discover_tailnet_at(
base_url: &Url,
) -> Result<TailnetDiscovery> {
let domain = email_domain(email)?;
debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery");
if let Some(discovery) = discover_well_known(client, base_url).await? {
info!(
%email,
%domain,
authority = %discovery.authority,
provider = ?discovery.provider,
"resolved tailnet discovery from well-known document"
);
return Ok(TailnetDiscovery { domain, ..discovery });
}
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger");
return Ok(TailnetDiscovery {
domain,
provider: inferred_provider(Some(&authority), None),
@ -162,6 +173,7 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
let url = base_url
.join(TAILNET_DISCOVERY_PATH)
.context("failed to build tailnet discovery URL")?;
debug!(%url, "requesting tailnet well-known document");
let response = client
.get(url)
.header("accept", "application/json")
@ -187,6 +199,7 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
url.query_pairs_mut()
.append_pair("resource", &format!("acct:{email}"))
.append_pair("rel", TAILNET_DISCOVERY_REL);
debug!(%email, url = %url, "requesting tailnet webfinger document");
let response = client
.get(url)

View file

@ -8,7 +8,7 @@ use rusqlite::Connection;
use tokio::sync::{mpsc, watch, RwLock};
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status as RspStatus};
use tracing::warn;
use tracing::{debug, info, warn};
use tun::tokio::TunInterface;
use super::{
@ -16,15 +16,15 @@ use super::{
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
TunnelStatusResponse,
},
runtime::{ActiveTunnel, ResolvedTunnel},
runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
};
use crate::{
auth::server::tailscale::{
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
TailscaleLoginStatus,
packet_socket_path, TailscaleBridgeManager,
TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus,
},
control::discovery,
daemon::rpc::ServerConfig,
@ -87,11 +87,20 @@ impl DaemonRPCServer {
}
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
let config = self
let config = {
let active = self.active_tunnel.read().await;
active
.as_ref()
.map(|tunnel| tunnel.server_config().clone())
};
let config = match config {
Some(config) => config,
None => self
.resolve_tunnel()
.await?
.server_config()
.map_err(proc_err)?;
.map_err(proc_err)?,
};
Ok(configuration_rsp(config))
}
@ -111,8 +120,18 @@ impl DaemonRPCServer {
async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> {
let _ = self.stop_active_tunnel().await?;
let tailnet_helper = match &desired {
ResolvedTunnel::Tailnet { identity, config } => Some(
self.tailnet_login
.ensure_session(tailnet_helper_request(identity, config))
.await
.map_err(proc_err)?
.helper,
),
_ => None,
};
let active = desired
.start(self.tun_interface.clone())
.start(self.tun_interface.clone(), tailnet_helper)
.await
.map_err(proc_err)?;
self.active_tunnel.write().await.replace(active);
@ -137,6 +156,23 @@ impl DaemonRPCServer {
Ok(())
}
fn tailnet_bridge_request(
account_name: String,
identity_name: String,
hostname: String,
authority: String,
) -> BridgeLoginStartRequest {
let mut request = BridgeLoginStartRequest {
account_name,
identity_name,
hostname: (!hostname.trim().is_empty()).then_some(hostname),
control_url: Self::tailnet_control_url(&authority),
packet_socket: None,
};
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
request
}
fn tailnet_control_url(authority: &str) -> Option<String> {
let authority = discovery::normalize_authority(authority);
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
@ -146,6 +182,7 @@ impl DaemonRPCServer {
#[tonic::async_trait]
impl Tunnel for DaemonRPCServer {
type TunnelConfigurationStream = ReceiverStream<Result<TunnelConfigurationResponse, RspStatus>>;
type TunnelPacketsStream = ReceiverStream<Result<TunnelPacket, RspStatus>>;
type TunnelStatusStream = ReceiverStream<Result<TunnelStatusResponse, RspStatus>>;
async fn tunnel_configuration(
@ -171,6 +208,62 @@ impl Tunnel for DaemonRPCServer {
Ok(Response::new(ReceiverStream::new(rx)))
}
async fn tunnel_packets(
&self,
request: Request<tonic::Streaming<TunnelPacket>>,
) -> Result<Response<Self::TunnelPacketsStream>, RspStatus> {
let (packet_tx, mut packet_rx) = {
let guard = self.active_tunnel.read().await;
let Some(active) = guard.as_ref() else {
return Err(RspStatus::failed_precondition("no active tunnel"));
};
active.packet_stream().ok_or_else(|| {
RspStatus::failed_precondition(
"active tunnel does not support packet streaming",
)
})?
};
let (tx, rx) = mpsc::channel(128);
tokio::spawn(async move {
loop {
match packet_rx.recv().await {
Ok(payload) => {
if tx.send(Ok(TunnelPacket { payload })).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let mut inbound = request.into_inner();
tokio::spawn(async move {
loop {
match inbound.message().await {
Ok(Some(packet)) => {
debug!(
"daemon tunnel packet stream received {} bytes from client",
packet.payload.len()
);
if packet_tx.send(packet.payload).await.is_err() {
break;
}
}
Ok(None) => break,
Err(error) => {
warn!("tailnet packet stream receive error: {error}");
break;
}
}
}
});
Ok(Response::new(ReceiverStream::new(rx)))
}
async fn tunnel_start(&self, _request: Request<Empty>) -> Result<Response<Empty>, RspStatus> {
let desired = self.resolve_tunnel().await?;
let already_running = {
@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer {
request: Request<TailnetDiscoverRequest>,
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
let request = request.into_inner();
info!(email = %request.email, "daemon tailnet discover RPC received");
let discovery = discovery::discover_tailnet(&request.email)
.await
.map_err(proc_err)?;
info!(
email = %request.email,
authority = %discovery.authority,
provider = ?discovery.provider,
"daemon tailnet discover RPC resolved"
);
Ok(Response::new(TailnetDiscoverResponse {
domain: discovery.domain,
@ -325,17 +425,32 @@ impl TailnetControl for DaemonRPCServer {
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner();
info!(
account = %request.account_name,
identity = %request.identity_name,
authority = %request.authority,
"daemon tailnet login start RPC received"
);
let response = self
.tailnet_login
.start_login(BridgeLoginStartRequest {
account_name: request.account_name,
identity_name: request.identity_name,
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
control_url: Self::tailnet_control_url(&request.authority),
})
.start_login(Self::tailnet_bridge_request(
request.account_name,
request.identity_name,
request.hostname,
request.authority,
))
.await
.map_err(proc_err)?;
info!(
session_id = %response.session_id,
backend_state = %response.status.backend_state,
running = response.status.running,
needs_login = response.status.needs_login,
auth_url = ?response.status.auth_url,
"daemon tailnet login start RPC resolved"
);
Ok(Response::new(tailnet_login_rsp(
response.session_id,
response.status,
@ -347,6 +462,7 @@ impl TailnetControl for DaemonRPCServer {
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner();
info!(session_id = %request.session_id, "daemon tailnet login status RPC received");
let status = self
.tailnet_login
.status(&request.session_id)
@ -355,6 +471,14 @@ impl TailnetControl for DaemonRPCServer {
let Some(status) = status else {
return Err(RspStatus::not_found("tailnet login session not found"));
};
info!(
session_id = %request.session_id,
backend_state = %status.backend_state,
running = status.running,
needs_login = status.needs_login,
auth_url = ?status.auth_url,
"daemon tailnet login status RPC resolved"
);
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
}
@ -381,8 +505,12 @@ fn proc_err(err: impl ToString) -> RspStatus {
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
TunnelConfigurationResponse {
mtu: config.mtu.unwrap_or(1000),
addresses: config.address,
mtu: config.mtu.unwrap_or(1000),
routes: config.routes,
dns_servers: config.dns_servers,
search_domains: config.search_domains,
include_default_route: config.include_default_route,
}
}

View file

@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct ServerConfig {
pub address: Vec<String>,
#[serde(default)]
pub routes: Vec<String>,
#[serde(default)]
pub dns_servers: Vec<String>,
#[serde(default)]
pub search_domains: Vec<String>,
#[serde(default)]
pub include_default_route: bool,
pub name: Option<String>,
pub mtu: Option<i32>,
}
@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig {
fn try_from(config: &Config) -> anyhow::Result<Self> {
Ok(ServerConfig {
address: config.interface.address.clone(),
routes: config
.peers
.iter()
.flat_map(|peer| peer.allowed_ips.iter().cloned())
.collect(),
dns_servers: config.interface.dns.clone(),
search_domains: Vec::new(),
include_default_route: false,
name: None,
mtu: config.interface.mtu.map(|mtu| mtu as i32),
})
@ -88,6 +104,10 @@ impl Default for ServerConfig {
fn default() -> Self {
Self {
address: vec!["10.13.13.2".to_string()], // Dummy remote address
routes: Vec::new(),
dns_servers: Vec::new(),
search_domains: Vec::new(),
include_default_route: false,
name: None,
mtu: None,
}

View file

@ -1,7 +1,13 @@
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use tokio::{sync::RwLock, task::JoinHandle};
use anyhow::{bail, Context, Result};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::UnixStream,
sync::{broadcast, mpsc, RwLock},
task::JoinHandle,
time::{sleep, Duration},
};
use tun::{tokio::TunInterface, TunOptions};
use super::rpc::{
@ -9,7 +15,11 @@ use super::rpc::{
ServerConfig,
};
use crate::{
control::TailnetConfig,
auth::server::tailscale::{
default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess,
TailscaleLoginStartRequest, TailscaleLoginStatus,
},
control::{discovery, TailnetConfig},
wireguard::{Config, Interface as WireGuardInterface},
};
@ -78,11 +88,19 @@ impl ResolvedTunnel {
match self {
Self::Passthrough { .. } => Ok(ServerConfig {
address: Vec::new(),
routes: Vec::new(),
dns_servers: Vec::new(),
search_domains: Vec::new(),
include_default_route: false,
name: None,
mtu: Some(1500),
}),
Self::Tailnet { .. } => Ok(ServerConfig {
address: Vec::new(),
routes: tailnet_routes(),
dns_servers: tailnet_dns_servers(),
search_domains: Vec::new(),
include_default_route: false,
name: None,
mtu: Some(1280),
}),
@ -93,21 +111,71 @@ impl ResolvedTunnel {
pub async fn start(
self,
tun_interface: Arc<RwLock<Option<TunInterface>>>,
tailnet_helper: Option<Arc<TailscaleHelperProcess>>,
) -> Result<ActiveTunnel> {
match self {
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }),
Self::Tailnet { config, .. } => Err(anyhow::anyhow!(
"tailnet runtime is not wired in this checkout yet ({:?})",
config.provider
)),
Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough {
identity,
server_config: ServerConfig {
address: Vec::new(),
routes: Vec::new(),
dns_servers: Vec::new(),
search_domains: Vec::new(),
include_default_route: false,
name: None,
mtu: Some(1500),
},
}),
Self::Tailnet { identity, config } => {
let (helper, shutdown_helper_on_stop) = match tailnet_helper {
Some(helper) => (helper, false),
None => {
let helper_request = tailnet_helper_request(&identity, &config);
let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?);
(helper, true)
}
};
let status = wait_for_tailnet_ready(helper.as_ref()).await?;
let server_config = tailnet_server_config(&status);
let packet_socket = helper
.packet_socket()
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?;
let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?;
#[cfg(target_vendor = "apple")]
let tun_task = None;
#[cfg(not(target_vendor = "apple"))]
let tun_task = {
let tun = TunOptions::new().open()?;
tun_interface.write().await.replace(tun);
Some(tokio::spawn(run_tailnet_tun_bridge(
tun_interface.clone(),
packet_bridge.outbound_sender(),
packet_bridge.subscribe(),
)))
};
Ok(ActiveTunnel::Tailnet {
identity,
server_config,
helper,
shutdown_helper_on_stop,
packet_bridge,
tun_task,
})
}
Self::WireGuard { identity, config } => {
let server_config = ServerConfig::try_from(&config)?;
let tun = TunOptions::new().open()?;
tun_interface.write().await.replace(tun);
match start_wireguard_runtime(config, tun_interface.clone()).await {
Ok((interface, task)) => {
Ok(ActiveTunnel::WireGuard { identity, interface, task })
}
Ok((interface, task)) => Ok(ActiveTunnel::WireGuard {
identity,
server_config,
interface,
task,
}),
Err(err) => {
tun_interface.write().await.take();
Err(err)
@ -121,9 +189,19 @@ impl ResolvedTunnel {
pub enum ActiveTunnel {
Passthrough {
identity: RuntimeIdentity,
server_config: ServerConfig,
},
Tailnet {
identity: RuntimeIdentity,
server_config: ServerConfig,
helper: Arc<TailscaleHelperProcess>,
shutdown_helper_on_stop: bool,
packet_bridge: TailnetPacketBridge,
tun_task: Option<JoinHandle<Result<()>>>,
},
WireGuard {
identity: RuntimeIdentity,
server_config: ServerConfig,
interface: Arc<RwLock<WireGuardInterface>>,
task: JoinHandle<Result<()>>,
},
@ -132,15 +210,69 @@ pub enum ActiveTunnel {
impl ActiveTunnel {
pub fn identity(&self) -> &RuntimeIdentity {
match self {
Self::Passthrough { identity }
Self::Passthrough { identity, .. }
| Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } => identity,
}
}
pub fn server_config(&self) -> &ServerConfig {
match self {
Self::Passthrough { server_config, .. }
| Self::Tailnet { server_config, .. }
| Self::WireGuard { server_config, .. } => server_config,
}
}
pub fn packet_stream(
&self,
) -> Option<(mpsc::Sender<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
match self {
Self::Tailnet { packet_bridge, .. } => Some((
packet_bridge.outbound_sender(),
packet_bridge.subscribe(),
)),
_ => None,
}
}
pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
match self {
Self::Passthrough { .. } => Ok(()),
Self::WireGuard { interface, task, .. } => {
Self::Tailnet {
helper,
shutdown_helper_on_stop,
packet_bridge,
tun_task,
..
} => {
if let Some(tun_task) = tun_task {
tun_task.abort();
match tun_task.await {
Ok(Ok(())) => {}
Ok(Err(err)) => return Err(err),
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(err.into()),
}
}
packet_bridge.task.abort();
match packet_bridge.task.await {
Ok(Ok(())) => {}
Ok(Err(err)) => return Err(err),
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(err.into()),
}
tun_interface.write().await.take();
if shutdown_helper_on_stop {
helper.shutdown().await?;
}
Ok(())
}
Self::WireGuard {
interface,
task,
..
} => {
interface.read().await.remove_tun().await;
let task_result = task.await;
tun_interface.write().await.take();
@ -151,6 +283,22 @@ impl ActiveTunnel {
}
}
pub struct TailnetPacketBridge {
outbound: mpsc::Sender<Vec<u8>>,
inbound: broadcast::Sender<Vec<u8>>,
task: JoinHandle<Result<()>>,
}
impl TailnetPacketBridge {
fn outbound_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.outbound.clone()
}
fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
self.inbound.subscribe()
}
}
async fn start_wireguard_runtime(
config: Config,
tun_interface: Arc<RwLock<Option<TunInterface>>>,
@ -166,6 +314,279 @@ async fn start_wireguard_runtime(
Ok((interface, task))
}
pub(crate) fn tailnet_helper_request(
identity: &RuntimeIdentity,
config: &TailnetConfig,
) -> TailscaleLoginStartRequest {
let account_name = config
.account
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("default")
.to_owned();
let identity_name = config
.identity
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| match identity {
RuntimeIdentity::Network { id, .. } => format!("network-{id}"),
RuntimeIdentity::Passthrough => "apple".to_owned(),
});
let control_url = config.authority.as_deref().and_then(|authority| {
let authority = discovery::normalize_authority(authority);
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
});
let mut request = TailscaleLoginStartRequest {
account_name,
identity_name,
hostname: config.hostname.clone(),
control_url,
packet_socket: None,
};
request.packet_socket = Some(packet_socket_path(&request).display().to_string());
if request
.hostname
.as_deref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
request.hostname = Some(default_hostname(&request));
}
request
}
async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result<TailscaleLoginStatus> {
let mut last_status = None;
for _ in 0..120 {
let status = helper.status().await?;
if status.running && !status.tailscale_ips.is_empty() {
return Ok(status);
}
if status.needs_login || status.auth_url.is_some() {
bail!("tailnet runtime requires a completed login before the tunnel can start");
}
last_status = Some(status);
sleep(Duration::from_millis(250)).await;
}
if let Some(status) = last_status {
bail!(
"tailnet helper never became ready (backend_state={})",
status.backend_state
);
}
bail!("tailnet helper never produced a status update")
}
fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig {
let mut search_domains = Vec::new();
if let Some(suffix) = status.magic_dns_suffix.as_deref() {
let suffix = suffix.trim().trim_end_matches('.');
if !suffix.is_empty() {
search_domains.push(suffix.to_owned());
}
}
ServerConfig {
address: status
.tailscale_ips
.iter()
.map(|ip| tailnet_cidr(ip))
.collect(),
routes: tailnet_routes(),
dns_servers: tailnet_dns_servers(),
search_domains,
include_default_route: false,
name: status.self_dns_name.clone(),
mtu: Some(1280),
}
}
fn tailnet_routes() -> Vec<String> {
vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()]
}
fn tailnet_dns_servers() -> Vec<String> {
vec!["100.100.100.100".to_owned()]
}
fn tailnet_cidr(ip: &str) -> String {
if ip.contains('/') {
return ip.to_owned();
}
if ip.contains(':') {
format!("{ip}/128")
} else {
format!("{ip}/32")
}
}
async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result<TailnetPacketBridge> {
let mut last_error = None;
let mut stream = None;
for _ in 0..50 {
match UnixStream::connect(&packet_socket).await {
Ok(connected) => {
stream = Some(connected);
break;
}
Err(err) => {
last_error = Some(err);
sleep(Duration::from_millis(100)).await;
}
}
}
let stream = if let Some(stream) = stream {
stream
} else {
return Err(last_error
.context("failed to connect to tailnet helper packet socket")?
.into());
};
let (outbound_tx, outbound_rx) = mpsc::channel(128);
let (inbound_tx, _) = broadcast::channel(128);
let task = tokio::spawn(run_tailnet_socket_bridge(
stream,
outbound_rx,
inbound_tx.clone(),
));
Ok(TailnetPacketBridge {
outbound: outbound_tx,
inbound: inbound_tx,
task,
})
}
async fn run_tailnet_socket_bridge(
stream: UnixStream,
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
inbound_tx: broadcast::Sender<Vec<u8>>,
) -> Result<()> {
let (mut reader, mut writer) = stream.into_split();
let inbound = tokio::spawn(async move {
loop {
let packet = read_packet_frame(&mut reader).await?;
tracing::debug!(
"tailnet packet bridge received {} bytes from helper socket",
packet.len()
);
let _ = inbound_tx.send(packet);
}
#[allow(unreachable_code)]
Result::<()>::Ok(())
});
let outbound = tokio::spawn(async move {
while let Some(packet) = outbound_rx.recv().await {
tracing::debug!(
"tailnet packet bridge writing {} bytes to helper socket",
packet.len()
);
write_packet_frame(&mut writer, &packet).await?;
}
Result::<()>::Ok(())
});
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
inbound_result?;
outbound_result?;
Ok(())
}
#[cfg(not(target_vendor = "apple"))]
async fn run_tailnet_tun_bridge(
tun_interface: Arc<RwLock<Option<TunInterface>>>,
outbound_tx: mpsc::Sender<Vec<u8>>,
mut inbound_rx: broadcast::Receiver<Vec<u8>>,
) -> Result<()> {
let inbound_tun = tun_interface.clone();
let inbound = tokio::spawn(async move {
loop {
let packet = match inbound_rx.recv().await {
Ok(packet) => packet,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
};
let guard = inbound_tun.read().await;
let Some(tun) = guard.as_ref() else {
bail!("tailnet tun interface unavailable");
};
tun.send(&packet)
.await
.context("failed to write tailnet packet to tun")?;
}
Result::<()>::Ok(())
});
let outbound_tun = tun_interface.clone();
let outbound = tokio::spawn(async move {
let mut buf = vec![0u8; 65_535];
loop {
let len = {
let guard = outbound_tun.read().await;
let Some(tun) = guard.as_ref() else {
bail!("tailnet tun interface unavailable");
};
tun.recv(&mut buf)
.await
.context("failed to read packet from tailnet tun")?
};
outbound_tx
.send(buf[..len].to_vec())
.await
.context("failed to forward packet to tailnet helper")?;
}
#[allow(unreachable_code)]
Result::<()>::Ok(())
});
let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?;
inbound_result?;
outbound_result?;
Ok(())
}
async fn read_packet_frame<R>(reader: &mut R) -> Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut len_buf = [0u8; 4];
reader
.read_exact(&mut len_buf)
.await
.context("failed to read tailnet packet frame length")?;
let len = u32::from_be_bytes(len_buf) as usize;
let mut packet = vec![0u8; len];
reader
.read_exact(&mut packet)
.await
.context("failed to read tailnet packet frame payload")?;
Ok(packet)
}
async fn write_packet_frame<W>(writer: &mut W, packet: &[u8]) -> Result<()>
where
W: AsyncWrite + Unpin,
{
writer
.write_all(&(packet.len() as u32).to_be_bytes())
.await
.context("failed to write tailnet packet frame length")?;
writer
.write_all(packet)
.await
.context("failed to write tailnet packet frame payload")?;
writer
.flush()
.await
.context("failed to flush tailnet packet frame")
}
#[cfg(test)]
mod tests {
use super::*;
@ -179,4 +600,19 @@ mod tests {
Vec::<String>::new()
);
}
#[test]
fn tailnet_server_config_uses_host_prefixes() {
let status = TailscaleLoginStatus {
running: true,
tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()],
..Default::default()
};
let config = tailnet_server_config(&status);
assert_eq!(
config.address,
vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"]
);
assert_eq!(config.mtu, Some(1280));
}
}

View file

@ -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)
};

View file

@ -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;
}