Run byre and the app on their own embedded Tailscale nodes

This commit is contained in:
Ian Gulliver
2026-10-20 13:32:56 -07:00
parent a7b2333535
commit 2dfcc5de47
29 changed files with 1602 additions and 602 deletions
+52 -11
View File
@@ -6,6 +6,9 @@ import Observation
import SwiftData
import SwiftUI
// Servers saved before byre had its own tailnet node are tailscale serve origins, which no longer answer.
private let tailnetServersSince = Date(timeIntervalSince1970: 1_792_526_916)
@MainActor
@Observable
final class Servers {
@@ -69,6 +72,13 @@ final class Servers {
}
private func load() {
let legacy = fetchRecords(FetchDescriptor<ServerRecord>()).filter { $0.saved < tailnetServersSince }
if !legacy.isEmpty {
for record in legacy {
persistence.delete(record)
}
saveRecords()
}
var loaded: [(origin: String, record: ServerRecord)] = []
for record in fetchRecords(FetchDescriptor<ServerRecord>(sortBy: [SortDescriptor(\.saved)])) {
if let origin = parseOrigin(record.origin), !loaded.contains(where: { $0.origin == origin.absoluteString }) {
@@ -157,12 +167,14 @@ final class Connection {
let origin: URL
let overlays = Overlays()
let forwards: Forwards
var phase: Phase = .starting
@ObservationIgnored private var stopped = false
@ObservationIgnored private var restarting = false
init(origin: URL) {
self.origin = origin
forwards = Forwards(host: origin.host()!)
Task {
await start()
}
@@ -177,6 +189,7 @@ final class Connection {
func stop() async {
stopped = true
forwards.closeAll()
if let store {
await store.stop()
}
@@ -208,7 +221,7 @@ final class Connection {
}
private func attempt(_ old: Store?) async -> Bool {
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts())
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards)
do {
let status = try await store.api.client.getStatus().ok.body.json
async let me: Void = store.loadMe()
@@ -231,6 +244,7 @@ final class Connection {
self?.reconnected(status.startedAt)
}
store.remote.start()
forwards.sync(store.api, Set(store.activeTasks().map(\.task.id)))
Notifier.shared.deliver()
return true
} catch {
@@ -250,10 +264,11 @@ final class ConnectForm {
var problem: String?
var missing: [Check] = []
var connecting = false
var serviceLogin: TailnetInfo?
func connect() async -> URL? {
guard let origin = parseOrigin(serverText) else {
problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net."
problem = "The server URL must be an https origin with no path, such as https://byre-mac.example.ts.net."
return nil
}
connecting = true
@@ -281,17 +296,38 @@ final class ConnectForm {
connecting = false
return nil
}
let origin = try await Server.turnOn()
try await Server.turnOn()
let origin = try await waitForTailnet(URL(string: checks.loopback)!)
serverText = origin.absoluteString
try await waitForServer(origin)
} catch {
connecting = false
serviceLogin = nil
problem = errorText(error)
return nil
}
return await connect()
}
private func waitForTailnet(_ loopback: URL) async throws -> URL {
let started = Date()
while true {
let info = try? await Server.tailnet(loopback)
if let info, info.state == "Running" {
serviceLogin = nil
guard let origin = URL(string: info.origin), !info.origin.isEmpty else {
throw ProcessFailure(errorDescription: noCertificates)
}
return origin
}
serviceLogin = info
if info == nil, Date().timeIntervalSince(started) > 10 {
throw ProcessFailure(errorDescription: "byre was installed but \(loopback.absoluteString) didn't answer within 10 s.")
}
try await Task.sleep(for: .milliseconds(500))
}
}
private func waitForServer(_ origin: URL) async throws {
let api = API(origin: origin)
try await withThrowingTaskGroup(of: Void.self) { group in
@@ -301,8 +337,8 @@ final class ConnectForm {
}
}
group.addTask {
try await Task.sleep(for: .seconds(10))
throw ProcessFailure(errorDescription: "byre was installed but \(origin.absoluteString) didn't answer within 10 s.")
try await Task.sleep(for: .seconds(15))
throw ProcessFailure(errorDescription: "byre's Tailscale node is signed in but \(origin.absoluteString) didn't answer within 15 s.")
}
defer {
group.cancelAll()
@@ -327,19 +363,19 @@ func explain(_ error: any Error, _ origin: URL) -> String {
if let api = e as? ApiError {
switch api.status {
case 401:
return "\(host) answered 401, no Tailscale identity: \(api.message). Use byre's ts.net HTTPS address, the one tailscale serve gives it, so the request carries your Tailscale login."
return "\(host) answered 401, it couldn't tell who this Mac's Tailscale node belongs to: \(api.message)"
case 403:
return "\(host) answered 403, the request didn't come through tailscale serve: \(api.message). Use the HTTPS address tailscale serve gives byre."
return "\(host) answered 403, it serves only nodes owned by a user, not tagged ones: \(api.message)"
default:
return api.message
}
}
if let url = e as? URLError {
if tlsErrors.contains(url.code) {
return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net HTTPS address, whose certificate tailscale serve provides."
return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net address, whose certificate its Tailscale node provides."
}
if unreachableErrors.contains(url.code) {
return "Can't reach \(host): \(url.localizedDescription) Is Tailscale connected?"
return "Can't reach \(host) on the tailnet: \(url.localizedDescription) Is byre running there?"
}
return url.localizedDescription
}
@@ -363,7 +399,7 @@ struct ConnectView: View {
private var fields: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Server URL")
TextField("https://byre.example.ts.net", text: $form.serverText)
TextField("https://byre-mac.example.ts.net", text: $form.serverText)
.textFieldStyle(.roundedBorder)
.onSubmit(connect)
HStack(spacing: 10) {
@@ -371,6 +407,9 @@ struct ConnectView: View {
Button("Run byre on this Mac", action: installHere)
}
.disabled(form.connecting)
if let login = form.serviceLogin {
ServiceLoginView(info: login)
}
if let problem = form.problem {
Text(problem)
.foregroundStyle(Palette.danger)
@@ -444,7 +483,9 @@ struct ServerWindow: View {
@ViewBuilder
private var content: some View {
if let connection {
if !Tailnet.shared.running {
TailnetLoginView()
} else if let connection {
switch connection.phase {
case .starting:
Color.clear