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() {
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else {
guard !didRunAutomation,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
else {
return
}
didRunAutomation = true
@ -324,6 +327,9 @@ private struct ConfigurationSheetView: View {
@State private var errorMessage: String?
@State private var loginSessionID: String?
@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 didRunAutomation = false
@State private var webAuthenticationTask: Task<Void, Never>?
@ -437,6 +443,12 @@ private struct ConfigurationSheetView: View {
.onAppear {
runAutomationIfNeeded()
}
.onChange(of: draft.tailnetProvider) { _, _ in
resetAuthorityProbe()
}
.onChange(of: draft.authority) { _, _ in
resetAuthorityProbe()
}
.onDisappear {
pollingTask?.cancel()
webAuthenticationTask?.cancel()
@ -460,6 +472,24 @@ private struct ConfigurationSheetView: View {
TextField("Server URL", text: $draft.authority)
.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")
@ -527,6 +557,28 @@ private struct ConfigurationSheetView: View {
.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 {
HStack(spacing: 8) {
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 {
Text(label)
.font(.caption.weight(.medium))
@ -914,23 +994,30 @@ private struct ConfigurationSheetView: View {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin
automation.action == .tailnetLogin || automation.action == .headscaleProbe
else {
return
}
didRunAutomation = true
draft.tailnetProvider = .tailscale
draft.title = automation.title ?? draft.title
draft.accountName = automation.accountName ?? draft.accountName
draft.identityName = automation.identityName ?? draft.identityName
draft.hostname = automation.hostname ?? draft.hostname
Task { @MainActor in
do {
try await startTailscaleLogin()
} catch {
errorMessage = error.localizedDescription
switch automation.action {
case .tailnetLogin:
draft.tailnetProvider = .tailscale
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() {
guard let clipboardString else { return }
draft.wireGuardConfig = clipboardString
@ -1228,6 +1345,7 @@ private extension View {
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
case headscaleProbe = "headscale-probe"
}
let action: Action
@ -1235,6 +1353,7 @@ private struct BurrowAutomationConfig {
let accountName: String?
let identityName: String?
let hostname: String?
let authority: String?
static let current: BurrowAutomationConfig? = {
let environment = ProcessInfo.processInfo.environment
@ -1249,7 +1368,8 @@ private struct BurrowAutomationConfig {
title: environment["BURROW_UI_AUTOMATION_TITLE"],
accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"],
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
}
struct TailnetAuthorityProbeStatus: Sendable {
var authority: String
var statusCode: Int
var summary: String
var detail: String?
}
enum TailnetBridgeClient {
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 {
case server(String)
@ -253,7 +320,9 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
switch self {
case .tailscale:
"https://controlplane.tailscale.com"
case .headscale, .burrow:
case .headscale:
"https://ts.burrow.net"
case .burrow:
nil
}
}