Probe Headscale reachability in Apple UI
This commit is contained in:
parent
be5b7d90db
commit
fff5475914
2 changed files with 198 additions and 9 deletions
|
|
@ -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,24 +994,31 @@ 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
|
||||||
|
switch automation.action {
|
||||||
|
case .tailnetLogin:
|
||||||
|
draft.tailnetProvider = .tailscale
|
||||||
do {
|
do {
|
||||||
try await startTailscaleLogin()
|
try await startTailscaleLogin()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
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"]
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue