Add email-based tailnet discovery to Apple app
This commit is contained in:
parent
baf1408060
commit
1da00ecdf3
12 changed files with 1784 additions and 213 deletions
|
|
@ -284,6 +284,7 @@ private struct AccountDraft {
|
|||
var identityName = ""
|
||||
var wireGuardConfig = ""
|
||||
|
||||
var discoveryEmail = ""
|
||||
var tailnetProvider: TailnetProvider = .tailscale
|
||||
var authority = ""
|
||||
var tailnet = ""
|
||||
|
|
@ -327,6 +328,9 @@ private struct ConfigurationSheetView: View {
|
|||
@State private var errorMessage: String?
|
||||
@State private var loginSessionID: String?
|
||||
@State private var loginStatus: TailnetLoginStatus?
|
||||
@State private var discoveryStatus: TailnetDiscoveryResponse?
|
||||
@State private var discoveryError: String?
|
||||
@State private var isDiscoveringTailnet = false
|
||||
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
|
||||
@State private var authorityProbeError: String?
|
||||
@State private var isProbingAuthority = false
|
||||
|
|
@ -449,6 +453,9 @@ private struct ConfigurationSheetView: View {
|
|||
.onChange(of: draft.authority) { _, _ in
|
||||
resetAuthorityProbe()
|
||||
}
|
||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||
resetTailnetDiscoveryFeedback()
|
||||
}
|
||||
.onDisappear {
|
||||
pollingTask?.cancel()
|
||||
webAuthenticationTask?.cancel()
|
||||
|
|
@ -459,7 +466,37 @@ private struct ConfigurationSheetView: View {
|
|||
@ViewBuilder
|
||||
private var tailnetSections: some View {
|
||||
Section("Connection") {
|
||||
Picker("Provider", selection: $draft.tailnetProvider) {
|
||||
TextField("Email address", text: $draft.discoveryEmail)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.emailAddress)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
discoverTailnetAuthority()
|
||||
} label: {
|
||||
Label {
|
||||
Text(isDiscoveringTailnet ? "Finding Server" : "Find Server")
|
||||
} icon: {
|
||||
Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
|
||||
|
||||
if let discoveryStatus {
|
||||
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
|
||||
} else if let discoveryError {
|
||||
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
||||
}
|
||||
|
||||
Picker(
|
||||
"Provider",
|
||||
selection: Binding(
|
||||
get: { draft.tailnetProvider },
|
||||
set: { applyTailnetProvider($0) }
|
||||
)
|
||||
) {
|
||||
ForEach(TailnetProvider.allCases) { provider in
|
||||
Text(provider.title).tag(provider)
|
||||
}
|
||||
|
|
@ -503,14 +540,14 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
Section("Authentication") {
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
if tailnetUsesWebLogin {
|
||||
tailnetWebLoginCard
|
||||
} else {
|
||||
TextField("Username", text: $draft.username)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
Picker("Authentication", selection: $draft.authMode) {
|
||||
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
|
|
@ -583,7 +620,7 @@ private struct ConfigurationSheetView: View {
|
|||
HStack(spacing: 8) {
|
||||
summaryBadge(draft.tailnetProvider.title)
|
||||
summaryBadge(
|
||||
draft.tailnetProvider.usesWebLogin ? "Web Sign-In" : draft.authMode.title
|
||||
tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -656,7 +693,7 @@ private struct ConfigurationSheetView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Burrow launches the local bridge, then opens the real Tailscale sign-in page in-app.")
|
||||
Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -696,6 +733,41 @@ private struct ConfigurationSheetView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func tailnetDiscoveryCard(
|
||||
status: TailnetDiscoveryResponse?,
|
||||
failure: String?
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let status {
|
||||
Text("Discovered \(status.provider.title)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(status.authority)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
if let oidcIssuer = status.oidcIssuer {
|
||||
Text("OIDC: \(oidcIssuer)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} else if let failure {
|
||||
Text("Discovery 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))
|
||||
|
|
@ -762,12 +834,12 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
if !draft.tailnetProvider.usesWebLogin {
|
||||
if availableTailnetAuthModes.count > 1 {
|
||||
Menu("Authentication") {
|
||||
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
|
||||
ForEach(availableTailnetAuthModes) { mode in
|
||||
Button(mode.title) {
|
||||
draft.authMode = mode
|
||||
if mode == .none {
|
||||
if mode == .none || mode == .web {
|
||||
draft.secret = ""
|
||||
}
|
||||
}
|
||||
|
|
@ -848,7 +920,7 @@ private struct ConfigurationSheetView: View {
|
|||
case .tor:
|
||||
return "Save Account"
|
||||
case .tailnet:
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
if tailnetUsesWebLogin {
|
||||
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
|
||||
}
|
||||
return "Save Account"
|
||||
|
|
@ -865,12 +937,12 @@ private struct ConfigurationSheetView: View {
|
|||
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
||||
return true
|
||||
}
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
return false
|
||||
}
|
||||
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
|
||||
return true
|
||||
}
|
||||
if tailnetUsesWebLogin {
|
||||
return false
|
||||
}
|
||||
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
||||
return true
|
||||
}
|
||||
|
|
@ -955,14 +1027,14 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
private func submitTailnet() async throws {
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
if tailnetUsesWebLogin {
|
||||
if loginStatus?.running == true {
|
||||
webAuthenticationTask?.cancel()
|
||||
webAuthenticationTask = nil
|
||||
try await saveTailnetAccount(secret: nil, username: nil)
|
||||
dismiss()
|
||||
} else {
|
||||
try await startTailscaleLogin()
|
||||
try await startTailnetLogin()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -973,13 +1045,13 @@ private struct ConfigurationSheetView: View {
|
|||
dismiss()
|
||||
}
|
||||
|
||||
private func startTailscaleLogin() async throws {
|
||||
private func startTailnetLogin() async throws {
|
||||
let response = try await TailnetBridgeClient.startLogin(
|
||||
TailnetLoginStartRequest(
|
||||
accountName: normalized(draft.accountName, fallback: "default"),
|
||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||
hostname: normalizedOptional(draft.hostname),
|
||||
controlURL: draft.tailnetProvider.defaultAuthority
|
||||
controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority
|
||||
)
|
||||
)
|
||||
loginSessionID = response.sessionID
|
||||
|
|
@ -1010,7 +1082,7 @@ private struct ConfigurationSheetView: View {
|
|||
case .tailnetLogin:
|
||||
draft.tailnetProvider = .tailscale
|
||||
do {
|
||||
try await startTailscaleLogin()
|
||||
try await startTailnetLogin()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
|
@ -1078,14 +1150,14 @@ private struct ConfigurationSheetView: View {
|
|||
let provider = draft.tailnetProvider
|
||||
let title = titleOrFallback(
|
||||
hostnameFallback(
|
||||
from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
|
||||
from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
|
||||
fallback: provider.title
|
||||
)
|
||||
)
|
||||
|
||||
let payload = TailnetNetworkPayload(
|
||||
provider: provider,
|
||||
authority: normalizedOptional(provider.defaultAuthority ?? draft.authority),
|
||||
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
|
||||
account: normalized(draft.accountName, fallback: "default"),
|
||||
identity: normalized(draft.identityName, fallback: "apple"),
|
||||
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
|
||||
|
|
@ -1094,7 +1166,7 @@ private struct ConfigurationSheetView: View {
|
|||
|
||||
var noteParts: [String] = [
|
||||
provider.title,
|
||||
provider.usesWebLogin
|
||||
tailnetUsesWebLogin
|
||||
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
|
||||
: "Auth: \(draft.authMode.title)",
|
||||
]
|
||||
|
|
@ -1123,7 +1195,7 @@ private struct ConfigurationSheetView: View {
|
|||
hostname: payload.hostname,
|
||||
username: username,
|
||||
tailnet: payload.tailnet,
|
||||
authMode: provider.usesWebLogin ? .web : draft.authMode,
|
||||
authMode: tailnetUsesWebLogin ? .web : draft.authMode,
|
||||
note: noteParts.joined(separator: " • "),
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
|
|
@ -1155,18 +1227,25 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
|
||||
private func applyTailnetProvider(_ provider: TailnetProvider) {
|
||||
resetTailnetDiscoveryFeedback()
|
||||
draft.tailnetProvider = provider
|
||||
applyTailnetDefaults(for: provider)
|
||||
}
|
||||
|
||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||
draft.authority = provider.defaultAuthority ?? ""
|
||||
if provider.usesWebLogin {
|
||||
loginStatus = nil
|
||||
loginSessionID = nil
|
||||
pollingTask?.cancel()
|
||||
if provider == .tailscale {
|
||||
draft.authMode = .web
|
||||
draft.username = ""
|
||||
draft.secret = ""
|
||||
} else {
|
||||
if draft.authMode == .web {
|
||||
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||
draft.authMode = provider.supportsWebLogin ? .web : .none
|
||||
}
|
||||
if draft.authMode == .web && !provider.supportsWebLogin {
|
||||
draft.authMode = .none
|
||||
}
|
||||
}
|
||||
|
|
@ -1202,6 +1281,41 @@ private struct ConfigurationSheetView: View {
|
|||
authorityProbeError = nil
|
||||
}
|
||||
|
||||
private func resetTailnetDiscoveryFeedback() {
|
||||
discoveryStatus = nil
|
||||
discoveryError = nil
|
||||
}
|
||||
|
||||
private func discoverTailnetAuthority() {
|
||||
guard let email = normalizedOptional(draft.discoveryEmail) else {
|
||||
discoveryStatus = nil
|
||||
discoveryError = "Enter an email address first."
|
||||
return
|
||||
}
|
||||
|
||||
isDiscoveringTailnet = true
|
||||
discoveryStatus = nil
|
||||
discoveryError = nil
|
||||
|
||||
Task { @MainActor in
|
||||
defer { isDiscoveringTailnet = false }
|
||||
do {
|
||||
let discovery = try await TailnetDiscoveryClient.discover(email: email)
|
||||
discoveryStatus = discovery
|
||||
draft.tailnetProvider = discovery.provider
|
||||
draft.authority = discovery.authority
|
||||
if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil {
|
||||
draft.authMode = .web
|
||||
draft.username = ""
|
||||
draft.secret = ""
|
||||
}
|
||||
probeTailnetAuthority()
|
||||
} catch {
|
||||
discoveryError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pasteWireGuardConfiguration() {
|
||||
guard let clipboardString else { return }
|
||||
draft.wireGuardConfig = clipboardString
|
||||
|
|
@ -1247,6 +1361,21 @@ private struct ConfigurationSheetView: View {
|
|||
return host
|
||||
}
|
||||
|
||||
private var tailnetUsesWebLogin: Bool {
|
||||
draft.authMode == .web && draft.tailnetProvider.supportsWebLogin
|
||||
}
|
||||
|
||||
private var availableTailnetAuthModes: [AccountAuthMode] {
|
||||
switch draft.tailnetProvider {
|
||||
case .tailscale:
|
||||
[.web]
|
||||
case .headscale:
|
||||
[.web, .none, .password, .preauthKey]
|
||||
case .burrow:
|
||||
[.none, .password, .preauthKey]
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ struct TailnetLoginStartRequest: Codable, Sendable {
|
|||
var controlURL: String?
|
||||
}
|
||||
|
||||
struct TailnetDiscoveryResponse: Codable, Sendable {
|
||||
var domain: String
|
||||
var provider: TailnetProvider
|
||||
var authority: String
|
||||
var oidcIssuer: String?
|
||||
}
|
||||
|
||||
struct TailnetLoginStatus: Codable, Sendable {
|
||||
var backendState: String
|
||||
var authURL: String?
|
||||
|
|
@ -91,7 +98,7 @@ enum TailnetBridgeClient {
|
|||
return try decoder.decode(TailnetLoginStatus.self, from: data)
|
||||
}
|
||||
|
||||
private static func validate(response: URLResponse, data: Data) throws {
|
||||
fileprivate static func validate(response: URLResponse, data: Data) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
|
@ -104,6 +111,32 @@ enum TailnetBridgeClient {
|
|||
}
|
||||
}
|
||||
|
||||
enum TailnetDiscoveryClient {
|
||||
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
|
||||
|
||||
static func discover(email: String) async throws -> TailnetDiscoveryResponse {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("v1/tailnet/discover"),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "email", value: email)
|
||||
]
|
||||
guard let url = components.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
try TailnetBridgeClient.validate(response: response, data: data)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return try decoder.decode(TailnetDiscoveryResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
enum TailnetAuthorityProbeClient {
|
||||
static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus {
|
||||
let normalizedAuthority = normalizeAuthority(authority)
|
||||
|
|
@ -308,8 +341,13 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
var usesWebLogin: Bool {
|
||||
self == .tailscale
|
||||
var supportsWebLogin: Bool {
|
||||
switch self {
|
||||
case .tailscale, .headscale:
|
||||
true
|
||||
case .burrow:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var requiresControlURL: Bool {
|
||||
|
|
@ -332,7 +370,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
|||
case .tailscale:
|
||||
"Use Tailscale's real browser login flow."
|
||||
case .headscale:
|
||||
"Store a Headscale control-plane endpoint and credentials."
|
||||
"Use your Headscale control plane with browser or key-based sign-in."
|
||||
case .burrow:
|
||||
"Store Burrow control-plane credentials."
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue