Route Tailnet Apple flows through daemon gRPC
This commit is contained in:
parent
f6a7f0922d
commit
d1e28b8817
8 changed files with 565 additions and 520 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
|
import Foundation
|
||||||
import GRPC
|
import GRPC
|
||||||
import NIOTransportServices
|
import NIOTransportServices
|
||||||
|
import SwiftProtobuf
|
||||||
|
|
||||||
public typealias TunnelClient = Burrow_TunnelAsyncClient
|
public typealias TunnelClient = Burrow_TunnelAsyncClient
|
||||||
public typealias NetworksClient = Burrow_NetworksAsyncClient
|
public typealias NetworksClient = Burrow_NetworksAsyncClient
|
||||||
|
|
@ -30,3 +32,199 @@ extension NetworksClient: Client {
|
||||||
self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none)
|
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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import AuthenticationServices
|
|
||||||
import BurrowConfiguration
|
import BurrowConfiguration
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
@ -204,7 +203,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: .wireGuard
|
case .wireGuard: .wireGuard
|
||||||
case .tor: .tor
|
case .tor: .tor
|
||||||
case .tailnet: .headscale
|
case .tailnet: .tailnet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,13 +284,12 @@ private struct AccountDraft {
|
||||||
var wireGuardConfig = ""
|
var wireGuardConfig = ""
|
||||||
|
|
||||||
var discoveryEmail = ""
|
var discoveryEmail = ""
|
||||||
var tailnetProvider: TailnetProvider = .tailscale
|
|
||||||
var authority = ""
|
var authority = ""
|
||||||
var tailnet = ""
|
var tailnet = ""
|
||||||
var hostname = ProcessInfo.processInfo.hostName
|
var hostname = ProcessInfo.processInfo.hostName
|
||||||
var username = ""
|
var username = ""
|
||||||
var secret = ""
|
var secret = ""
|
||||||
var authMode: AccountAuthMode = .web
|
var authMode: AccountAuthMode = .none
|
||||||
|
|
||||||
var torAddresses = "100.64.0.2/32"
|
var torAddresses = "100.64.0.2/32"
|
||||||
var torDNS = "1.1.1.1, 1.0.0.1"
|
var torDNS = "1.1.1.1, 1.0.0.1"
|
||||||
|
|
@ -317,7 +315,6 @@ private struct AccountDraft {
|
||||||
|
|
||||||
private struct ConfigurationSheetView: View {
|
private struct ConfigurationSheetView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
|
|
||||||
|
|
||||||
let sheet: ConfigurationSheet
|
let sheet: ConfigurationSheet
|
||||||
let networkViewModel: NetworkViewModel
|
let networkViewModel: NetworkViewModel
|
||||||
|
|
@ -326,17 +323,13 @@ private struct ConfigurationSheetView: View {
|
||||||
@State private var draft: AccountDraft
|
@State private var draft: AccountDraft
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var loginSessionID: String?
|
|
||||||
@State private var loginStatus: TailnetLoginStatus?
|
|
||||||
@State private var discoveryStatus: TailnetDiscoveryResponse?
|
@State private var discoveryStatus: TailnetDiscoveryResponse?
|
||||||
@State private var discoveryError: String?
|
@State private var discoveryError: String?
|
||||||
@State private var isDiscoveringTailnet = false
|
@State private var isDiscoveringTailnet = false
|
||||||
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
|
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
|
||||||
@State private var authorityProbeError: String?
|
@State private var authorityProbeError: String?
|
||||||
@State private var isProbingAuthority = false
|
@State private var isProbingAuthority = false
|
||||||
@State private var pollingTask: Task<Void, Never>?
|
|
||||||
@State private var didRunAutomation = false
|
@State private var didRunAutomation = false
|
||||||
@State private var webAuthenticationTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
sheet: ConfigurationSheet,
|
sheet: ConfigurationSheet,
|
||||||
|
|
@ -447,20 +440,12 @@ private struct ConfigurationSheetView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
runAutomationIfNeeded()
|
runAutomationIfNeeded()
|
||||||
}
|
}
|
||||||
.onChange(of: draft.tailnetProvider) { _, _ in
|
|
||||||
resetAuthorityProbe()
|
|
||||||
}
|
|
||||||
.onChange(of: draft.authority) { _, _ in
|
.onChange(of: draft.authority) { _, _ in
|
||||||
resetAuthorityProbe()
|
resetAuthorityProbe()
|
||||||
}
|
}
|
||||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
|
||||||
pollingTask?.cancel()
|
|
||||||
webAuthenticationTask?.cancel()
|
|
||||||
webAuthenticationTask = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -490,26 +475,14 @@ private struct ConfigurationSheetView: View {
|
||||||
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker(
|
TextField("Authority URL", text: $draft.authority)
|
||||||
"Provider",
|
|
||||||
selection: Binding(
|
|
||||||
get: { draft.tailnetProvider },
|
|
||||||
set: { applyTailnetProvider($0) }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ForEach(TailnetProvider.allCases) { provider in
|
|
||||||
Text(provider.title).tag(provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
tailnetProviderCard
|
|
||||||
|
|
||||||
if draft.tailnetProvider.requiresControlURL {
|
|
||||||
TextField("Server URL", text: $draft.authority)
|
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
} label: {
|
} label: {
|
||||||
|
|
@ -527,12 +500,6 @@ private struct ConfigurationSheetView: View {
|
||||||
} else if let authorityProbeError {
|
} else if let authorityProbeError {
|
||||||
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
|
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LabeledContent("Server") {
|
|
||||||
Text("Tailscale managed")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("Tailnet", text: $draft.tailnet)
|
TextField("Tailnet", text: $draft.tailnet)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
|
|
@ -540,9 +507,6 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
if tailnetUsesWebLogin {
|
|
||||||
tailnetWebLoginCard
|
|
||||||
} else {
|
|
||||||
TextField("Username", text: $draft.username)
|
TextField("Username", text: $draft.username)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
@ -558,12 +522,11 @@ private struct ConfigurationSheetView: View {
|
||||||
text: $draft.secret
|
text: $draft.secret
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.")
|
Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var sheetSummaryCard: some View {
|
private var sheetSummaryCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
@ -618,10 +581,8 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
if sheet == .tailnet {
|
if sheet == .tailnet {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
summaryBadge(draft.tailnetProvider.title)
|
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
||||||
summaryBadge(
|
summaryBadge(draft.authMode.title)
|
||||||
tailnetUsesWebLogin ? "Web Sign-In" : 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(
|
private func tailnetAuthorityProbeCard(
|
||||||
status: TailnetAuthorityProbeStatus?,
|
status: TailnetAuthorityProbeStatus?,
|
||||||
failure: String?
|
failure: String?
|
||||||
|
|
@ -739,12 +627,15 @@ private struct ConfigurationSheetView: View {
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if let status {
|
if let status {
|
||||||
Text("Discovered \(status.provider.title)")
|
Text("Discovered Tailnet Server")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
Text(status.authority)
|
Text(status.authority)
|
||||||
.font(.footnote.monospaced())
|
.font(.footnote.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
Text(status.provider == .tailscale ? "Managed authority" : "Custom authority")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
if let oidcIssuer = status.oidcIssuer {
|
if let oidcIssuer = status.oidcIssuer {
|
||||||
Text("OIDC: \(oidcIssuer)")
|
Text("OIDC: \(oidcIssuer)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|
@ -826,12 +717,8 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
Menu("Provider") {
|
Button("Use Tailscale Managed Server") {
|
||||||
ForEach(TailnetProvider.allCases) { provider in
|
applyTailnetDefaults(for: .tailscale)
|
||||||
Button(provider.title) {
|
|
||||||
applyTailnetProvider(provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if availableTailnetAuthModes.count > 1 {
|
if availableTailnetAuthModes.count > 1 {
|
||||||
|
|
@ -839,7 +726,7 @@ private struct ConfigurationSheetView: View {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
Button(mode.title) {
|
Button(mode.title) {
|
||||||
draft.authMode = mode
|
draft.authMode = mode
|
||||||
if mode == .none || mode == .web {
|
if mode == .none {
|
||||||
draft.secret = ""
|
draft.secret = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -847,8 +734,8 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Restore Provider Defaults") {
|
Button("Clear Discovery Result") {
|
||||||
applyTailnetDefaults(for: draft.tailnetProvider)
|
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 {
|
private var showsBottomActionButton: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
true
|
true
|
||||||
|
|
@ -920,9 +796,6 @@ private struct ConfigurationSheetView: View {
|
||||||
case .tor:
|
case .tor:
|
||||||
return "Save Account"
|
return "Save Account"
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
if tailnetUsesWebLogin {
|
|
||||||
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
|
|
||||||
}
|
|
||||||
return "Save Account"
|
return "Save Account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -937,12 +810,9 @@ private struct ConfigurationSheetView: View {
|
||||||
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
|
if normalizedOptional(draft.authority) == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if tailnetUsesWebLogin {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -1027,41 +897,12 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitTailnet() async throws {
|
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 secret = draft.authMode == .none ? nil : draft.secret
|
||||||
let username = normalizedOptional(draft.username)
|
let username = normalizedOptional(draft.username)
|
||||||
try await saveTailnetAccount(secret: secret, username: username)
|
try await saveTailnetAccount(secret: secret, username: username)
|
||||||
dismiss()
|
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() {
|
private func runAutomationIfNeeded() {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
sheet == .tailnet,
|
sheet == .tailnet,
|
||||||
|
|
@ -1080,79 +921,19 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch automation.action {
|
switch automation.action {
|
||||||
case .tailnetLogin:
|
case .tailnetLogin:
|
||||||
draft.tailnetProvider = .tailscale
|
applyTailnetDefaults(for: .tailscale)
|
||||||
do {
|
probeTailnetAuthority()
|
||||||
try await startTailnetLogin()
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
case .headscaleProbe:
|
case .headscaleProbe:
|
||||||
applyTailnetProvider(.headscale)
|
|
||||||
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
||||||
probeTailnetAuthority()
|
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 {
|
private func saveTailnetAccount(secret: String?, username: String?) async throws {
|
||||||
let provider = draft.tailnetProvider
|
let provider = inferredTailnetProvider
|
||||||
let title = titleOrFallback(
|
let title = titleOrFallback(
|
||||||
hostnameFallback(
|
hostnameFallback(from: draft.authority, fallback: "Tailnet")
|
||||||
from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
|
|
||||||
fallback: provider.title
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let payload = TailnetNetworkPayload(
|
let payload = TailnetNetworkPayload(
|
||||||
|
|
@ -1160,22 +941,14 @@ private struct ConfigurationSheetView: View {
|
||||||
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
|
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
|
||||||
account: normalized(draft.accountName, fallback: "default"),
|
account: normalized(draft.accountName, fallback: "default"),
|
||||||
identity: normalized(draft.identityName, fallback: "apple"),
|
identity: normalized(draft.identityName, fallback: "apple"),
|
||||||
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
|
tailnet: normalizedOptional(draft.tailnet),
|
||||||
hostname: normalizedOptional(draft.hostname)
|
hostname: normalizedOptional(draft.hostname)
|
||||||
)
|
)
|
||||||
|
|
||||||
var noteParts: [String] = [
|
var noteParts: [String] = [
|
||||||
provider.title,
|
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
|
||||||
tailnetUsesWebLogin
|
"Auth: \(draft.authMode.title)",
|
||||||
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
|
|
||||||
: "Auth: \(draft.authMode.title)",
|
|
||||||
]
|
]
|
||||||
if let dnsName = loginStatus?.selfDNSName {
|
|
||||||
noteParts.append("Device: \(dnsName)")
|
|
||||||
}
|
|
||||||
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
|
|
||||||
noteParts.append("MagicDNS: \(magicDNSSuffix)")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
|
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
|
||||||
|
|
@ -1186,7 +959,7 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
let record = NetworkAccountRecord(
|
let record = NetworkAccountRecord(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .headscale,
|
kind: .tailnet,
|
||||||
title: title,
|
title: title,
|
||||||
authority: payload.authority,
|
authority: payload.authority,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
|
@ -1195,7 +968,7 @@ private struct ConfigurationSheetView: View {
|
||||||
hostname: payload.hostname,
|
hostname: payload.hostname,
|
||||||
username: username,
|
username: username,
|
||||||
tailnet: payload.tailnet,
|
tailnet: payload.tailnet,
|
||||||
authMode: tailnetUsesWebLogin ? .web : draft.authMode,
|
authMode: draft.authMode,
|
||||||
note: noteParts.joined(separator: " • "),
|
note: noteParts.joined(separator: " • "),
|
||||||
createdAt: .now,
|
createdAt: .now,
|
||||||
updatedAt: .now
|
updatedAt: .now
|
||||||
|
|
@ -1226,33 +999,15 @@ private struct ConfigurationSheetView: View {
|
||||||
draft.torListen = defaults.torListen
|
draft.torListen = defaults.torListen
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTailnetProvider(_ provider: TailnetProvider) {
|
|
||||||
resetTailnetDiscoveryFeedback()
|
|
||||||
draft.tailnetProvider = provider
|
|
||||||
applyTailnetDefaults(for: provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||||
|
resetTailnetDiscoveryFeedback()
|
||||||
draft.authority = provider.defaultAuthority ?? ""
|
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) {
|
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||||
draft.authMode = provider.supportsWebLogin ? .web : .none
|
|
||||||
}
|
|
||||||
if draft.authMode == .web && !provider.supportsWebLogin {
|
|
||||||
draft.authMode = .none
|
draft.authMode = .none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func probeTailnetAuthority() {
|
private func probeTailnetAuthority() {
|
||||||
guard draft.tailnetProvider.requiresControlURL else { return }
|
|
||||||
guard let authority = normalizedOptional(draft.authority) else {
|
guard let authority = normalizedOptional(draft.authority) else {
|
||||||
authorityProbeStatus = nil
|
authorityProbeStatus = nil
|
||||||
authorityProbeError = "Enter a server URL first."
|
authorityProbeError = "Enter a server URL first."
|
||||||
|
|
@ -1266,10 +1021,7 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isProbingAuthority = false }
|
defer { isProbingAuthority = false }
|
||||||
do {
|
do {
|
||||||
authorityProbeStatus = try await TailnetAuthorityProbeClient.probe(
|
authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority)
|
||||||
provider: draft.tailnetProvider,
|
|
||||||
authority: authority
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
authorityProbeError = error.localizedDescription
|
authorityProbeError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
@ -1300,15 +1052,9 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isDiscoveringTailnet = false }
|
defer { isDiscoveringTailnet = false }
|
||||||
do {
|
do {
|
||||||
let discovery = try await TailnetDiscoveryClient.discover(email: email)
|
let discovery = try await networkViewModel.discoverTailnet(email: email)
|
||||||
discoveryStatus = discovery
|
discoveryStatus = discovery
|
||||||
draft.tailnetProvider = discovery.provider
|
|
||||||
draft.authority = discovery.authority
|
draft.authority = discovery.authority
|
||||||
if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil {
|
|
||||||
draft.authMode = .web
|
|
||||||
draft.username = ""
|
|
||||||
draft.secret = ""
|
|
||||||
}
|
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
} catch {
|
} catch {
|
||||||
discoveryError = error.localizedDescription
|
discoveryError = error.localizedDescription
|
||||||
|
|
@ -1361,19 +1107,19 @@ private struct ConfigurationSheetView: View {
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tailnetUsesWebLogin: Bool {
|
|
||||||
draft.authMode == .web && draft.tailnetProvider.supportsWebLogin
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableTailnetAuthModes: [AccountAuthMode] {
|
private var availableTailnetAuthModes: [AccountAuthMode] {
|
||||||
switch draft.tailnetProvider {
|
|
||||||
case .tailscale:
|
|
||||||
[.web]
|
|
||||||
case .headscale:
|
|
||||||
[.web, .none, .password, .preauthKey]
|
|
||||||
case .burrow:
|
|
||||||
[.none, .password, .preauthKey]
|
[.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
|
@ViewBuilder
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
struct TailnetDiscoveryResponse: Codable, Sendable {
|
||||||
var domain: String
|
var domain: String
|
||||||
var provider: TailnetProvider
|
var provider: TailnetProvider
|
||||||
|
|
@ -40,23 +33,6 @@ struct TailnetDiscoveryResponse: Codable, Sendable {
|
||||||
var oidcIssuer: String?
|
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 {
|
struct TailnetAuthorityProbeStatus: Sendable {
|
||||||
var authority: String
|
var authority: String
|
||||||
var statusCode: Int
|
var statusCode: Int
|
||||||
|
|
@ -64,148 +40,38 @@ struct TailnetAuthorityProbeStatus: Sendable {
|
||||||
var detail: String?
|
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 {
|
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 {
|
let response = try await TailnetClient.unix(socketURL: socketURL).discover(request)
|
||||||
guard var components = URLComponents(
|
return TailnetDiscoveryResponse(
|
||||||
url: baseURL.appendingPathComponent("v1/tailnet/discover"),
|
domain: response.domain,
|
||||||
resolvingAgainstBaseURL: false
|
provider: response.managed ? .tailscale : .headscale,
|
||||||
) else {
|
authority: response.authority,
|
||||||
throw URLError(.badURL)
|
oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
? nil
|
||||||
components.queryItems = [
|
: response.oidcIssuer
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TailnetAuthorityProbeClient {
|
enum TailnetAuthorityProbeClient {
|
||||||
static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus {
|
static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus {
|
||||||
let normalizedAuthority = normalizeAuthority(authority)
|
var request = Burrow_TailnetProbeRequest()
|
||||||
let baseURL = try validatedBaseURL(normalizedAuthority)
|
request.authority = authority
|
||||||
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 }
|
|
||||||
|
|
||||||
|
let response = try await TailnetClient.unix(socketURL: socketURL).probe(request)
|
||||||
return TailnetAuthorityProbeStatus(
|
return TailnetAuthorityProbeStatus(
|
||||||
authority: normalizedAuthority,
|
authority: response.authority,
|
||||||
statusCode: http.statusCode,
|
statusCode: Int(response.statusCode),
|
||||||
summary: "\(provider.title) reachable",
|
summary: response.summary,
|
||||||
detail: detail
|
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
|
@Observable
|
||||||
|
|
@ -215,7 +81,7 @@ final class NetworkViewModel: Sendable {
|
||||||
private(set) var connectionError: String?
|
private(set) var connectionError: String?
|
||||||
private let socketURLResult: Result<URL, Error>
|
private let socketURLResult: Result<URL, Error>
|
||||||
|
|
||||||
nonisolated(unsafe) private var task: Task<Void, Never>?
|
@ObservationIgnored private var task: Task<Void, Never>?
|
||||||
|
|
||||||
init(socketURLResult: Result<URL, Error>) {
|
init(socketURLResult: Result<URL, Error>) {
|
||||||
self.socketURLResult = socketURLResult
|
self.socketURLResult = socketURLResult
|
||||||
|
|
@ -242,6 +108,16 @@ final class NetworkViewModel: Sendable {
|
||||||
try await addNetwork(type: .tailnet, payload: payload.encoded())
|
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 {
|
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
|
||||||
let socketURL = try socketURLResult.get()
|
let socketURL = try socketURLResult.get()
|
||||||
let networkID = nextNetworkID
|
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? {
|
var defaultAuthority: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale:
|
case .tailscale:
|
||||||
|
|
@ -368,19 +231,44 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale:
|
case .tailscale:
|
||||||
"Use Tailscale's real browser login flow."
|
"Managed Tailnet authority."
|
||||||
case .headscale:
|
case .headscale:
|
||||||
"Use your Headscale control plane with browser or key-based sign-in."
|
"Custom Tailnet control server."
|
||||||
case .burrow:
|
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 {
|
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
case wireGuard
|
case wireGuard
|
||||||
case tor
|
case tor
|
||||||
case headscale
|
case tailnet
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
|
@ -388,7 +276,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "WireGuard"
|
case .wireGuard: "WireGuard"
|
||||||
case .tor: "Tor"
|
case .tor: "Tor"
|
||||||
case .headscale: "Tailnet"
|
case .tailnet: "Tailnet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,7 +284,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||||
case .tor: "Store Arti account and identity preferences."
|
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 {
|
switch self {
|
||||||
case .wireGuard: .init("WireGuard")
|
case .wireGuard: .init("WireGuard")
|
||||||
case .tor: .orange
|
case .tor: .orange
|
||||||
case .headscale: .mint
|
case .tailnet: .mint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,7 +300,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Add Network"
|
case .wireGuard: "Add Network"
|
||||||
case .tor: "Save Account"
|
case .tor: "Save Account"
|
||||||
case .headscale: "Save Account"
|
case .tailnet: "Save Account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -422,7 +310,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
nil
|
nil
|
||||||
case .tor:
|
case .tor:
|
||||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
"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."
|
"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 {
|
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
case none
|
case none
|
||||||
case web
|
|
||||||
case password
|
case password
|
||||||
case preauthKey
|
case preauthKey
|
||||||
|
|
||||||
|
|
@ -439,7 +326,6 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none: "None"
|
case .none: "None"
|
||||||
case .web: "Web Login"
|
|
||||||
case .password: "Password"
|
case .password: "Password"
|
||||||
case .preauthKey: "Preauth Key"
|
case .preauthKey: "Preauth Key"
|
||||||
}
|
}
|
||||||
|
|
@ -465,17 +351,15 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
|
||||||
struct TailnetCard {
|
struct TailnetCard {
|
||||||
var id: Int32
|
var id: Int32
|
||||||
var provider: String
|
|
||||||
var title: String
|
var title: String
|
||||||
var detail: String
|
var detail: String
|
||||||
|
|
||||||
init(network: Burrow_Network) {
|
init(network: Burrow_Network) {
|
||||||
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
|
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
|
||||||
id = network.id
|
id = network.id
|
||||||
provider = payload?.provider.title ?? "Tailnet"
|
|
||||||
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
|
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
|
||||||
detail = [
|
detail = [
|
||||||
payload?.provider.title,
|
payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority,
|
||||||
payload?.authority,
|
payload?.authority,
|
||||||
payload.map { "Account: \($0.account)" },
|
payload.map { "Account: \($0.account)" },
|
||||||
]
|
]
|
||||||
|
|
@ -492,7 +376,7 @@ struct TailnetCard {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(provider)
|
Text("Tailnet")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use super::TailnetProvider;
|
||||||
pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server";
|
pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server";
|
||||||
const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet";
|
const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet";
|
||||||
const WEBFINGER_PATH: &str = "/.well-known/webfinger";
|
const WEBFINGER_PATH: &str = "/.well-known/webfinger";
|
||||||
|
const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TailnetDiscovery {
|
pub struct TailnetDiscovery {
|
||||||
|
|
@ -17,6 +18,15 @@ pub struct TailnetDiscovery {
|
||||||
pub oidc_issuer: Option<String>,
|
pub oidc_issuer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
struct WebFingerDocument {
|
struct WebFingerDocument {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -43,6 +53,63 @@ pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
||||||
discover_tailnet_at(&client, email, &base_url).await
|
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<TailnetAuthorityProbe> {
|
||||||
|
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(
|
pub async fn discover_tailnet_at(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
email: &str,
|
email: &str,
|
||||||
|
|
@ -57,7 +124,7 @@ pub async fn discover_tailnet_at(
|
||||||
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
||||||
return Ok(TailnetDiscovery {
|
return Ok(TailnetDiscovery {
|
||||||
domain,
|
domain,
|
||||||
provider: TailnetProvider::Headscale,
|
provider: inferred_provider(Some(&authority), None),
|
||||||
authority,
|
authority,
|
||||||
oidc_issuer: None,
|
oidc_issuer: None,
|
||||||
});
|
});
|
||||||
|
|
@ -78,6 +145,19 @@ pub fn email_domain(email: &str) -> Result<String> {
|
||||||
Ok(domain)
|
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<Option<TailnetDiscovery>> {
|
async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<TailnetDiscovery>> {
|
||||||
let url = base_url
|
let url = base_url
|
||||||
.join(TAILNET_DISCOVERY_PATH)
|
.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<Option<TailnetAuthorityProbe>> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
|
|
@ -147,6 +258,13 @@ mod tests {
|
||||||
assert!(email_domain("contact").is_err());
|
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]
|
#[tokio::test]
|
||||||
async fn discovers_from_well_known_document() -> Result<()> {
|
async fn discovers_from_well_known_document() -> Result<()> {
|
||||||
let router = Router::new().route(
|
let router = Router::new().route(
|
||||||
|
|
@ -209,4 +327,20 @@ mod tests {
|
||||||
server.abort();
|
server.abort();
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,16 @@ use tun::tokio::TunInterface;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
rpc::grpc_defs::{
|
rpc::grpc_defs::{
|
||||||
networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest,
|
networks_server::Networks, tailnet_control_server::TailnetControl,
|
||||||
NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState,
|
tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse,
|
||||||
|
NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest,
|
||||||
|
TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse,
|
||||||
TunnelConfigurationResponse, TunnelStatusResponse,
|
TunnelConfigurationResponse, TunnelStatusResponse,
|
||||||
},
|
},
|
||||||
runtime::{ActiveTunnel, ResolvedTunnel},
|
runtime::{ActiveTunnel, ResolvedTunnel},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
control::discovery,
|
||||||
daemon::rpc::ServerConfig,
|
daemon::rpc::ServerConfig,
|
||||||
database::{add_network, delete_network, get_connection, list_networks, reorder_network},
|
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<TailnetDiscoverRequest>,
|
||||||
|
) -> Result<Response<TailnetDiscoverResponse>, 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<TailnetProbeRequest>,
|
||||||
|
) -> Result<Response<TailnetProbeResponse>, 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 {
|
fn proc_err(err: impl ToString) -> RspStatus {
|
||||||
RspStatus::internal(err.to_string())
|
RspStatus::internal(err.to_string())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ use tonic::transport::Server;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
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,
|
database::get_connection,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,9 +39,11 @@ pub async fn daemon_main(
|
||||||
let uds = UnixListener::bind(sock_path)?;
|
let uds = UnixListener::bind(sock_path)?;
|
||||||
let serve_job = tokio::spawn(async move {
|
let serve_job = tokio::spawn(async move {
|
||||||
let uds_stream = UnixListenerStream::new(uds);
|
let uds_stream = UnixListenerStream::new(uds);
|
||||||
|
let tailnet_server = burrow_server.clone();
|
||||||
let _srv = Server::builder()
|
let _srv = Server::builder()
|
||||||
.add_service(TunnelServer::new(burrow_server.clone()))
|
.add_service(TunnelServer::new(burrow_server.clone()))
|
||||||
.add_service(NetworksServer::new(burrow_server))
|
.add_service(NetworksServer::new(burrow_server))
|
||||||
|
.add_service(TailnetControlServer::new(tailnet_server))
|
||||||
.serve_with_incoming(uds_stream)
|
.serve_with_incoming(uds_stream)
|
||||||
.await?;
|
.await?;
|
||||||
Ok::<(), AhError>(())
|
Ok::<(), AhError>(())
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ use tokio::net::UnixStream;
|
||||||
use tonic::transport::{Endpoint, Uri};
|
use tonic::transport::{Endpoint, Uri};
|
||||||
use tower::service_fn;
|
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;
|
use crate::daemon::get_socket_path;
|
||||||
|
|
||||||
pub struct BurrowClient<T> {
|
pub struct BurrowClient<T> {
|
||||||
pub networks_client: NetworksClient<T>,
|
pub networks_client: NetworksClient<T>,
|
||||||
|
pub tailnet_client: TailnetControlClient<T>,
|
||||||
pub tunnel_client: TunnelClient<T>,
|
pub tunnel_client: TunnelClient<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,9 +35,11 @@ impl BurrowClient<tonic::transport::Channel> {
|
||||||
}))
|
}))
|
||||||
.await?;
|
.await?;
|
||||||
let nw_client = NetworksClient::new(channel.clone());
|
let nw_client = NetworksClient::new(channel.clone());
|
||||||
|
let tailnet_client = TailnetControlClient::new(channel.clone());
|
||||||
let tun_client = TunnelClient::new(channel.clone());
|
let tun_client = TunnelClient::new(channel.clone());
|
||||||
Ok(BurrowClient {
|
Ok(BurrowClient {
|
||||||
networks_client: nw_client,
|
networks_client: nw_client,
|
||||||
|
tailnet_client,
|
||||||
tunnel_client: tun_client,
|
tunnel_client: tun_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ service Networks {
|
||||||
rpc NetworkDelete (NetworkDeleteRequest) returns (Empty);
|
rpc NetworkDelete (NetworkDeleteRequest) returns (Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service TailnetControl {
|
||||||
|
rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse);
|
||||||
|
rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse);
|
||||||
|
}
|
||||||
|
|
||||||
message NetworkReorderRequest {
|
message NetworkReorderRequest {
|
||||||
int32 id = 1;
|
int32 id = 1;
|
||||||
int32 index = 2;
|
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 {
|
enum State {
|
||||||
Stopped = 0;
|
Stopped = 0;
|
||||||
Running = 1;
|
Running = 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue