diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index ce93231..835510d 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -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? @State private var didRunAutomation = false @State private var webAuthenticationTask: Task? @@ -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"] ) }() } diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index f38ab26..71e5bca 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -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 } }