From 0c660acd1e0b61dde4a3ea80643b5df9ae381623 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 02:09:58 -0700 Subject: [PATCH] Add daemon-owned Tailnet login flow --- Apple/Core/Client.swift | 228 ++++++++++++++++++++ Apple/UI/BurrowView.swift | 318 ++++++++++++++++++++++++++-- Apple/UI/Networks/Network.swift | 93 ++++++++ burrow/src/auth/server/tailscale.rs | 77 ++++++- burrow/src/daemon/instance.rs | 93 +++++++- proto/burrow.proto | 31 +++ 6 files changed, 812 insertions(+), 28 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index c426fe7..e44ebcd 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -68,6 +68,46 @@ public struct Burrow_TailnetProbeResponse: Sendable { 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 { public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" 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(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(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(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(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(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(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(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(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 let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -227,4 +419,40 @@ public struct TailnetClient: Client, GRPCClient { 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: [] + ) + } } diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 9938eef..b95e904 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,6 +1,9 @@ import BurrowConfiguration import Foundation import SwiftUI +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -309,6 +312,7 @@ private struct AccountDraft { accountName = "default" identityName = "apple" authority = TailnetProvider.tailscale.defaultAuthority ?? "" + authMode = .web } } } @@ -329,6 +333,14 @@ private struct ConfigurationSheetView: View { @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @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? @State private var didRunAutomation = false init( @@ -397,7 +409,10 @@ private struct ConfigurationSheetView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - dismiss() + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + dismiss() + } } } #if os(iOS) @@ -446,14 +461,28 @@ private struct ConfigurationSheetView: View { .onChange(of: draft.discoveryEmail) { _, _ in 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 private var tailnetSections: some View { Section("Connection") { TextField("Email address", text: $draft.discoveryEmail) - .textInputAutocapitalization(.never) - .keyboardType(.emailAddress) + .burrowEmailField() .burrowLoginField() .autocorrectionDisabled() @@ -507,22 +536,44 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() Picker("Authentication", selection: $draft.authMode) { ForEach(availableTailnetAuthModes) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.menu) - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) + + 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 { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } } - Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") + + Text(tailnetAuthenticationFootnote) .font(.footnote) .foregroundStyle(.secondary) } @@ -583,6 +634,9 @@ private struct ConfigurationSheetView: View { HStack(spacing: 8) { summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") 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 { Text(label) .font(.caption.weight(.medium)) @@ -813,6 +913,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.authority) == nil { return true } + if draft.authMode == .web { + return tailnetLoginStatus?.running != true + } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -897,8 +1000,9 @@ private struct ConfigurationSheetView: View { } 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) + preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true try await saveTailnetAccount(secret: secret, username: username) dismiss() } @@ -922,7 +1026,7 @@ private struct ConfigurationSheetView: View { switch automation.action { case .tailnetLogin: applyTailnetDefaults(for: .tailscale) - probeTailnetAuthority() + startTailnetLogin() case .headscaleProbe: draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() @@ -950,6 +1054,10 @@ private struct ConfigurationSheetView: View { "Auth: \(draft.authMode.title)", ] + if draft.authMode == .web, tailnetLoginStatus?.running == true { + noteParts.append("Browser sign-in complete") + } + do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) noteParts.append("Linked to daemon network #\(networkID)") @@ -1003,7 +1111,36 @@ private struct ConfigurationSheetView: View { resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" 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() { authorityProbeStatus = nil authorityProbeError = nil + tailnetLoginError = nil } 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() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1108,7 +1316,28 @@ private struct ConfigurationSheetView: View { } 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 { @@ -1215,8 +1444,65 @@ private extension View { self #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 { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index b048add..32f0b8c 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -40,6 +40,19 @@ struct TailnetAuthorityProbeStatus: Sendable { 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 { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { 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 @MainActor final class NetworkViewModel: Sendable { @@ -118,6 +183,32 @@ final class NetworkViewModel: Sendable { 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 { let socketURL = try socketURLResult.get() let networkID = nextNetworkID @@ -317,6 +408,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { } enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { + case web case none case password case preauthKey @@ -325,6 +417,7 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { + case .web: "Browser Sign-In" case .none: "None" case .password: "Password" case .preauthKey: "Preauth Key" diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index fbe1980..55516e1 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -82,11 +82,22 @@ impl TailscaleBridgeManager { let key = session_key(&request.account_name, &request.identity_name); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - let status = self.fetch_status(existing.as_ref()).await?; - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginStartResponse { + session_id: existing.session_id.clone(), + 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)); @@ -155,11 +166,28 @@ impl TailscaleBridgeManager { }; 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), } } + pub async fn cancel(&self, session_id: &str) -> Result { + 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 { let mut last_error = None; let mut last_status = None; @@ -201,6 +229,38 @@ impl TailscaleBridgeManager { .await .context("invalid tailscale helper status response") } + + async fn remove_session_by_id(&self, session_id: &str) -> Option> { + 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 { @@ -249,7 +309,10 @@ fn state_root() -> PathBuf { .join("Burrow") .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 { diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index e4e6d96..0a23ddc 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,15 +13,19 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, - tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, - NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, - TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, - TunnelConfigurationResponse, TunnelStatusResponse, + networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, + Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, + State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, + TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, + TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + auth::server::tailscale::{ + TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, + TailscaleLoginStatus, + }, control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, @@ -49,6 +53,7 @@ pub struct DaemonRPCServer { wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), active_tunnel: Arc>>, + tailnet_login: TailscaleBridgeManager, } impl DaemonRPCServer { @@ -59,6 +64,7 @@ impl DaemonRPCServer { wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), active_tunnel: Arc::new(RwLock::new(None)), + tailnet_login: TailscaleBridgeManager::default(), }) } @@ -130,6 +136,11 @@ impl DaemonRPCServer { Ok(()) } + + fn tailnet_control_url(authority: &str) -> Option { + let authority = discovery::normalize_authority(authority); + (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) + } } #[tonic::async_trait] @@ -308,6 +319,60 @@ impl TailnetControl for DaemonRPCServer { reachable: status.reachable, })) } + + async fn login_start( + &self, + request: Request, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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 { @@ -327,3 +392,21 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { 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, + } +} diff --git a/proto/burrow.proto b/proto/burrow.proto index 79e8976..a590cb1 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -20,6 +20,9 @@ service Networks { service TailnetControl { rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); + rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse); + rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse); + rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); } message NetworkReorderRequest { @@ -84,6 +87,34 @@ message TailnetProbeResponse { 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 { Stopped = 0; Running = 1;