From d1e28b881775967fa696294bc4d3c18ebebde757 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 01:36:55 -0700 Subject: [PATCH] Route Tailnet Apple flows through daemon gRPC --- Apple/Core/Client.swift | 198 ++++++++++++++++ Apple/UI/BurrowView.swift | 406 ++++++-------------------------- Apple/UI/Networks/Network.swift | 254 ++++++-------------- burrow/src/control/discovery.rs | 136 ++++++++++- burrow/src/daemon/instance.rs | 48 +++- burrow/src/daemon/mod.rs | 7 +- burrow/src/daemon/rpc/client.rs | 8 +- proto/burrow.proto | 28 +++ 8 files changed, 565 insertions(+), 520 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 8874e3b..c426fe7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -1,5 +1,7 @@ +import Foundation import GRPC import NIOTransportServices +import SwiftProtobuf public typealias TunnelClient = Burrow_TunnelAsyncClient public typealias NetworksClient = Burrow_NetworksAsyncClient @@ -30,3 +32,199 @@ extension NetworksClient: Client { 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(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(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(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(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(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(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(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(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: [] + ) + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index b4fa7d8..9938eef 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,4 +1,3 @@ -import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI @@ -204,7 +203,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .wireGuard case .tor: .tor - case .tailnet: .headscale + case .tailnet: .tailnet } } @@ -285,13 +284,12 @@ private struct AccountDraft { var wireGuardConfig = "" var discoveryEmail = "" - var tailnetProvider: TailnetProvider = .tailscale var authority = "" var tailnet = "" var hostname = ProcessInfo.processInfo.hostName var username = "" var secret = "" - var authMode: AccountAuthMode = .web + var authMode: AccountAuthMode = .none var torAddresses = "100.64.0.2/32" var torDNS = "1.1.1.1, 1.0.0.1" @@ -317,7 +315,6 @@ private struct AccountDraft { private struct ConfigurationSheetView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.webAuthenticationSession) private var webAuthenticationSession let sheet: ConfigurationSheet let networkViewModel: NetworkViewModel @@ -326,17 +323,13 @@ private struct ConfigurationSheetView: View { @State private var draft: AccountDraft @State private var isSubmitting = false @State private var errorMessage: String? - @State private var loginSessionID: String? - @State private var loginStatus: TailnetLoginStatus? @State private var discoveryStatus: TailnetDiscoveryResponse? @State private var discoveryError: String? @State private var isDiscoveringTailnet = false @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? init( sheet: ConfigurationSheet, @@ -447,20 +440,12 @@ private struct ConfigurationSheetView: View { .onAppear { runAutomationIfNeeded() } - .onChange(of: draft.tailnetProvider) { _, _ in - resetAuthorityProbe() - } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } - .onDisappear { - pollingTask?.cancel() - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - } } @ViewBuilder @@ -490,48 +475,30 @@ private struct ConfigurationSheetView: View { tailnetDiscoveryCard(status: nil, failure: discoveryError) } - Picker( - "Provider", - selection: Binding( - get: { draft.tailnetProvider }, - set: { applyTailnetProvider($0) } - ) - ) { - ForEach(TailnetProvider.allCases) { provider in - Text(provider.title).tag(provider) + TextField("Authority URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + + Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") + .font(.footnote) + .foregroundStyle(.secondary) + + 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 draft.tailnetProvider.requiresControlURL { - 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") - .foregroundStyle(.secondary) - } + if let authorityProbeStatus { + tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) + } else if let authorityProbeError { + tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) } TextField("Tailnet", text: $draft.tailnet) @@ -540,28 +507,24 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - if tailnetUsesWebLogin { - tailnetWebLoginCard - } else { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) - } + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + 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 { HStack(spacing: 8) { - summaryBadge(draft.tailnetProvider.title) - summaryBadge( - tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title - ) + summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") + summaryBadge(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( status: TailnetAuthorityProbeStatus?, failure: String? @@ -739,12 +627,15 @@ private struct ConfigurationSheetView: View { ) -> some View { VStack(alignment: .leading, spacing: 6) { if let status { - Text("Discovered \(status.provider.title)") + Text("Discovered Tailnet Server") .font(.subheadline.weight(.medium)) Text(status.authority) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) + Text(status.provider == .tailscale ? "Managed authority" : "Custom authority") + .font(.footnote) + .foregroundStyle(.secondary) if let oidcIssuer = status.oidcIssuer { Text("OIDC: \(oidcIssuer)") .font(.footnote) @@ -826,12 +717,8 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Menu("Provider") { - ForEach(TailnetProvider.allCases) { provider in - Button(provider.title) { - applyTailnetProvider(provider) - } - } + Button("Use Tailscale Managed Server") { + applyTailnetDefaults(for: .tailscale) } if availableTailnetAuthModes.count > 1 { @@ -839,7 +726,7 @@ private struct ConfigurationSheetView: View { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { draft.authMode = mode - if mode == .none || mode == .web { + if mode == .none { draft.secret = "" } } @@ -847,8 +734,8 @@ private struct ConfigurationSheetView: View { } } - Button("Restore Provider Defaults") { - applyTailnetDefaults(for: draft.tailnetProvider) + Button("Clear Discovery Result") { + 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 { #if os(iOS) true @@ -920,9 +796,6 @@ private struct ConfigurationSheetView: View { case .tor: return "Save Account" case .tailnet: - if tailnetUsesWebLogin { - return loginStatus?.running == true ? "Save Account" : "Start Sign-In" - } return "Save Account" } } @@ -937,12 +810,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { return true } - if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { + if normalizedOptional(draft.authority) == nil { return true } - if tailnetUsesWebLogin { - return false - } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -1027,41 +897,12 @@ private struct ConfigurationSheetView: View { } 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 username = normalizedOptional(draft.username) try await saveTailnetAccount(secret: secret, username: username) 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() { guard !didRunAutomation, sheet == .tailnet, @@ -1080,79 +921,19 @@ private struct ConfigurationSheetView: View { Task { @MainActor in switch automation.action { case .tailnetLogin: - draft.tailnetProvider = .tailscale - do { - try await startTailnetLogin() - } catch { - errorMessage = error.localizedDescription - } + applyTailnetDefaults(for: .tailscale) + probeTailnetAuthority() case .headscaleProbe: - applyTailnetProvider(.headscale) draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority 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 { - let provider = draft.tailnetProvider + let provider = inferredTailnetProvider let title = titleOrFallback( - hostnameFallback( - from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, - fallback: provider.title - ) + hostnameFallback(from: draft.authority, fallback: "Tailnet") ) let payload = TailnetNetworkPayload( @@ -1160,22 +941,14 @@ private struct ConfigurationSheetView: View { authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), account: normalized(draft.accountName, fallback: "default"), identity: normalized(draft.identityName, fallback: "apple"), - tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), + tailnet: normalizedOptional(draft.tailnet), hostname: normalizedOptional(draft.hostname) ) var noteParts: [String] = [ - provider.title, - tailnetUsesWebLogin - ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" - : "Auth: \(draft.authMode.title)", + isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", + "Auth: \(draft.authMode.title)", ] - if let dnsName = loginStatus?.selfDNSName { - noteParts.append("Device: \(dnsName)") - } - if let magicDNSSuffix = loginStatus?.magicDNSSuffix { - noteParts.append("MagicDNS: \(magicDNSSuffix)") - } do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) @@ -1186,7 +959,7 @@ private struct ConfigurationSheetView: View { let record = NetworkAccountRecord( id: UUID(), - kind: .headscale, + kind: .tailnet, title: title, authority: payload.authority, provider: provider, @@ -1195,7 +968,7 @@ private struct ConfigurationSheetView: View { hostname: payload.hostname, username: username, tailnet: payload.tailnet, - authMode: tailnetUsesWebLogin ? .web : draft.authMode, + authMode: draft.authMode, note: noteParts.joined(separator: " • "), createdAt: .now, updatedAt: .now @@ -1226,33 +999,15 @@ private struct ConfigurationSheetView: View { draft.torListen = defaults.torListen } - private func applyTailnetProvider(_ provider: TailnetProvider) { - resetTailnetDiscoveryFeedback() - draft.tailnetProvider = provider - applyTailnetDefaults(for: provider) - } - private func applyTailnetDefaults(for provider: TailnetProvider) { + resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" - loginStatus = nil - loginSessionID = nil - 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 - } + if !availableTailnetAuthModes.contains(draft.authMode) { + draft.authMode = .none } } private func probeTailnetAuthority() { - guard draft.tailnetProvider.requiresControlURL else { return } guard let authority = normalizedOptional(draft.authority) else { authorityProbeStatus = nil authorityProbeError = "Enter a server URL first." @@ -1266,10 +1021,7 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isProbingAuthority = false } do { - authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( - provider: draft.tailnetProvider, - authority: authority - ) + authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority) } catch { authorityProbeError = error.localizedDescription } @@ -1300,15 +1052,9 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isDiscoveringTailnet = false } do { - let discovery = try await TailnetDiscoveryClient.discover(email: email) + let discovery = try await networkViewModel.discoverTailnet(email: email) discoveryStatus = discovery - draft.tailnetProvider = discovery.provider draft.authority = discovery.authority - if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil { - draft.authMode = .web - draft.username = "" - draft.secret = "" - } probeTailnetAuthority() } catch { discoveryError = error.localizedDescription @@ -1361,19 +1107,19 @@ private struct ConfigurationSheetView: View { return host } - private var tailnetUsesWebLogin: Bool { - draft.authMode == .web && draft.tailnetProvider.supportsWebLogin + private var availableTailnetAuthModes: [AccountAuthMode] { + [.none, .password, .preauthKey] } - private var availableTailnetAuthModes: [AccountAuthMode] { - switch draft.tailnetProvider { - case .tailscale: - [.web] - case .headscale: - [.web, .none, .password, .preauthKey] - case .burrow: - [.none, .password, .preauthKey] - } + private var inferredTailnetProvider: TailnetProvider { + TailnetProvider.inferred( + authority: normalizedOptional(draft.authority), + explicit: discoveryStatus?.provider + ) + } + + private var isManagedTailnetAuthority: Bool { + TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority)) } @ViewBuilder diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 9a534ce..b048add 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -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 { var domain: String var provider: TailnetProvider @@ -40,23 +33,6 @@ struct TailnetDiscoveryResponse: Codable, Sendable { 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 { var authority: String var statusCode: Int @@ -64,148 +40,38 @@ struct TailnetAuthorityProbeStatus: Sendable { 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 { - 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 { - guard var components = URLComponents( - url: baseURL.appendingPathComponent("v1/tailnet/discover"), - resolvingAgainstBaseURL: false - ) else { - throw URLError(.badURL) - } - components.queryItems = [ - 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) + let response = try await TailnetClient.unix(socketURL: socketURL).discover(request) + return TailnetDiscoveryResponse( + domain: response.domain, + provider: response.managed ? .tailscale : .headscale, + authority: response.authority, + oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.oidcIssuer + ) } } 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 } + static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus { + var request = Burrow_TailnetProbeRequest() + request.authority = authority + let response = try await TailnetClient.unix(socketURL: socketURL).probe(request) return TailnetAuthorityProbeStatus( - authority: normalizedAuthority, - statusCode: http.statusCode, - summary: "\(provider.title) reachable", - detail: detail + authority: response.authority, + statusCode: Int(response.statusCode), + summary: response.summary, + 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 @@ -215,7 +81,7 @@ final class NetworkViewModel: Sendable { private(set) var connectionError: String? private let socketURLResult: Result - nonisolated(unsafe) private var task: Task? + @ObservationIgnored private var task: Task? init(socketURLResult: Result) { self.socketURLResult = socketURLResult @@ -242,6 +108,16 @@ final class NetworkViewModel: Sendable { 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 { let socketURL = try socketURLResult.get() 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? { switch self { case .tailscale: @@ -368,19 +231,44 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .tailscale: - "Use Tailscale's real browser login flow." + "Managed Tailnet authority." case .headscale: - "Use your Headscale control plane with browser or key-based sign-in." + "Custom Tailnet control server." 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 { case wireGuard case tor - case headscale + case tailnet var id: String { rawValue } @@ -388,7 +276,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "WireGuard" case .tor: "Tor" - case .headscale: "Tailnet" + case .tailnet: "Tailnet" } } @@ -396,7 +284,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." 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 { case .wireGuard: .init("WireGuard") case .tor: .orange - case .headscale: .mint + case .tailnet: .mint } } @@ -412,7 +300,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Add Network" case .tor: "Save Account" - case .headscale: "Save Account" + case .tailnet: "Save Account" } } @@ -422,7 +310,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { nil case .tor: "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." } } @@ -430,7 +318,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { case none - case web case password case preauthKey @@ -439,7 +326,6 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .none: "None" - case .web: "Web Login" case .password: "Password" case .preauthKey: "Preauth Key" } @@ -465,17 +351,15 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { struct TailnetCard { var id: Int32 - var provider: String var title: String var detail: String init(network: Burrow_Network) { let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) id = network.id - provider = payload?.provider.title ?? "Tailnet" title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" detail = [ - payload?.provider.title, + payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority, payload?.authority, payload.map { "Account: \($0.account)" }, ] @@ -492,7 +376,7 @@ struct TailnetCard { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { - Text(provider) + Text("Tailnet") .font(.headline) .foregroundStyle(.white.opacity(0.85)) Text(title) diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 28b48bb..5fc7add 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -7,6 +7,7 @@ use super::TailnetProvider; pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; const WEBFINGER_PATH: &str = "/.well-known/webfinger"; +const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TailnetDiscovery { @@ -17,6 +18,15 @@ pub struct TailnetDiscovery { pub oidc_issuer: Option, } +#[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)] struct WebFingerDocument { #[serde(default)] @@ -43,6 +53,63 @@ pub async fn discover_tailnet(email: &str) -> Result { 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 { + 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( client: &Client, email: &str, @@ -57,7 +124,7 @@ pub async fn discover_tailnet_at( if let Some(authority) = discover_webfinger(client, email, base_url).await? { return Ok(TailnetDiscovery { domain, - provider: TailnetProvider::Headscale, + provider: inferred_provider(Some(&authority), None), authority, oidc_issuer: None, }); @@ -78,6 +145,19 @@ pub fn email_domain(email: &str) -> Result { 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> { let url = base_url .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> { + 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)] mod tests { use axum::{routing::get, Router}; @@ -147,6 +258,13 @@ mod tests { 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] async fn discovers_from_well_known_document() -> Result<()> { let router = Router::new().route( @@ -209,4 +327,20 @@ mod tests { server.abort(); 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(()) + } } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 1eb0629..e4e6d96 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,13 +13,16 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, - NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, + networks_server::Networks, tailnet_control_server::TailnetControl, + tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, + NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, + TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + control::discovery, daemon::rpc::ServerConfig, 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, + ) -> Result, 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, + ) -> Result, 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 { RspStatus::internal(err.to_string()) } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index a016788..724e3bb 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -16,7 +16,10 @@ use tonic::transport::Server; use tracing::info; 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, }; @@ -36,9 +39,11 @@ pub async fn daemon_main( let uds = UnixListener::bind(sock_path)?; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); + let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server)) + .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; Ok::<(), AhError>(()) diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index 06a9b45..aa84c64 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -5,11 +5,15 @@ use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; 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; pub struct BurrowClient { pub networks_client: NetworksClient, + pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -31,9 +35,11 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); + let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, + tailnet_client, tunnel_client: tun_client, }) } diff --git a/proto/burrow.proto b/proto/burrow.proto index 5b5a30b..79e8976 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -17,6 +17,11 @@ service Networks { rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); } +service TailnetControl { + rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); + rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); +} + message NetworkReorderRequest { int32 id = 1; 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 { Stopped = 0; Running = 1;