Route Tailnet Apple flows through daemon gRPC

This commit is contained in:
Conrad Kramer 2026-04-03 01:36:55 -07:00
parent f6a7f0922d
commit d1e28b8817
8 changed files with 565 additions and 520 deletions

View file

@ -1,5 +1,7 @@
import Foundation
import GRPC import GRPC
import NIOTransportServices import NIOTransportServices
import SwiftProtobuf
public typealias TunnelClient = Burrow_TunnelAsyncClient public typealias TunnelClient = Burrow_TunnelAsyncClient
public typealias NetworksClient = Burrow_NetworksAsyncClient public typealias NetworksClient = Burrow_NetworksAsyncClient
@ -30,3 +32,199 @@ extension NetworksClient: Client {
self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none)
} }
} }
public struct Burrow_TailnetDiscoverRequest: Sendable {
public var email: String = ""
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetDiscoverResponse: Sendable {
public var domain: String = ""
public var authority: String = ""
public var oidcIssuer: String = ""
public var managed: Bool = false
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetProbeRequest: Sendable {
public var authority: String = ""
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetProbeResponse: Sendable {
public var authority: String = ""
public var statusCode: Int32 = 0
public var summary: String = ""
public var detail: String = ""
public var reachable: Bool = false
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 = [
1: .same(proto: "email")
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularStringField(value: &self.email)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.email.isEmpty {
try visitor.visitSingularStringField(value: self.email, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetDiscoverResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetDiscoverResponse"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "domain"),
2: .same(proto: "authority"),
3: .same(proto: "oidc_issuer"),
4: .same(proto: "managed"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularStringField(value: &self.domain)
case 2: try decoder.decodeSingularStringField(value: &self.authority)
case 3: try decoder.decodeSingularStringField(value: &self.oidcIssuer)
case 4: try decoder.decodeSingularBoolField(value: &self.managed)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.domain.isEmpty {
try visitor.visitSingularStringField(value: self.domain, fieldNumber: 1)
}
if !self.authority.isEmpty {
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 2)
}
if !self.oidcIssuer.isEmpty {
try visitor.visitSingularStringField(value: self.oidcIssuer, fieldNumber: 3)
}
if self.managed {
try visitor.visitSingularBoolField(value: self.managed, fieldNumber: 4)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetProbeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetProbeRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "authority")
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularStringField(value: &self.authority)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.authority.isEmpty {
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetProbeResponse"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "authority"),
2: .same(proto: "status_code"),
3: .same(proto: "summary"),
4: .same(proto: "detail"),
5: .same(proto: "reachable"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularStringField(value: &self.authority)
case 2: try decoder.decodeSingularInt32Field(value: &self.statusCode)
case 3: try decoder.decodeSingularStringField(value: &self.summary)
case 4: try decoder.decodeSingularStringField(value: &self.detail)
case 5: try decoder.decodeSingularBoolField(value: &self.reachable)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.authority.isEmpty {
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1)
}
if self.statusCode != 0 {
try visitor.visitSingularInt32Field(value: self.statusCode, fieldNumber: 2)
}
if !self.summary.isEmpty {
try visitor.visitSingularStringField(value: self.summary, fieldNumber: 3)
}
if !self.detail.isEmpty {
try visitor.visitSingularStringField(value: self.detail, fieldNumber: 4)
}
if self.reachable {
try visitor.visitSingularBoolField(value: self.reachable, fieldNumber: 5)
}
try unknownFields.traverse(visitor: &visitor)
}
}
public struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel
public var defaultCallOptions: CallOptions
public init(channel: any GRPCChannel) {
self.channel = channel
self.defaultCallOptions = .init()
}
public func discover(
_ request: Burrow_TailnetDiscoverRequest,
callOptions: CallOptions? = nil
) async throws -> Burrow_TailnetDiscoverResponse {
try await self.performAsyncUnaryCall(
path: "/burrow.TailnetControl/Discover",
request: request,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
public func probe(
_ request: Burrow_TailnetProbeRequest,
callOptions: CallOptions? = nil
) async throws -> Burrow_TailnetProbeResponse {
try await self.performAsyncUnaryCall(
path: "/burrow.TailnetControl/Probe",
request: request,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
}

View file

@ -1,4 +1,3 @@
import AuthenticationServices
import BurrowConfiguration import BurrowConfiguration
import Foundation import Foundation
import SwiftUI import SwiftUI
@ -204,7 +203,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
switch self { switch self {
case .wireGuard: .wireGuard case .wireGuard: .wireGuard
case .tor: .tor case .tor: .tor
case .tailnet: .headscale case .tailnet: .tailnet
} }
} }
@ -285,13 +284,12 @@ private struct AccountDraft {
var wireGuardConfig = "" var wireGuardConfig = ""
var discoveryEmail = "" var discoveryEmail = ""
var tailnetProvider: TailnetProvider = .tailscale
var authority = "" var authority = ""
var tailnet = "" var tailnet = ""
var hostname = ProcessInfo.processInfo.hostName var hostname = ProcessInfo.processInfo.hostName
var username = "" var username = ""
var secret = "" var secret = ""
var authMode: AccountAuthMode = .web var authMode: AccountAuthMode = .none
var torAddresses = "100.64.0.2/32" var torAddresses = "100.64.0.2/32"
var torDNS = "1.1.1.1, 1.0.0.1" var torDNS = "1.1.1.1, 1.0.0.1"
@ -317,7 +315,6 @@ private struct AccountDraft {
private struct ConfigurationSheetView: View { private struct ConfigurationSheetView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
let sheet: ConfigurationSheet let sheet: ConfigurationSheet
let networkViewModel: NetworkViewModel let networkViewModel: NetworkViewModel
@ -326,17 +323,13 @@ private struct ConfigurationSheetView: View {
@State private var draft: AccountDraft @State private var draft: AccountDraft
@State private var isSubmitting = false @State private var isSubmitting = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var loginSessionID: String?
@State private var loginStatus: TailnetLoginStatus?
@State private var discoveryStatus: TailnetDiscoveryResponse? @State private var discoveryStatus: TailnetDiscoveryResponse?
@State private var discoveryError: String? @State private var discoveryError: String?
@State private var isDiscoveringTailnet = false @State private var isDiscoveringTailnet = false
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
@State private var authorityProbeError: String? @State private var authorityProbeError: String?
@State private var isProbingAuthority = false @State private var isProbingAuthority = false
@State private var pollingTask: Task<Void, Never>?
@State private var didRunAutomation = false @State private var didRunAutomation = false
@State private var webAuthenticationTask: Task<Void, Never>?
init( init(
sheet: ConfigurationSheet, sheet: ConfigurationSheet,
@ -447,20 +440,12 @@ private struct ConfigurationSheetView: View {
.onAppear { .onAppear {
runAutomationIfNeeded() runAutomationIfNeeded()
} }
.onChange(of: draft.tailnetProvider) { _, _ in
resetAuthorityProbe()
}
.onChange(of: draft.authority) { _, _ in .onChange(of: draft.authority) { _, _ in
resetAuthorityProbe() resetAuthorityProbe()
} }
.onChange(of: draft.discoveryEmail) { _, _ in .onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback() resetTailnetDiscoveryFeedback()
} }
.onDisappear {
pollingTask?.cancel()
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
}
} }
@ViewBuilder @ViewBuilder
@ -490,48 +475,30 @@ private struct ConfigurationSheetView: View {
tailnetDiscoveryCard(status: nil, failure: discoveryError) tailnetDiscoveryCard(status: nil, failure: discoveryError)
} }
Picker( TextField("Authority URL", text: $draft.authority)
"Provider", .burrowLoginField()
selection: Binding( .autocorrectionDisabled()
get: { draft.tailnetProvider },
set: { applyTailnetProvider($0) } Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
) .font(.footnote)
) { .foregroundStyle(.secondary)
ForEach(TailnetProvider.allCases) { provider in
Text(provider.title).tag(provider) Button {
probeTailnetAuthority()
} label: {
Label {
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
} icon: {
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
} }
} }
.pickerStyle(.menu) .buttonStyle(.borderless)
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
tailnetProviderCard if let authorityProbeStatus {
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
if draft.tailnetProvider.requiresControlURL { } else if let authorityProbeError {
TextField("Server URL", text: $draft.authority) tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
.burrowLoginField()
.autocorrectionDisabled()
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)
if let authorityProbeStatus {
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
} else if let authorityProbeError {
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
}
} else {
LabeledContent("Server") {
Text("Tailscale managed")
.foregroundStyle(.secondary)
}
} }
TextField("Tailnet", text: $draft.tailnet) TextField("Tailnet", text: $draft.tailnet)
@ -540,28 +507,24 @@ private struct ConfigurationSheetView: View {
} }
Section("Authentication") { Section("Authentication") {
if tailnetUsesWebLogin { TextField("Username", text: $draft.username)
tailnetWebLoginCard .burrowLoginField()
} else { .autocorrectionDisabled()
TextField("Username", text: $draft.username) Picker("Authentication", selection: $draft.authMode) {
.burrowLoginField() ForEach(availableTailnetAuthModes) { mode in
.autocorrectionDisabled() Text(mode.title).tag(mode)
Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
}
} }
.pickerStyle(.menu)
if draft.authMode != .none {
SecureField(
draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret
)
}
Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.")
.font(.footnote)
.foregroundStyle(.secondary)
} }
.pickerStyle(.menu)
if draft.authMode != .none {
SecureField(
draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret
)
}
Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.")
.font(.footnote)
.foregroundStyle(.secondary)
} }
} }
@ -618,10 +581,8 @@ private struct ConfigurationSheetView: View {
if sheet == .tailnet { if sheet == .tailnet {
HStack(spacing: 8) { HStack(spacing: 8) {
summaryBadge(draft.tailnetProvider.title) summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
summaryBadge( summaryBadge(draft.authMode.title)
tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title
)
} }
} }
} }
@ -632,79 +593,6 @@ private struct ConfigurationSheetView: View {
) )
} }
private var tailnetProviderCard: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Image(systemName: tailnetProviderIconName)
.font(.headline)
.foregroundStyle(sheetAccentColor)
.frame(width: 28, height: 28)
.background(
Circle()
.fill(sheetAccentColor.opacity(0.14))
)
VStack(alignment: .leading, spacing: 2) {
Text(draft.tailnetProvider.title)
.font(.headline)
Text(draft.tailnetProvider.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
@ViewBuilder
private var tailnetWebLoginCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Sign in with the shared browser session.")
.font(.subheadline.weight(.medium))
if let loginStatus {
labeledValue("State", loginStatus.backendState)
if let tailnetName = loginStatus.tailnetName {
labeledValue("Tailnet", tailnetName)
}
if let dnsName = loginStatus.selfDNSName {
labeledValue("Device", dnsName)
}
if !loginStatus.tailscaleIPs.isEmpty {
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
}
if let authURL = loginStatus.authURL {
Button("Resume Sign-In") {
if let url = URL(string: authURL) {
openLoginURL(url)
}
}
.buttonStyle(.borderless)
}
if !loginStatus.health.isEmpty {
Text(loginStatus.health.joined(separator: ""))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
private func tailnetAuthorityProbeCard( private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?, status: TailnetAuthorityProbeStatus?,
failure: String? failure: String?
@ -739,12 +627,15 @@ private struct ConfigurationSheetView: View {
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if let status { if let status {
Text("Discovered \(status.provider.title)") Text("Discovered Tailnet Server")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
Text(status.authority) Text(status.authority)
.font(.footnote.monospaced()) .font(.footnote.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.textSelection(.enabled) .textSelection(.enabled)
Text(status.provider == .tailscale ? "Managed authority" : "Custom authority")
.font(.footnote)
.foregroundStyle(.secondary)
if let oidcIssuer = status.oidcIssuer { if let oidcIssuer = status.oidcIssuer {
Text("OIDC: \(oidcIssuer)") Text("OIDC: \(oidcIssuer)")
.font(.footnote) .font(.footnote)
@ -826,12 +717,8 @@ private struct ConfigurationSheetView: View {
} }
case .tailnet: case .tailnet:
Menu("Provider") { Button("Use Tailscale Managed Server") {
ForEach(TailnetProvider.allCases) { provider in applyTailnetDefaults(for: .tailscale)
Button(provider.title) {
applyTailnetProvider(provider)
}
}
} }
if availableTailnetAuthModes.count > 1 { if availableTailnetAuthModes.count > 1 {
@ -839,7 +726,7 @@ private struct ConfigurationSheetView: View {
ForEach(availableTailnetAuthModes) { mode in ForEach(availableTailnetAuthModes) { mode in
Button(mode.title) { Button(mode.title) {
draft.authMode = mode draft.authMode = mode
if mode == .none || mode == .web { if mode == .none {
draft.secret = "" draft.secret = ""
} }
} }
@ -847,8 +734,8 @@ private struct ConfigurationSheetView: View {
} }
} }
Button("Restore Provider Defaults") { Button("Clear Discovery Result") {
applyTailnetDefaults(for: draft.tailnetProvider) resetTailnetDiscoveryFeedback()
} }
} }
} }
@ -886,17 +773,6 @@ private struct ConfigurationSheetView: View {
} }
} }
private var tailnetProviderIconName: String {
switch draft.tailnetProvider {
case .tailscale:
"globe.badge.chevron.backward"
case .headscale:
"server.rack"
case .burrow:
"shield"
}
}
private var showsBottomActionButton: Bool { private var showsBottomActionButton: Bool {
#if os(iOS) #if os(iOS)
true true
@ -920,9 +796,6 @@ private struct ConfigurationSheetView: View {
case .tor: case .tor:
return "Save Account" return "Save Account"
case .tailnet: case .tailnet:
if tailnetUsesWebLogin {
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
}
return "Save Account" return "Save Account"
} }
} }
@ -937,12 +810,9 @@ private struct ConfigurationSheetView: View {
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
return true return true
} }
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { if normalizedOptional(draft.authority) == nil {
return true return true
} }
if tailnetUsesWebLogin {
return false
}
if draft.authMode != .none && normalizedOptional(draft.secret) == nil { if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
return true return true
} }
@ -1027,41 +897,12 @@ private struct ConfigurationSheetView: View {
} }
private func submitTailnet() async throws { private func submitTailnet() async throws {
if tailnetUsesWebLogin {
if loginStatus?.running == true {
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
try await saveTailnetAccount(secret: nil, username: nil)
dismiss()
} else {
try await startTailnetLogin()
}
return
}
let secret = draft.authMode == .none ? nil : draft.secret let secret = draft.authMode == .none ? nil : draft.secret
let username = normalizedOptional(draft.username) let username = normalizedOptional(draft.username)
try await saveTailnetAccount(secret: secret, username: username) try await saveTailnetAccount(secret: secret, username: username)
dismiss() dismiss()
} }
private func startTailnetLogin() async throws {
let response = try await TailnetBridgeClient.startLogin(
TailnetLoginStartRequest(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: normalizedOptional(draft.hostname),
controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority
)
)
loginSessionID = response.sessionID
loginStatus = response.status
if let authURL = response.status.authURL, let url = URL(string: authURL) {
openLoginURL(url)
}
startPollingTailscaleLogin()
}
private func runAutomationIfNeeded() { private func runAutomationIfNeeded() {
guard !didRunAutomation, guard !didRunAutomation,
sheet == .tailnet, sheet == .tailnet,
@ -1080,79 +921,19 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in Task { @MainActor in
switch automation.action { switch automation.action {
case .tailnetLogin: case .tailnetLogin:
draft.tailnetProvider = .tailscale applyTailnetDefaults(for: .tailscale)
do { probeTailnetAuthority()
try await startTailnetLogin()
} catch {
errorMessage = error.localizedDescription
}
case .headscaleProbe: case .headscaleProbe:
applyTailnetProvider(.headscale)
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority() probeTailnetAuthority()
} }
} }
} }
private func startPollingTailscaleLogin() {
pollingTask?.cancel()
guard let loginSessionID else { return }
pollingTask = Task { @MainActor in
while !Task.isCancelled {
do {
let status = try await TailnetBridgeClient.status(sessionID: loginSessionID)
let previousAuthURL = loginStatus?.authURL
loginStatus = status
if previousAuthURL == nil,
let authURL = status.authURL,
let url = URL(string: authURL)
{
openLoginURL(url)
}
if status.running {
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
return
}
} catch {
errorMessage = error.localizedDescription
return
}
try? await Task.sleep(for: .seconds(2))
}
}
}
private func openLoginURL(_ url: URL) {
webAuthenticationTask?.cancel()
webAuthenticationTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
do {
_ = try await webAuthenticationSession.authenticate(
using: url,
callbackURLScheme: "burrow",
preferredBrowserSession: .shared
)
} catch is CancellationError {
return
} catch let error as ASWebAuthenticationSessionError
where error.code == .canceledLogin
{
return
} catch {
errorMessage = error.localizedDescription
}
webAuthenticationTask = nil
}
}
private func saveTailnetAccount(secret: String?, username: String?) async throws { private func saveTailnetAccount(secret: String?, username: String?) async throws {
let provider = draft.tailnetProvider let provider = inferredTailnetProvider
let title = titleOrFallback( let title = titleOrFallback(
hostnameFallback( hostnameFallback(from: draft.authority, fallback: "Tailnet")
from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
fallback: provider.title
)
) )
let payload = TailnetNetworkPayload( let payload = TailnetNetworkPayload(
@ -1160,22 +941,14 @@ private struct ConfigurationSheetView: View {
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
account: normalized(draft.accountName, fallback: "default"), account: normalized(draft.accountName, fallback: "default"),
identity: normalized(draft.identityName, fallback: "apple"), identity: normalized(draft.identityName, fallback: "apple"),
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), tailnet: normalizedOptional(draft.tailnet),
hostname: normalizedOptional(draft.hostname) hostname: normalizedOptional(draft.hostname)
) )
var noteParts: [String] = [ var noteParts: [String] = [
provider.title, isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
tailnetUsesWebLogin "Auth: \(draft.authMode.title)",
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
: "Auth: \(draft.authMode.title)",
] ]
if let dnsName = loginStatus?.selfDNSName {
noteParts.append("Device: \(dnsName)")
}
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
noteParts.append("MagicDNS: \(magicDNSSuffix)")
}
do { do {
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
@ -1186,7 +959,7 @@ private struct ConfigurationSheetView: View {
let record = NetworkAccountRecord( let record = NetworkAccountRecord(
id: UUID(), id: UUID(),
kind: .headscale, kind: .tailnet,
title: title, title: title,
authority: payload.authority, authority: payload.authority,
provider: provider, provider: provider,
@ -1195,7 +968,7 @@ private struct ConfigurationSheetView: View {
hostname: payload.hostname, hostname: payload.hostname,
username: username, username: username,
tailnet: payload.tailnet, tailnet: payload.tailnet,
authMode: tailnetUsesWebLogin ? .web : draft.authMode, authMode: draft.authMode,
note: noteParts.joined(separator: ""), note: noteParts.joined(separator: ""),
createdAt: .now, createdAt: .now,
updatedAt: .now updatedAt: .now
@ -1226,33 +999,15 @@ private struct ConfigurationSheetView: View {
draft.torListen = defaults.torListen draft.torListen = defaults.torListen
} }
private func applyTailnetProvider(_ provider: TailnetProvider) {
resetTailnetDiscoveryFeedback()
draft.tailnetProvider = provider
applyTailnetDefaults(for: provider)
}
private func applyTailnetDefaults(for provider: TailnetProvider) { private func applyTailnetDefaults(for provider: TailnetProvider) {
resetTailnetDiscoveryFeedback()
draft.authority = provider.defaultAuthority ?? "" draft.authority = provider.defaultAuthority ?? ""
loginStatus = nil if !availableTailnetAuthModes.contains(draft.authMode) {
loginSessionID = nil draft.authMode = .none
pollingTask?.cancel()
if provider == .tailscale {
draft.authMode = .web
draft.username = ""
draft.secret = ""
} else {
if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = provider.supportsWebLogin ? .web : .none
}
if draft.authMode == .web && !provider.supportsWebLogin {
draft.authMode = .none
}
} }
} }
private func probeTailnetAuthority() { private func probeTailnetAuthority() {
guard draft.tailnetProvider.requiresControlURL else { return }
guard let authority = normalizedOptional(draft.authority) else { guard let authority = normalizedOptional(draft.authority) else {
authorityProbeStatus = nil authorityProbeStatus = nil
authorityProbeError = "Enter a server URL first." authorityProbeError = "Enter a server URL first."
@ -1266,10 +1021,7 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in Task { @MainActor in
defer { isProbingAuthority = false } defer { isProbingAuthority = false }
do { do {
authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority)
provider: draft.tailnetProvider,
authority: authority
)
} catch { } catch {
authorityProbeError = error.localizedDescription authorityProbeError = error.localizedDescription
} }
@ -1300,15 +1052,9 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in Task { @MainActor in
defer { isDiscoveringTailnet = false } defer { isDiscoveringTailnet = false }
do { do {
let discovery = try await TailnetDiscoveryClient.discover(email: email) let discovery = try await networkViewModel.discoverTailnet(email: email)
discoveryStatus = discovery discoveryStatus = discovery
draft.tailnetProvider = discovery.provider
draft.authority = discovery.authority draft.authority = discovery.authority
if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil {
draft.authMode = .web
draft.username = ""
draft.secret = ""
}
probeTailnetAuthority() probeTailnetAuthority()
} catch { } catch {
discoveryError = error.localizedDescription discoveryError = error.localizedDescription
@ -1361,19 +1107,19 @@ private struct ConfigurationSheetView: View {
return host return host
} }
private var tailnetUsesWebLogin: Bool { private var availableTailnetAuthModes: [AccountAuthMode] {
draft.authMode == .web && draft.tailnetProvider.supportsWebLogin [.none, .password, .preauthKey]
} }
private var availableTailnetAuthModes: [AccountAuthMode] { private var inferredTailnetProvider: TailnetProvider {
switch draft.tailnetProvider { TailnetProvider.inferred(
case .tailscale: authority: normalizedOptional(draft.authority),
[.web] explicit: discoveryStatus?.provider
case .headscale: )
[.web, .none, .password, .preauthKey] }
case .burrow:
[.none, .password, .preauthKey] private var isManagedTailnetAuthority: Bool {
} TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
} }
@ViewBuilder @ViewBuilder

View file

@ -26,13 +26,6 @@ struct TailnetNetworkPayload: Codable, Sendable {
} }
} }
struct TailnetLoginStartRequest: Codable, Sendable {
var accountName: String
var identityName: String
var hostname: String?
var controlURL: String?
}
struct TailnetDiscoveryResponse: Codable, Sendable { struct TailnetDiscoveryResponse: Codable, Sendable {
var domain: String var domain: String
var provider: TailnetProvider var provider: TailnetProvider
@ -40,23 +33,6 @@ struct TailnetDiscoveryResponse: Codable, Sendable {
var oidcIssuer: String? var oidcIssuer: String?
} }
struct TailnetLoginStatus: Codable, Sendable {
var backendState: String
var authURL: String?
var running: Bool
var needsLogin: Bool
var tailnetName: String?
var magicDNSSuffix: String?
var selfDNSName: String?
var tailscaleIPs: [String]
var health: [String]
}
struct TailnetLoginStartResponse: Codable, Sendable {
var sessionID: String
var status: TailnetLoginStatus
}
struct TailnetAuthorityProbeStatus: Sendable { struct TailnetAuthorityProbeStatus: Sendable {
var authority: String var authority: String
var statusCode: Int var statusCode: Int
@ -64,148 +40,38 @@ struct TailnetAuthorityProbeStatus: Sendable {
var detail: String? var detail: String?
} }
enum TailnetBridgeClient {
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse {
var urlRequest = URLRequest(
url: baseURL.appendingPathComponent("v1/tailscale/login/start")
)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
urlRequest.httpBody = try encoder.encode(request)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
try validate(response: response, data: data)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(TailnetLoginStartResponse.self, from: data)
}
static func status(sessionID: String) async throws -> TailnetLoginStatus {
let url = baseURL
.appendingPathComponent("v1/tailscale/login")
.appendingPathComponent(sessionID)
let (data, response) = try await URLSession.shared.data(from: url)
try validate(response: response, data: data)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(TailnetLoginStatus.self, from: data)
}
fileprivate static func validate(response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines
)
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
}
}
}
enum TailnetDiscoveryClient { enum TailnetDiscoveryClient {
private static let baseURL = URL(string: "http://127.0.0.1:8080")! static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
var request = Burrow_TailnetDiscoverRequest()
request.email = email
static func discover(email: String) async throws -> TailnetDiscoveryResponse { let response = try await TailnetClient.unix(socketURL: socketURL).discover(request)
guard var components = URLComponents( return TailnetDiscoveryResponse(
url: baseURL.appendingPathComponent("v1/tailnet/discover"), domain: response.domain,
resolvingAgainstBaseURL: false provider: response.managed ? .tailscale : .headscale,
) else { authority: response.authority,
throw URLError(.badURL) oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} ? nil
components.queryItems = [ : response.oidcIssuer
URLQueryItem(name: "email", value: email) )
]
guard let url = components.url else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
try TailnetBridgeClient.validate(response: response, data: data)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(TailnetDiscoveryResponse.self, from: data)
} }
} }
enum TailnetAuthorityProbeClient { enum TailnetAuthorityProbeClient {
static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus {
let normalizedAuthority = normalizeAuthority(authority) var request = Burrow_TailnetProbeRequest()
let baseURL = try validatedBaseURL(normalizedAuthority) request.authority = authority
let probeURL = probeURL(for: provider, baseURL: baseURL)
var request = URLRequest(url: probeURL)
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines
)
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
}
let body = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let detail = body.flatMap { $0.isEmpty ? nil : $0 }
let response = try await TailnetClient.unix(socketURL: socketURL).probe(request)
return TailnetAuthorityProbeStatus( return TailnetAuthorityProbeStatus(
authority: normalizedAuthority, authority: response.authority,
statusCode: http.statusCode, statusCode: Int(response.statusCode),
summary: "\(provider.title) reachable", summary: response.summary,
detail: detail detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.detail
) )
} }
private static func normalizeAuthority(_ authority: String) -> String {
let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.contains("://") {
return trimmed
}
return "https://\(trimmed)"
}
private static func validatedBaseURL(_ authority: String) throws -> URL {
guard let url = URL(string: authority), url.host != nil else {
throw TailnetBridgeError.server("Invalid server URL")
}
return url
}
private static func probeURL(for provider: TailnetProvider, baseURL: URL) -> URL {
switch provider {
case .headscale:
baseURL.appendingPathComponent("health")
case .burrow:
baseURL.appendingPathComponent("healthz")
case .tailscale:
baseURL
}
}
}
enum TailnetBridgeError: LocalizedError {
case server(String)
var errorDescription: String? {
switch self {
case .server(let message):
message
}
}
} }
@Observable @Observable
@ -215,7 +81,7 @@ final class NetworkViewModel: Sendable {
private(set) var connectionError: String? private(set) var connectionError: String?
private let socketURLResult: Result<URL, Error> private let socketURLResult: Result<URL, Error>
nonisolated(unsafe) private var task: Task<Void, Never>? @ObservationIgnored private var task: Task<Void, Never>?
init(socketURLResult: Result<URL, Error>) { init(socketURLResult: Result<URL, Error>) {
self.socketURLResult = socketURLResult self.socketURLResult = socketURLResult
@ -242,6 +108,16 @@ final class NetworkViewModel: Sendable {
try await addNetwork(type: .tailnet, payload: payload.encoded()) try await addNetwork(type: .tailnet, payload: payload.encoded())
} }
func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse {
let socketURL = try socketURLResult.get()
return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL)
}
func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus {
let socketURL = try socketURLResult.get()
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
}
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
let socketURL = try socketURLResult.get() let socketURL = try socketURLResult.get()
let networkID = nextNetworkID let networkID = nextNetworkID
@ -341,19 +217,6 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
} }
} }
var supportsWebLogin: Bool {
switch self {
case .tailscale, .headscale:
true
case .burrow:
false
}
}
var requiresControlURL: Bool {
self != .tailscale
}
var defaultAuthority: String? { var defaultAuthority: String? {
switch self { switch self {
case .tailscale: case .tailscale:
@ -368,19 +231,44 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
var subtitle: String { var subtitle: String {
switch self { switch self {
case .tailscale: case .tailscale:
"Use Tailscale's real browser login flow." "Managed Tailnet authority."
case .headscale: case .headscale:
"Use your Headscale control plane with browser or key-based sign-in." "Custom Tailnet control server."
case .burrow: case .burrow:
"Store Burrow control-plane credentials." "Burrow-native Tailnet authority."
} }
} }
static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider {
if explicit == .burrow {
return .burrow
}
if isManagedTailscaleAuthority(authority) {
return .tailscale
}
return .headscale
}
static func isManagedTailscaleAuthority(_ authority: String?) -> Bool {
guard let normalized = authority?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "/")),
!normalized.isEmpty
else {
return false
}
return normalized == "https://controlplane.tailscale.com"
|| normalized == "http://controlplane.tailscale.com"
|| normalized == "controlplane.tailscale.com"
}
} }
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case wireGuard case wireGuard
case tor case tor
case headscale case tailnet
var id: String { rawValue } var id: String { rawValue }
@ -388,7 +276,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .wireGuard: "WireGuard" case .wireGuard: "WireGuard"
case .tor: "Tor" case .tor: "Tor"
case .headscale: "Tailnet" case .tailnet: "Tailnet"
} }
} }
@ -396,7 +284,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .wireGuard: "Import a tunnel and optional account metadata." case .wireGuard: "Import a tunnel and optional account metadata."
case .tor: "Store Arti account and identity preferences." case .tor: "Store Arti account and identity preferences."
case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities." case .tailnet: "Save Tailnet authority, identity, and login material."
} }
} }
@ -404,7 +292,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .wireGuard: .init("WireGuard") case .wireGuard: .init("WireGuard")
case .tor: .orange case .tor: .orange
case .headscale: .mint case .tailnet: .mint
} }
} }
@ -412,7 +300,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .wireGuard: "Add Network" case .wireGuard: "Add Network"
case .tor: "Save Account" case .tor: "Save Account"
case .headscale: "Save Account" case .tailnet: "Save Account"
} }
} }
@ -422,7 +310,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
nil nil
case .tor: case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
case .headscale: 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 be stored in the daemon."
} }
} }
@ -430,7 +318,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
case none case none
case web
case password case password
case preauthKey case preauthKey
@ -439,7 +326,6 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String { var title: String {
switch self { switch self {
case .none: "None" case .none: "None"
case .web: "Web Login"
case .password: "Password" case .password: "Password"
case .preauthKey: "Preauth Key" case .preauthKey: "Preauth Key"
} }
@ -465,17 +351,15 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable {
struct TailnetCard { struct TailnetCard {
var id: Int32 var id: Int32
var provider: String
var title: String var title: String
var detail: String var detail: String
init(network: Burrow_Network) { init(network: Burrow_Network) {
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
id = network.id id = network.id
provider = payload?.provider.title ?? "Tailnet"
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
detail = [ detail = [
payload?.provider.title, payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority,
payload?.authority, payload?.authority,
payload.map { "Account: \($0.account)" }, payload.map { "Account: \($0.account)" },
] ]
@ -492,7 +376,7 @@ struct TailnetCard {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(provider) Text("Tailnet")
.font(.headline) .font(.headline)
.foregroundStyle(.white.opacity(0.85)) .foregroundStyle(.white.opacity(0.85))
Text(title) Text(title)

View file

@ -7,6 +7,7 @@ use super::TailnetProvider;
pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server";
const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet";
const WEBFINGER_PATH: &str = "/.well-known/webfinger"; const WEBFINGER_PATH: &str = "/.well-known/webfinger";
const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TailnetDiscovery { pub struct TailnetDiscovery {
@ -17,6 +18,15 @@ pub struct TailnetDiscovery {
pub oidc_issuer: Option<String>, pub oidc_issuer: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TailnetAuthorityProbe {
pub authority: String,
pub status_code: i32,
pub summary: String,
pub detail: String,
pub reachable: bool,
}
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
struct WebFingerDocument { struct WebFingerDocument {
#[serde(default)] #[serde(default)]
@ -43,6 +53,63 @@ pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
discover_tailnet_at(&client, email, &base_url).await discover_tailnet_at(&client, email, &base_url).await
} }
pub fn normalize_authority(authority: &str) -> String {
let trimmed = authority.trim();
if trimmed.contains("://") {
trimmed.to_owned()
} else {
format!("https://{trimmed}")
}
}
pub fn is_managed_tailscale_authority(authority: &str) -> bool {
let normalized = normalize_authority(authority)
.trim_end_matches('/')
.to_ascii_lowercase();
normalized == format!("https://{MANAGED_TAILSCALE_AUTHORITY}")
|| normalized == format!("http://{MANAGED_TAILSCALE_AUTHORITY}")
}
pub async fn probe_tailnet_authority(authority: &str) -> Result<TailnetAuthorityProbe> {
let authority = normalize_authority(authority);
if is_managed_tailscale_authority(&authority) {
return Ok(TailnetAuthorityProbe {
authority,
status_code: 200,
summary: "Tailscale-managed control plane".to_owned(),
detail: "Using Tailscale's default login server.".to_owned(),
reachable: true,
});
}
let base_url =
Url::parse(&authority).with_context(|| format!("invalid tailnet authority {authority}"))?;
let client = Client::builder()
.user_agent("burrow-tailnet-probe")
.timeout(std::time::Duration::from_secs(10))
.build()
.context("failed to build tailnet authority probe client")?;
if let Some(status) =
probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await?
{
return Ok(status);
}
if let Some(status) = probe_url(
&client,
base_url.clone(),
&authority,
"Tailnet server reachable",
)
.await?
{
return Ok(status);
}
Err(anyhow!("could not connect to the server"))
}
pub async fn discover_tailnet_at( pub async fn discover_tailnet_at(
client: &Client, client: &Client,
email: &str, email: &str,
@ -57,7 +124,7 @@ pub async fn discover_tailnet_at(
if let Some(authority) = discover_webfinger(client, email, base_url).await? { if let Some(authority) = discover_webfinger(client, email, base_url).await? {
return Ok(TailnetDiscovery { return Ok(TailnetDiscovery {
domain, domain,
provider: TailnetProvider::Headscale, provider: inferred_provider(Some(&authority), None),
authority, authority,
oidc_issuer: None, oidc_issuer: None,
}); });
@ -78,6 +145,19 @@ pub fn email_domain(email: &str) -> Result<String> {
Ok(domain) Ok(domain)
} }
pub fn inferred_provider(
authority: Option<&str>,
explicit: Option<&TailnetProvider>,
) -> TailnetProvider {
if matches!(explicit, Some(TailnetProvider::Burrow)) {
return TailnetProvider::Burrow;
}
if authority.is_some_and(is_managed_tailscale_authority) {
return TailnetProvider::Tailscale;
}
TailnetProvider::Headscale
}
async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<TailnetDiscovery>> { async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<TailnetDiscovery>> {
let url = base_url let url = base_url
.join(TAILNET_DISCOVERY_PATH) .join(TAILNET_DISCOVERY_PATH)
@ -133,6 +213,37 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
} }
} }
async fn probe_url(
client: &Client,
url: Url,
authority: &str,
summary: &str,
) -> Result<Option<TailnetAuthorityProbe>> {
let response = match client
.get(url)
.header("accept", "application/json")
.send()
.await
{
Ok(response) => response,
Err(_) => return Ok(None),
};
let status = response.status();
if !status.is_success() {
return Ok(None);
}
let detail = response.text().await.unwrap_or_default().trim().to_owned();
Ok(Some(TailnetAuthorityProbe {
authority: authority.to_owned(),
status_code: i32::from(status.as_u16()),
summary: summary.to_owned(),
detail,
reachable: true,
}))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::{routing::get, Router}; use axum::{routing::get, Router};
@ -147,6 +258,13 @@ mod tests {
assert!(email_domain("contact").is_err()); assert!(email_domain("contact").is_err());
} }
#[test]
fn detects_managed_tailscale_authority() {
assert!(is_managed_tailscale_authority("controlplane.tailscale.com"));
assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/"));
assert!(!is_managed_tailscale_authority("https://ts.burrow.net"));
}
#[tokio::test] #[tokio::test]
async fn discovers_from_well_known_document() -> Result<()> { async fn discovers_from_well_known_document() -> Result<()> {
let router = Router::new().route( let router = Router::new().route(
@ -209,4 +327,20 @@ mod tests {
server.abort(); server.abort();
Ok(()) Ok(())
} }
#[tokio::test]
async fn probes_custom_authority() -> Result<()> {
let router = Router::new().route("/health", get(|| async { "ok" }));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let authority = format!("http://{}", listener.local_addr()?);
let server = tokio::spawn(async move { axum::serve(listener, router).await });
let status = probe_tailnet_authority(&authority).await?;
assert_eq!(status.authority, authority);
assert_eq!(status.status_code, 200);
assert!(status.reachable);
server.abort();
Ok(())
}
} }

View file

@ -13,13 +13,16 @@ use tun::tokio::TunInterface;
use super::{ use super::{
rpc::grpc_defs::{ rpc::grpc_defs::{
networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, networks_server::Networks, tailnet_control_server::TailnetControl,
NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse,
NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest,
TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse,
TunnelConfigurationResponse, TunnelStatusResponse, TunnelConfigurationResponse, TunnelStatusResponse,
}, },
runtime::{ActiveTunnel, ResolvedTunnel}, runtime::{ActiveTunnel, ResolvedTunnel},
}; };
use crate::{ use crate::{
control::discovery,
daemon::rpc::ServerConfig, daemon::rpc::ServerConfig,
database::{add_network, delete_network, get_connection, list_networks, reorder_network}, database::{add_network, delete_network, get_connection, list_networks, reorder_network},
}; };
@ -266,6 +269,47 @@ impl Networks for DaemonRPCServer {
} }
} }
#[tonic::async_trait]
impl TailnetControl for DaemonRPCServer {
async fn discover(
&self,
request: Request<TailnetDiscoverRequest>,
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
let request = request.into_inner();
let discovery = discovery::discover_tailnet(&request.email)
.await
.map_err(proc_err)?;
Ok(Response::new(TailnetDiscoverResponse {
domain: discovery.domain,
authority: discovery.authority.clone(),
oidc_issuer: discovery.oidc_issuer.unwrap_or_default(),
managed: matches!(
discovery::inferred_provider(Some(&discovery.authority), Some(&discovery.provider)),
crate::control::TailnetProvider::Tailscale
),
}))
}
async fn probe(
&self,
request: Request<TailnetProbeRequest>,
) -> Result<Response<TailnetProbeResponse>, RspStatus> {
let request = request.into_inner();
let status = discovery::probe_tailnet_authority(&request.authority)
.await
.map_err(proc_err)?;
Ok(Response::new(TailnetProbeResponse {
authority: status.authority,
status_code: status.status_code,
summary: status.summary,
detail: status.detail,
reachable: status.reachable,
}))
}
}
fn proc_err(err: impl ToString) -> RspStatus { fn proc_err(err: impl ToString) -> RspStatus {
RspStatus::internal(err.to_string()) RspStatus::internal(err.to_string())
} }

View file

@ -16,7 +16,10 @@ use tonic::transport::Server;
use tracing::info; use tracing::info;
use crate::{ use crate::{
daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, daemon::rpc::grpc_defs::{
networks_server::NetworksServer, tailnet_control_server::TailnetControlServer,
tunnel_server::TunnelServer,
},
database::get_connection, database::get_connection,
}; };
@ -36,9 +39,11 @@ pub async fn daemon_main(
let uds = UnixListener::bind(sock_path)?; let uds = UnixListener::bind(sock_path)?;
let serve_job = tokio::spawn(async move { let serve_job = tokio::spawn(async move {
let uds_stream = UnixListenerStream::new(uds); let uds_stream = UnixListenerStream::new(uds);
let tailnet_server = burrow_server.clone();
let _srv = Server::builder() let _srv = Server::builder()
.add_service(TunnelServer::new(burrow_server.clone())) .add_service(TunnelServer::new(burrow_server.clone()))
.add_service(NetworksServer::new(burrow_server)) .add_service(NetworksServer::new(burrow_server))
.add_service(TailnetControlServer::new(tailnet_server))
.serve_with_incoming(uds_stream) .serve_with_incoming(uds_stream)
.await?; .await?;
Ok::<(), AhError>(()) Ok::<(), AhError>(())

View file

@ -5,11 +5,15 @@ use tokio::net::UnixStream;
use tonic::transport::{Endpoint, Uri}; use tonic::transport::{Endpoint, Uri};
use tower::service_fn; use tower::service_fn;
use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; use super::grpc_defs::{
networks_client::NetworksClient, tailnet_control_client::TailnetControlClient,
tunnel_client::TunnelClient,
};
use crate::daemon::get_socket_path; use crate::daemon::get_socket_path;
pub struct BurrowClient<T> { pub struct BurrowClient<T> {
pub networks_client: NetworksClient<T>, pub networks_client: NetworksClient<T>,
pub tailnet_client: TailnetControlClient<T>,
pub tunnel_client: TunnelClient<T>, pub tunnel_client: TunnelClient<T>,
} }
@ -31,9 +35,11 @@ impl BurrowClient<tonic::transport::Channel> {
})) }))
.await?; .await?;
let nw_client = NetworksClient::new(channel.clone()); let nw_client = NetworksClient::new(channel.clone());
let tailnet_client = TailnetControlClient::new(channel.clone());
let tun_client = TunnelClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone());
Ok(BurrowClient { Ok(BurrowClient {
networks_client: nw_client, networks_client: nw_client,
tailnet_client,
tunnel_client: tun_client, tunnel_client: tun_client,
}) })
} }

View file

@ -17,6 +17,11 @@ service Networks {
rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); rpc NetworkDelete (NetworkDeleteRequest) returns (Empty);
} }
service TailnetControl {
rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse);
rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse);
}
message NetworkReorderRequest { message NetworkReorderRequest {
int32 id = 1; int32 id = 1;
int32 index = 2; int32 index = 2;
@ -56,6 +61,29 @@ message Empty {
} }
message TailnetDiscoverRequest {
string email = 1;
}
message TailnetDiscoverResponse {
string domain = 1;
string authority = 2;
string oidc_issuer = 3;
bool managed = 4;
}
message TailnetProbeRequest {
string authority = 1;
}
message TailnetProbeResponse {
string authority = 1;
int32 status_code = 2;
string summary = 3;
string detail = 4;
bool reachable = 5;
}
enum State { enum State {
Stopped = 0; Stopped = 0;
Running = 1; Running = 1;