Probe Headscale reachability in Apple UI

This commit is contained in:
Conrad Kramer 2026-03-31 23:28:42 -07:00
parent be5b7d90db
commit fff5475914
2 changed files with 198 additions and 9 deletions

View file

@ -131,7 +131,10 @@ public struct BurrowView: View {
} }
private func runAutomationIfNeeded() { private func runAutomationIfNeeded() {
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else { guard !didRunAutomation,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
else {
return return
} }
didRunAutomation = true didRunAutomation = true
@ -324,6 +327,9 @@ private struct ConfigurationSheetView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var loginSessionID: String? @State private var loginSessionID: String?
@State private var loginStatus: TailnetLoginStatus? @State private var loginStatus: TailnetLoginStatus?
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
@State private var authorityProbeError: String?
@State private var isProbingAuthority = false
@State private var pollingTask: Task<Void, Never>? @State private var pollingTask: Task<Void, Never>?
@State private var didRunAutomation = false @State private var didRunAutomation = false
@State private var webAuthenticationTask: Task<Void, Never>? @State private var webAuthenticationTask: Task<Void, Never>?
@ -437,6 +443,12 @@ private struct ConfigurationSheetView: View {
.onAppear { .onAppear {
runAutomationIfNeeded() runAutomationIfNeeded()
} }
.onChange(of: draft.tailnetProvider) { _, _ in
resetAuthorityProbe()
}
.onChange(of: draft.authority) { _, _ in
resetAuthorityProbe()
}
.onDisappear { .onDisappear {
pollingTask?.cancel() pollingTask?.cancel()
webAuthenticationTask?.cancel() webAuthenticationTask?.cancel()
@ -460,6 +472,24 @@ private struct ConfigurationSheetView: View {
TextField("Server URL", text: $draft.authority) TextField("Server URL", text: $draft.authority)
.burrowLoginField() .burrowLoginField()
.autocorrectionDisabled() .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 { } else {
LabeledContent("Server") { LabeledContent("Server") {
Text("Tailscale managed") Text("Tailscale managed")
@ -527,6 +557,28 @@ private struct ConfigurationSheetView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if sheet == .tailnet {
if let authorityProbeStatus {
Text(authorityProbeStatus.summary)
.font(.footnote.weight(.medium))
.foregroundStyle(.primary)
if let detail = authorityProbeStatus.detail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}
} else if let authorityProbeError {
Text("Connection failed")
.font(.footnote.weight(.medium))
.foregroundStyle(.red)
Text(authorityProbeError)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
if sheet == .tailnet { if sheet == .tailnet {
HStack(spacing: 8) { HStack(spacing: 8) {
summaryBadge(draft.tailnetProvider.title) summaryBadge(draft.tailnetProvider.title)
@ -616,6 +668,34 @@ private struct ConfigurationSheetView: View {
) )
} }
private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?,
failure: String?
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
Text(status.summary)
.font(.subheadline.weight(.medium))
Text(status.detail ?? "HTTP \(status.statusCode) from \(status.authority)")
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else if let failure {
Text("Connection failed")
.font(.subheadline.weight(.medium))
.foregroundStyle(.red)
Text(failure)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
private func summaryBadge(_ label: String) -> some View { private func summaryBadge(_ label: String) -> some View {
Text(label) Text(label)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
@ -914,23 +994,30 @@ private struct ConfigurationSheetView: View {
guard !didRunAutomation, guard !didRunAutomation,
sheet == .tailnet, sheet == .tailnet,
let automation = BurrowAutomationConfig.current, let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin automation.action == .tailnetLogin || automation.action == .headscaleProbe
else { else {
return return
} }
didRunAutomation = true didRunAutomation = true
draft.tailnetProvider = .tailscale
draft.title = automation.title ?? draft.title draft.title = automation.title ?? draft.title
draft.accountName = automation.accountName ?? draft.accountName draft.accountName = automation.accountName ?? draft.accountName
draft.identityName = automation.identityName ?? draft.identityName draft.identityName = automation.identityName ?? draft.identityName
draft.hostname = automation.hostname ?? draft.hostname draft.hostname = automation.hostname ?? draft.hostname
Task { @MainActor in Task { @MainActor in
do { switch automation.action {
try await startTailscaleLogin() case .tailnetLogin:
} catch { draft.tailnetProvider = .tailscale
errorMessage = error.localizedDescription do {
try await startTailscaleLogin()
} catch {
errorMessage = error.localizedDescription
}
case .headscaleProbe:
applyTailnetProvider(.headscale)
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority()
} }
} }
} }
@ -1085,6 +1172,36 @@ private struct ConfigurationSheetView: View {
} }
} }
private func probeTailnetAuthority() {
guard draft.tailnetProvider.requiresControlURL else { return }
guard let authority = normalizedOptional(draft.authority) else {
authorityProbeStatus = nil
authorityProbeError = "Enter a server URL first."
return
}
isProbingAuthority = true
authorityProbeStatus = nil
authorityProbeError = nil
Task { @MainActor in
defer { isProbingAuthority = false }
do {
authorityProbeStatus = try await TailnetAuthorityProbeClient.probe(
provider: draft.tailnetProvider,
authority: authority
)
} catch {
authorityProbeError = error.localizedDescription
}
}
}
private func resetAuthorityProbe() {
authorityProbeStatus = nil
authorityProbeError = nil
}
private func pasteWireGuardConfiguration() { private func pasteWireGuardConfiguration() {
guard let clipboardString else { return } guard let clipboardString else { return }
draft.wireGuardConfig = clipboardString draft.wireGuardConfig = clipboardString
@ -1228,6 +1345,7 @@ private extension View {
private struct BurrowAutomationConfig { private struct BurrowAutomationConfig {
enum Action: String { enum Action: String {
case tailnetLogin = "tailnet-login" case tailnetLogin = "tailnet-login"
case headscaleProbe = "headscale-probe"
} }
let action: Action let action: Action
@ -1235,6 +1353,7 @@ private struct BurrowAutomationConfig {
let accountName: String? let accountName: String?
let identityName: String? let identityName: String?
let hostname: String? let hostname: String?
let authority: String?
static let current: BurrowAutomationConfig? = { static let current: BurrowAutomationConfig? = {
let environment = ProcessInfo.processInfo.environment let environment = ProcessInfo.processInfo.environment
@ -1249,7 +1368,8 @@ private struct BurrowAutomationConfig {
title: environment["BURROW_UI_AUTOMATION_TITLE"], title: environment["BURROW_UI_AUTOMATION_TITLE"],
accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"], accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"],
identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"], identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"],
hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"] hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"],
authority: environment["BURROW_UI_AUTOMATION_AUTHORITY"]
) )
}() }()
} }

View file

@ -50,6 +50,13 @@ struct TailnetLoginStartResponse: Codable, Sendable {
var status: TailnetLoginStatus var status: TailnetLoginStatus
} }
struct TailnetAuthorityProbeStatus: Sendable {
var authority: String
var statusCode: Int
var summary: String
var detail: String?
}
enum TailnetBridgeClient { enum TailnetBridgeClient {
private static let baseURL = URL(string: "http://127.0.0.1:8080")! private static let baseURL = URL(string: "http://127.0.0.1:8080")!
@ -97,6 +104,66 @@ enum TailnetBridgeClient {
} }
} }
enum TailnetAuthorityProbeClient {
static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus {
let normalizedAuthority = normalizeAuthority(authority)
let baseURL = try validatedBaseURL(normalizedAuthority)
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 }
return TailnetAuthorityProbeStatus(
authority: normalizedAuthority,
statusCode: http.statusCode,
summary: "\(provider.title) reachable",
detail: 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 { enum TailnetBridgeError: LocalizedError {
case server(String) case server(String)
@ -253,7 +320,9 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
switch self { switch self {
case .tailscale: case .tailscale:
"https://controlplane.tailscale.com" "https://controlplane.tailscale.com"
case .headscale, .burrow: case .headscale:
"https://ts.burrow.net"
case .burrow:
nil nil
} }
} }