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 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<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 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: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
@State private var didRunAutomation = false
|
||||
|
||||
init(
|
||||
|
|
@ -397,9 +409,12 @@ private struct ConfigurationSheetView: View {
|
|||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
Task { @MainActor in
|
||||
await cancelTailnetLoginIfNeeded()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
|
|
@ -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 == .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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -82,12 +82,23 @@ 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?;
|
||||
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));
|
||||
tokio::fs::create_dir_all(&state_dir)
|
||||
|
|
@ -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<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> {
|
||||
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<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> {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<RunState>, watch::Receiver<RunState>),
|
||||
network_update_chan: (watch::Sender<()>, watch::Receiver<()>),
|
||||
active_tunnel: Arc<RwLock<Option<ActiveTunnel>>>,
|
||||
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<String> {
|
||||
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<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 {
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue