Add daemon-owned Tailnet login flow

This commit is contained in:
Conrad Kramer 2026-04-03 02:09:58 -07:00
parent d1e28b8817
commit 0c660acd1e
6 changed files with 812 additions and 28 deletions

View file

@ -68,6 +68,46 @@ public struct Burrow_TailnetProbeResponse: Sendable {
public init() {} public init() {}
} }
public struct Burrow_TailnetLoginStartRequest: Sendable {
public var accountName: String = ""
public var identityName: String = ""
public var hostname: String = ""
public var authority: String = ""
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetLoginStatusRequest: Sendable {
public var sessionID: String = ""
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetLoginCancelRequest: Sendable {
public var sessionID: String = ""
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Burrow_TailnetLoginStatusResponse: Sendable {
public var sessionID: String = ""
public var backendState: String = ""
public var authURL: String = ""
public var running: Bool = false
public var needsLogin: Bool = false
public var tailnetName: String = ""
public var magicDNSSuffix: String = ""
public var selfDNSName: String = ""
public var tailnetIPs: [String] = []
public var health: [String] = []
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
@ -195,6 +235,158 @@ extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._Mes
} }
} }
extension Burrow_TailnetLoginStartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetLoginStartRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .standard(proto: "account_name"),
2: .standard(proto: "identity_name"),
3: .same(proto: "hostname"),
4: .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.accountName)
case 2: try decoder.decodeSingularStringField(value: &self.identityName)
case 3: try decoder.decodeSingularStringField(value: &self.hostname)
case 4: try decoder.decodeSingularStringField(value: &self.authority)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.accountName.isEmpty {
try visitor.visitSingularStringField(value: self.accountName, fieldNumber: 1)
}
if !self.identityName.isEmpty {
try visitor.visitSingularStringField(value: self.identityName, fieldNumber: 2)
}
if !self.hostname.isEmpty {
try visitor.visitSingularStringField(value: self.hostname, fieldNumber: 3)
}
if !self.authority.isEmpty {
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 4)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetLoginStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetLoginStatusRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .standard(proto: "session_id")
]
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.sessionID)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.sessionID.isEmpty {
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetLoginCancelRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetLoginCancelRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .standard(proto: "session_id")
]
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.sessionID)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.sessionID.isEmpty {
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetLoginStatusResponse"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .standard(proto: "session_id"),
2: .standard(proto: "backend_state"),
3: .standard(proto: "auth_url"),
4: .same(proto: "running"),
5: .standard(proto: "needs_login"),
6: .standard(proto: "tailnet_name"),
7: .standard(proto: "magic_dns_suffix"),
8: .standard(proto: "self_dns_name"),
9: .standard(proto: "tailnet_ips"),
10: .same(proto: "health"),
]
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.sessionID)
case 2: try decoder.decodeSingularStringField(value: &self.backendState)
case 3: try decoder.decodeSingularStringField(value: &self.authURL)
case 4: try decoder.decodeSingularBoolField(value: &self.running)
case 5: try decoder.decodeSingularBoolField(value: &self.needsLogin)
case 6: try decoder.decodeSingularStringField(value: &self.tailnetName)
case 7: try decoder.decodeSingularStringField(value: &self.magicDNSSuffix)
case 8: try decoder.decodeSingularStringField(value: &self.selfDNSName)
case 9: try decoder.decodeRepeatedStringField(value: &self.tailnetIPs)
case 10: try decoder.decodeRepeatedStringField(value: &self.health)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.sessionID.isEmpty {
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
}
if !self.backendState.isEmpty {
try visitor.visitSingularStringField(value: self.backendState, fieldNumber: 2)
}
if !self.authURL.isEmpty {
try visitor.visitSingularStringField(value: self.authURL, fieldNumber: 3)
}
if self.running {
try visitor.visitSingularBoolField(value: self.running, fieldNumber: 4)
}
if self.needsLogin {
try visitor.visitSingularBoolField(value: self.needsLogin, fieldNumber: 5)
}
if !self.tailnetName.isEmpty {
try visitor.visitSingularStringField(value: self.tailnetName, fieldNumber: 6)
}
if !self.magicDNSSuffix.isEmpty {
try visitor.visitSingularStringField(value: self.magicDNSSuffix, fieldNumber: 7)
}
if !self.selfDNSName.isEmpty {
try visitor.visitSingularStringField(value: self.selfDNSName, fieldNumber: 8)
}
if !self.tailnetIPs.isEmpty {
try visitor.visitRepeatedStringField(value: self.tailnetIPs, fieldNumber: 9)
}
if !self.health.isEmpty {
try visitor.visitRepeatedStringField(value: self.health, fieldNumber: 10)
}
try unknownFields.traverse(visitor: &visitor)
}
}
public struct TailnetClient: Client, GRPCClient { public struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel public let channel: GRPCChannel
public var defaultCallOptions: CallOptions public var defaultCallOptions: CallOptions
@ -227,4 +419,40 @@ public struct TailnetClient: Client, GRPCClient {
interceptors: [] interceptors: []
) )
} }
public func loginStart(
_ request: Burrow_TailnetLoginStartRequest,
callOptions: CallOptions? = nil
) async throws -> Burrow_TailnetLoginStatusResponse {
try await self.performAsyncUnaryCall(
path: "/burrow.TailnetControl/LoginStart",
request: request,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
public func loginStatus(
_ request: Burrow_TailnetLoginStatusRequest,
callOptions: CallOptions? = nil
) async throws -> Burrow_TailnetLoginStatusResponse {
try await self.performAsyncUnaryCall(
path: "/burrow.TailnetControl/LoginStatus",
request: request,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
public func loginCancel(
_ request: Burrow_TailnetLoginCancelRequest,
callOptions: CallOptions? = nil
) async throws -> Burrow_Empty {
try await self.performAsyncUnaryCall(
path: "/burrow.TailnetControl/LoginCancel",
request: request,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
} }

View file

@ -1,6 +1,9 @@
import BurrowConfiguration import BurrowConfiguration
import Foundation import Foundation
import SwiftUI import SwiftUI
#if canImport(AuthenticationServices)
import AuthenticationServices
#endif
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#elseif canImport(AppKit) #elseif canImport(AppKit)
@ -309,6 +312,7 @@ private struct AccountDraft {
accountName = "default" accountName = "default"
identityName = "apple" identityName = "apple"
authority = TailnetProvider.tailscale.defaultAuthority ?? "" authority = TailnetProvider.tailscale.defaultAuthority ?? ""
authMode = .web
} }
} }
} }
@ -329,6 +333,14 @@ private struct ConfigurationSheetView: View {
@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 tailnetLoginStatus: TailnetLoginStatus?
@State private var tailnetLoginError: String?
@State private var tailnetLoginSessionID: String?
@State private var isStartingTailnetLogin = false
@State private var tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
@State private var tailnetLoginPollTask: Task<Void, Never>?
@State private var didRunAutomation = false @State private var didRunAutomation = false
init( init(
@ -397,9 +409,12 @@ private struct ConfigurationSheetView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") {
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
dismiss() dismiss()
} }
} }
}
#if os(iOS) #if os(iOS)
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
@ -446,14 +461,28 @@ private struct ConfigurationSheetView: View {
.onChange(of: draft.discoveryEmail) { _, _ in .onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback() resetTailnetDiscoveryFeedback()
} }
.onChange(of: draft.authMode) { _, newMode in
guard newMode != .web else { return }
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
}
}
.onDisappear {
tailnetLoginPollTask?.cancel()
browserAuthenticator.cancel()
if !preserveTailnetLoginSession {
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
}
}
}
} }
@ViewBuilder @ViewBuilder
private var tailnetSections: some View { private var tailnetSections: some View {
Section("Connection") { Section("Connection") {
TextField("Email address", text: $draft.discoveryEmail) TextField("Email address", text: $draft.discoveryEmail)
.textInputAutocapitalization(.never) .burrowEmailField()
.keyboardType(.emailAddress)
.burrowLoginField() .burrowLoginField()
.autocorrectionDisabled() .autocorrectionDisabled()
@ -507,22 +536,44 @@ private struct ConfigurationSheetView: View {
} }
Section("Authentication") { Section("Authentication") {
TextField("Username", text: $draft.username)
.burrowLoginField()
.autocorrectionDisabled()
Picker("Authentication", selection: $draft.authMode) { Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode) Text(mode.title).tag(mode)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
if draft.authMode == .web {
Button {
startTailnetLogin()
} label: {
Label {
Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle)
} icon: {
Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key")
}
}
.buttonStyle(.borderless)
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
if let tailnetLoginStatus {
tailnetLoginCard(status: tailnetLoginStatus, failure: nil)
} else if let tailnetLoginError {
tailnetLoginCard(status: nil, failure: tailnetLoginError)
}
} else {
TextField("Username", text: $draft.username)
.burrowLoginField()
.autocorrectionDisabled()
if draft.authMode != .none { if draft.authMode != .none {
SecureField( SecureField(
draft.authMode == .password ? "Password" : "Preauth Key", draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret text: $draft.secret
) )
} }
Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") }
Text(tailnetAuthenticationFootnote)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -583,6 +634,9 @@ private struct ConfigurationSheetView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
summaryBadge(draft.authMode.title) summaryBadge(draft.authMode.title)
if tailnetLoginStatus?.running == true {
summaryBadge("Signed In")
}
} }
} }
} }
@ -659,6 +713,52 @@ private struct ConfigurationSheetView: View {
) )
} }
private func tailnetLoginCard(
status: TailnetLoginStatus?,
failure: String?
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In")
.font(.subheadline.weight(.medium))
if let tailnetName = status.tailnetName, !tailnetName.isEmpty {
Text("Tailnet: \(tailnetName)")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty {
Text(selfDNSName)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !status.tailnetIPs.isEmpty {
Text(status.tailnetIPs.joined(separator: ", "))
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !status.health.isEmpty {
Text(status.health.joined(separator: ""))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else if let failure {
Text("Sign-In failed")
.font(.subheadline.weight(.medium))
.foregroundStyle(.red)
Text(failure)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
private func summaryBadge(_ label: String) -> some View { private func summaryBadge(_ label: String) -> some View {
Text(label) Text(label)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
@ -813,6 +913,9 @@ private struct ConfigurationSheetView: View {
if normalizedOptional(draft.authority) == nil { if normalizedOptional(draft.authority) == nil {
return true return true
} }
if draft.authMode == .web {
return tailnetLoginStatus?.running != true
}
if draft.authMode != .none && normalizedOptional(draft.secret) == nil { if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
return true return true
} }
@ -897,8 +1000,9 @@ private struct ConfigurationSheetView: View {
} }
private func submitTailnet() async throws { private func submitTailnet() async throws {
let secret = draft.authMode == .none ? nil : draft.secret let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret
let username = normalizedOptional(draft.username) let username = normalizedOptional(draft.username)
preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true
try await saveTailnetAccount(secret: secret, username: username) try await saveTailnetAccount(secret: secret, username: username)
dismiss() dismiss()
} }
@ -922,7 +1026,7 @@ private struct ConfigurationSheetView: View {
switch automation.action { switch automation.action {
case .tailnetLogin: case .tailnetLogin:
applyTailnetDefaults(for: .tailscale) applyTailnetDefaults(for: .tailscale)
probeTailnetAuthority() startTailnetLogin()
case .headscaleProbe: case .headscaleProbe:
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority() probeTailnetAuthority()
@ -950,6 +1054,10 @@ private struct ConfigurationSheetView: View {
"Auth: \(draft.authMode.title)", "Auth: \(draft.authMode.title)",
] ]
if draft.authMode == .web, tailnetLoginStatus?.running == true {
noteParts.append("Browser sign-in complete")
}
do { do {
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
noteParts.append("Linked to daemon network #\(networkID)") noteParts.append("Linked to daemon network #\(networkID)")
@ -1003,7 +1111,36 @@ private struct ConfigurationSheetView: View {
resetTailnetDiscoveryFeedback() resetTailnetDiscoveryFeedback()
draft.authority = provider.defaultAuthority ?? "" draft.authority = provider.defaultAuthority ?? ""
if !availableTailnetAuthModes.contains(draft.authMode) { if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = .none draft.authMode = .web
}
}
private func startTailnetLogin() {
guard let authority = normalizedOptional(draft.authority) else {
tailnetLoginStatus = nil
tailnetLoginError = "Enter a server URL first."
return
}
isStartingTailnetLogin = true
tailnetLoginError = nil
preserveTailnetLoginSession = false
Task { @MainActor in
defer { isStartingTailnetLogin = false }
do {
let status = try await networkViewModel.startTailnetLogin(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: normalizedOptional(draft.hostname),
authority: authority
)
tailnetLoginSessionID = status.sessionID
updateTailnetLoginStatus(status)
beginTailnetLoginPolling(sessionID: status.sessionID)
} catch {
tailnetLoginError = error.localizedDescription
}
} }
} }
@ -1031,6 +1168,7 @@ private struct ConfigurationSheetView: View {
private func resetAuthorityProbe() { private func resetAuthorityProbe() {
authorityProbeStatus = nil authorityProbeStatus = nil
authorityProbeError = nil authorityProbeError = nil
tailnetLoginError = nil
} }
private func resetTailnetDiscoveryFeedback() { private func resetTailnetDiscoveryFeedback() {
@ -1062,6 +1200,76 @@ private struct ConfigurationSheetView: View {
} }
} }
private func beginTailnetLoginPolling(sessionID: String) {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = Task { @MainActor in
while !Task.isCancelled {
do {
let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID)
updateTailnetLoginStatus(status)
if status.running {
tailnetLoginPollTask = nil
return
}
} catch {
tailnetLoginError = error.localizedDescription
tailnetLoginPollTask = nil
return
}
try? await Task.sleep(for: .seconds(1))
}
}
}
private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) {
tailnetLoginStatus = status
tailnetLoginError = nil
tailnetLoginSessionID = status.sessionID
if status.running {
browserAuthenticator.cancel()
tailnetPresentedAuthURL = nil
return
}
guard let authURL = status.authURL else {
return
}
if tailnetPresentedAuthURL != authURL {
tailnetPresentedAuthURL = authURL
browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in
Task { @MainActor in
if tailnetLoginStatus?.running != true {
tailnetLoginSessionID = sessionID
}
}
}
}
}
private func cancelTailnetLoginIfNeeded() async {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = nil
browserAuthenticator.cancel()
tailnetPresentedAuthURL = nil
guard tailnetLoginStatus?.running != true,
let sessionID = tailnetLoginSessionID
else {
return
}
do {
try await networkViewModel.cancelTailnetLogin(sessionID: sessionID)
} catch {
tailnetLoginError = error.localizedDescription
}
tailnetLoginStatus = nil
tailnetLoginSessionID = nil
}
private func pasteWireGuardConfiguration() { private func pasteWireGuardConfiguration() {
guard let clipboardString else { return } guard let clipboardString else { return }
draft.wireGuardConfig = clipboardString draft.wireGuardConfig = clipboardString
@ -1108,7 +1316,28 @@ private struct ConfigurationSheetView: View {
} }
private var availableTailnetAuthModes: [AccountAuthMode] { private var availableTailnetAuthModes: [AccountAuthMode] {
[.none, .password, .preauthKey] [.web, .none, .password, .preauthKey]
}
private var tailnetSignInActionTitle: String {
if tailnetLoginStatus?.running == true {
return "Signed In"
}
if tailnetLoginSessionID != nil {
return "Resume Sign-In"
}
return "Start Sign-In"
}
private var tailnetAuthenticationFootnote: String {
switch draft.authMode {
case .web:
return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running."
case .none:
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
case .password, .preauthKey:
return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh."
}
} }
private var inferredTailnetProvider: TailnetProvider { private var inferredTailnetProvider: TailnetProvider {
@ -1215,7 +1444,64 @@ private extension View {
self self
#endif #endif
} }
@ViewBuilder
func burrowEmailField() -> some View {
#if os(iOS)
textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
#else
self
#endif
} }
}
#if canImport(AuthenticationServices)
@MainActor
private final class TailnetBrowserAuthenticator: NSObject {
private var session: ASWebAuthenticationSession?
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
cancel()
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in
onDismiss()
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
self.session = session
_ = session.start()
}
func cancel() {
session?.cancel()
session = nil
}
}
extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
#if canImport(AppKit)
return NSApplication.shared.keyWindow
?? NSApplication.shared.windows.first
?? ASPresentationAnchor()
#elseif canImport(UIKit)
return ASPresentationAnchor()
#else
return ASPresentationAnchor()
#endif
}
}
#else
@MainActor
private final class TailnetBrowserAuthenticator {
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
_ = url
onDismiss()
}
func cancel() {}
}
#endif
private struct BurrowAutomationConfig { private struct BurrowAutomationConfig {
enum Action: String { enum Action: String {

View file

@ -40,6 +40,19 @@ struct TailnetAuthorityProbeStatus: Sendable {
var detail: String? var detail: String?
} }
struct TailnetLoginStatus: Sendable {
var sessionID: String
var backendState: String
var authURL: URL?
var running: Bool
var needsLogin: Bool
var tailnetName: String?
var magicDNSSuffix: String?
var selfDNSName: String?
var tailnetIPs: [String]
var health: [String]
}
enum TailnetDiscoveryClient { enum TailnetDiscoveryClient {
static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
var request = Burrow_TailnetDiscoverRequest() var request = Burrow_TailnetDiscoverRequest()
@ -74,6 +87,58 @@ enum TailnetAuthorityProbeClient {
} }
} }
enum TailnetLoginClient {
static func start(
accountName: String,
identityName: String,
hostname: String?,
authority: String,
socketURL: URL
) async throws -> TailnetLoginStatus {
var request = Burrow_TailnetLoginStartRequest()
request.accountName = accountName
request.identityName = identityName
request.hostname = hostname ?? ""
request.authority = authority
let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request)
return decode(response)
}
static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus {
var request = Burrow_TailnetLoginStatusRequest()
request.sessionID = sessionID
let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request)
return decode(response)
}
static func cancel(sessionID: String, socketURL: URL) async throws {
var request = Burrow_TailnetLoginCancelRequest()
request.sessionID = sessionID
_ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request)
}
private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus {
TailnetLoginStatus(
sessionID: response.sessionID,
backendState: response.backendState,
authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)),
running: response.running,
needsLogin: response.needsLogin,
tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.tailnetName,
magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.magicDNSSuffix,
selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: response.selfDNSName,
tailnetIPs: response.tailnetIPs,
health: response.health
)
}
}
@Observable @Observable
@MainActor @MainActor
final class NetworkViewModel: Sendable { final class NetworkViewModel: Sendable {
@ -118,6 +183,32 @@ final class NetworkViewModel: Sendable {
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
} }
func startTailnetLogin(
accountName: String,
identityName: String,
hostname: String?,
authority: String
) async throws -> TailnetLoginStatus {
let socketURL = try socketURLResult.get()
return try await TailnetLoginClient.start(
accountName: accountName,
identityName: identityName,
hostname: hostname,
authority: authority,
socketURL: socketURL
)
}
func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus {
let socketURL = try socketURLResult.get()
return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL)
}
func cancelTailnetLogin(sessionID: String) async throws {
let socketURL = try socketURLResult.get()
try await TailnetLoginClient.cancel(sessionID: sessionID, 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
@ -317,6 +408,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
} }
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
case web
case none case none
case password case password
case preauthKey case preauthKey
@ -325,6 +417,7 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String { var title: String {
switch self { switch self {
case .web: "Browser Sign-In"
case .none: "None" case .none: "None"
case .password: "Password" case .password: "Password"
case .preauthKey: "Preauth Key" case .preauthKey: "Preauth Key"

View file

@ -82,12 +82,23 @@ impl TailscaleBridgeManager {
let key = session_key(&request.account_name, &request.identity_name); let key = session_key(&request.account_name, &request.identity_name);
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
let status = self.fetch_status(existing.as_ref()).await?; match self.fetch_status(existing.as_ref()).await {
Ok(status) => {
return Ok(TailscaleLoginStartResponse { return Ok(TailscaleLoginStartResponse {
session_id: existing.session_id.clone(), session_id: existing.session_id.clone(),
status, status,
}); });
} }
Err(err) => {
log::warn!(
"tailscale login session {} is stale, restarting: {err}",
existing.session_id
);
self.sessions.lock().await.remove(&key);
let _ = self.shutdown_session(existing.as_ref()).await;
}
}
}
let state_dir = state_root().join(session_dir_name(&request)); let state_dir = state_root().join(session_dir_name(&request));
tokio::fs::create_dir_all(&state_dir) tokio::fs::create_dir_all(&state_dir)
@ -155,11 +166,28 @@ impl TailscaleBridgeManager {
}; };
match session { match session {
Some(session) => self.fetch_status(session.as_ref()).await.map(Some), Some(session) => match self.fetch_status(session.as_ref()).await {
Ok(status) => Ok(Some(status)),
Err(err) => {
self.remove_session_by_id(session_id).await;
Err(err)
}
},
None => Ok(None), None => Ok(None),
} }
} }
pub async fn cancel(&self, session_id: &str) -> Result<bool> {
let session = self.remove_session_by_id(session_id).await;
match session {
Some(session) => {
self.shutdown_session(session.as_ref()).await?;
Ok(true)
}
None => Ok(false),
}
}
async fn wait_for_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> { async fn wait_for_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
let mut last_error = None; let mut last_error = None;
let mut last_status = None; let mut last_status = None;
@ -201,6 +229,38 @@ impl TailscaleBridgeManager {
.await .await
.context("invalid tailscale helper status response") .context("invalid tailscale helper status response")
} }
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
let mut sessions = self.sessions.lock().await;
let key = sessions
.iter()
.find_map(|(key, session)| (session.session_id == session_id).then(|| key.clone()))?;
sessions.remove(&key)
}
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
let _ = self
.client
.post(format!("{}/shutdown", session.listen_url))
.send()
.await;
for _ in 0..10 {
let mut child = session.child.lock().await;
if child.try_wait()?.is_some() {
return Ok(());
}
drop(child);
tokio::time::sleep(Duration::from_millis(100)).await;
}
let mut child = session.child.lock().await;
child
.start_kill()
.context("failed to kill tailscale helper")?;
let _ = child.wait().await;
Ok(())
}
} }
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> { fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
@ -249,7 +309,10 @@ fn state_root() -> PathBuf {
.join("Burrow") .join("Burrow")
.join("tailscale"); .join("tailscale");
} }
home.join(".local").join("share").join("burrow").join("tailscale") home.join(".local")
.join("share")
.join("burrow")
.join("tailscale")
} }
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {

View file

@ -13,15 +13,19 @@ use tun::tokio::TunInterface;
use super::{ use super::{
rpc::grpc_defs::{ rpc::grpc_defs::{
networks_server::Networks, tailnet_control_server::TailnetControl, networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
TunnelConfigurationResponse, TunnelStatusResponse, TunnelStatusResponse,
}, },
runtime::{ActiveTunnel, ResolvedTunnel}, runtime::{ActiveTunnel, ResolvedTunnel},
}; };
use crate::{ use crate::{
auth::server::tailscale::{
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
TailscaleLoginStatus,
},
control::discovery, 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},
@ -49,6 +53,7 @@ pub struct DaemonRPCServer {
wg_state_chan: (watch::Sender<RunState>, watch::Receiver<RunState>), wg_state_chan: (watch::Sender<RunState>, watch::Receiver<RunState>),
network_update_chan: (watch::Sender<()>, watch::Receiver<()>), network_update_chan: (watch::Sender<()>, watch::Receiver<()>),
active_tunnel: Arc<RwLock<Option<ActiveTunnel>>>, active_tunnel: Arc<RwLock<Option<ActiveTunnel>>>,
tailnet_login: TailscaleBridgeManager,
} }
impl DaemonRPCServer { impl DaemonRPCServer {
@ -59,6 +64,7 @@ impl DaemonRPCServer {
wg_state_chan: watch::channel(RunState::Idle), wg_state_chan: watch::channel(RunState::Idle),
network_update_chan: watch::channel(()), network_update_chan: watch::channel(()),
active_tunnel: Arc::new(RwLock::new(None)), active_tunnel: Arc::new(RwLock::new(None)),
tailnet_login: TailscaleBridgeManager::default(),
}) })
} }
@ -130,6 +136,11 @@ impl DaemonRPCServer {
Ok(()) Ok(())
} }
fn tailnet_control_url(authority: &str) -> Option<String> {
let authority = discovery::normalize_authority(authority);
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
}
} }
#[tonic::async_trait] #[tonic::async_trait]
@ -308,6 +319,60 @@ impl TailnetControl for DaemonRPCServer {
reachable: status.reachable, reachable: status.reachable,
})) }))
} }
async fn login_start(
&self,
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner();
let response = self
.tailnet_login
.start_login(BridgeLoginStartRequest {
account_name: request.account_name,
identity_name: request.identity_name,
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
control_url: Self::tailnet_control_url(&request.authority),
})
.await
.map_err(proc_err)?;
Ok(Response::new(tailnet_login_rsp(
response.session_id,
response.status,
)))
}
async fn login_status(
&self,
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
let request = request.into_inner();
let status = self
.tailnet_login
.status(&request.session_id)
.await
.map_err(proc_err)?;
let Some(status) = status else {
return Err(RspStatus::not_found("tailnet login session not found"));
};
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
}
async fn login_cancel(
&self,
request: Request<super::rpc::grpc_defs::TailnetLoginCancelRequest>,
) -> Result<Response<Empty>, RspStatus> {
let request = request.into_inner();
let canceled = self
.tailnet_login
.cancel(&request.session_id)
.await
.map_err(proc_err)?;
if !canceled {
return Err(RspStatus::not_found("tailnet login session not found"));
}
Ok(Response::new(Empty {}))
}
} }
fn proc_err(err: impl ToString) -> RspStatus { fn proc_err(err: impl ToString) -> RspStatus {
@ -327,3 +392,21 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse {
start: None, // TODO: Add timestamp start: None, // TODO: Add timestamp
} }
} }
fn tailnet_login_rsp(
session_id: String,
status: TailscaleLoginStatus,
) -> super::rpc::grpc_defs::TailnetLoginStatusResponse {
super::rpc::grpc_defs::TailnetLoginStatusResponse {
session_id,
backend_state: status.backend_state,
auth_url: status.auth_url.unwrap_or_default(),
running: status.running,
needs_login: status.needs_login,
tailnet_name: status.tailnet_name.unwrap_or_default(),
magic_dns_suffix: status.magic_dns_suffix.unwrap_or_default(),
self_dns_name: status.self_dns_name.unwrap_or_default(),
tailnet_ips: status.tailscale_ips,
health: status.health,
}
}

View file

@ -20,6 +20,9 @@ service Networks {
service TailnetControl { service TailnetControl {
rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse);
rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse);
rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse);
rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse);
rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty);
} }
message NetworkReorderRequest { message NetworkReorderRequest {
@ -84,6 +87,34 @@ message TailnetProbeResponse {
bool reachable = 5; bool reachable = 5;
} }
message TailnetLoginStartRequest {
string account_name = 1;
string identity_name = 2;
string hostname = 3;
string authority = 4;
}
message TailnetLoginStatusRequest {
string session_id = 1;
}
message TailnetLoginCancelRequest {
string session_id = 1;
}
message TailnetLoginStatusResponse {
string session_id = 1;
string backend_state = 2;
string auth_url = 3;
bool running = 4;
bool needs_login = 5;
string tailnet_name = 6;
string magic_dns_suffix = 7;
string self_dns_name = 8;
repeated string tailnet_ips = 9;
repeated string health = 10;
}
enum State { enum State {
Stopped = 0; Stopped = 0;
Running = 1; Running = 1;