Add daemon-owned Tailnet login flow
This commit is contained in:
parent
d1e28b8817
commit
0c660acd1e
6 changed files with 812 additions and 28 deletions
|
|
@ -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: []
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +409,10 @@ private struct ConfigurationSheetView: View {
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
Task { @MainActor in
|
||||||
|
await cancelTailnetLoginIfNeeded()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
@ -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 != .none {
|
|
||||||
SecureField(
|
if draft.authMode == .web {
|
||||||
draft.authMode == .password ? "Password" : "Preauth Key",
|
Button {
|
||||||
text: $draft.secret
|
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)
|
.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,8 +1444,65 @@ 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 {
|
||||||
case tailnetLogin = "tailnet-login"
|
case tailnetLogin = "tailnet-login"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -82,11 +82,22 @@ 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 {
|
||||||
return Ok(TailscaleLoginStartResponse {
|
Ok(status) => {
|
||||||
session_id: existing.session_id.clone(),
|
return Ok(TailscaleLoginStartResponse {
|
||||||
status,
|
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));
|
let state_dir = state_root().join(session_dir_name(&request));
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue