Run byre and the app on their own embedded Tailscale nodes
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user