Wait for the server's tailnet peer before connecting at app startup

This commit is contained in:
Ian Gulliver
2026-10-20 14:52:10 -07:00
parent a2571763b4
commit a95e0ddb29
5 changed files with 83 additions and 12 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ The app joins the tailnet as its own node and reaches byre's HTTPS origin on byr
## Tailnet ## Tailnet
`Tailnet.swift` runs one node per app install with libtailscale, through the C API of TailscaleKit (`tailscale_new` and the rest, from the umbrella header): hostname `byre-app-<slug of the Mac's name>` (the slug as the server's, see "Tailnet" in `docs/operations.md`), not ephemeral, its state in `tsnet` in `run.fc.byre.mac` under Application Support. It starts at launch (`tailnetNode`, which exits the app if libtailscale can't start) and opens the node's loopback SOCKS5 proxy (`tailscale_loopback`). `API` sets that proxy, with user `tsnet` and the proxy credential, as the `proxyConfigurations` of its `URLSession`, so REST, the SSE streams (`EventStream`), the command channel (`Remote`) and terminals (`Terminal`) all go over the tailnet; only `GET /api/tailnet` to the local service's loopback address uses `URLSession.shared`. `Tailnet` polls the node's status (`tailscale_status_json`) every second, and every 2 s reads `GET /api/tailnet` at the local service's loopback address (the `loopback` `byre check` prints, found once at launch). Until the app's node is `Running`, the window shows a spinner with "Starting Tailscale…"; while the app's node is `NeedsLogin` or `NeedsMachineAuth`, or the local service's node is `NeedsLogin`, it shows only the sign-in screen instead: a row per node (the app's, and the local service's when one answers), each **Signed in**, or **Sign in to Tailscale**, which opens that node's `AuthURL` in the browser. Servers are added without typing in two ways. When the local service's node runs with an `origin`, that origin is saved as a server (a `ServerRecord`, so iCloud carries it to every Mac on the account). Once the app's node runs, and every 10 s after, the app also takes each online tailnet peer owned by its node's user whose MagicDNS name starts with `byre-` but not `byre-app-`, and saves it when `GET /api/admin/status` answers there within 5 s, probing all of them at once. Either way a forgotten server comes back the next time it is found. Preview forwards dial through the node too (see "Previews" in `docs/operations.md`). `Tailnet.swift` runs one node per app install with libtailscale, through the C API of TailscaleKit (`tailscale_new` and the rest, from the umbrella header): hostname `byre-app-<slug of the Mac's name>` (the slug as the server's, see "Tailnet" in `docs/operations.md`), not ephemeral, its state in `tsnet` in `run.fc.byre.mac` under Application Support, its log (`tailscale_set_logfd`, a pipe) in the app's log as `tsnet:` lines. It starts at launch (`tailnetNode`, which exits the app if libtailscale can't start) and opens the node's loopback SOCKS5 proxy (`tailscale_loopback`). `API` sets that proxy, with user `tsnet` and the proxy credential, as the `proxyConfigurations` of its `URLSession`, so REST, the SSE streams (`EventStream`), the command channel (`Remote`) and terminals (`Terminal`) all go over the tailnet; only `GET /api/tailnet` to the local service's loopback address uses `URLSession.shared`. `Tailnet` polls the node's status (`tailscale_status_json`) every second, and every 2 s reads `GET /api/tailnet` at the local service's loopback address (the `loopback` `byre check` prints, found once at launch). Until the app's node is `Running`, the window shows a spinner with "Starting Tailscale…"; while the app's node is `NeedsLogin` or `NeedsMachineAuth`, or the local service's node is `NeedsLogin`, it shows only the sign-in screen instead: a row per node (the app's, and the local service's when one answers), each **Signed in**, or **Sign in to Tailscale**, which opens that node's `AuthURL` in the browser. Servers are added without typing in two ways. When the local service's node runs with an `origin`, that origin is saved as a server (a `ServerRecord`, so iCloud carries it to every Mac on the account). Once the app's node runs, and every 10 s after, the app also takes each online tailnet peer owned by its node's user whose MagicDNS name starts with `byre-` but not `byre-app-`, and saves it when `GET /api/admin/status` answers there within 5 s, probing all of them at once. Either way a forgotten server comes back the next time it is found. Preview forwards dial through the node too (see "Previews" in `docs/operations.md`).
`mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`): it clones it into `~/Library/Caches/byre/build/libtailscale-<commit>`, runs its `make -C swift macos` (Go's `c-archive` build, then `xcodebuild` of the framework, unsigned) and wraps the framework with `xcodebuild -create-xcframework`, once per commit, under the build lock. It copies that into the shared build directory's `mac/Frameworks/TailscaleKit.xcframework`, which `mac/Package.swift` names as a binary target, and bundles and signs the framework in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit. `mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`): it clones it into `~/Library/Caches/byre/build/libtailscale-<commit>`, runs its `make -C swift macos` (Go's `c-archive` build, then `xcodebuild` of the framework, unsigned) and wraps the framework with `xcodebuild -create-xcframework`, once per commit, under the build lock. It copies that into the shared build directory's `mac/Frameworks/TailscaleKit.xcframework`, which `mac/Package.swift` names as a binary target, and bundles and signs the framework in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit.
@@ -81,7 +81,7 @@ API calls go through the generated `Client` over `URLSessionTransport`. A middle
`EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. `EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot.
Startup waits for the app's tailnet node to run, then: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. Until the window shows, a spinner reads "Connecting to <server>…". A failure adds `Retrying: <error>` below it (explained as Connect explains one) and the whole startup is tried again every 2 s until it succeeds, so a server restarting (after an update replaces its binary, for one) heals by itself; Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created`, `notifyTurn` on `turn.ended`, and `notifyTask` on the task events listed under "Notifications" in `docs/operations.md`. Startup waits until the app's tailnet node is `Running` and its status lists the server's node among its peers (`Tailnet.reachable`; whether that peer is connected to control doesn't matter): a node starting from libtailscale's netmap cache reports `Running` with no peers before control sends the real netmap, and a dial then waits out the SOCKS proxy's 5 s and is refused, which `URLSession` reports as "bad URL". Then: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. Until the window shows, a spinner reads "Connecting to <server>…". A failure adds `Retrying: <error>` below it (explained as Connect explains one) and the whole startup is tried again every 2 s until it succeeds, so a server restarting (after an update replaces its binary, for one) heals by itself; Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created`, `notifyTurn` on `turn.ended`, and `notifyTask` on the task events listed under "Notifications" in `docs/operations.md`.
The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
@@ -160,7 +160,7 @@ The app menu has About, Check for Updates…, Forget Server (for the server the
## Connection ## Connection
Once the app's node runs (see "Tailnet") and its first search of the tailnet for servers has finished (until then a spinner reads "Looking for byre servers on the tailnet…"), on first launch with no server found and from Add Server…, the window shows one field, Server URL (an `https` origin with no path, a byre node's `ts.net` origin), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `byre check`, then, unless the service is already loaded, `Contents/Helpers/byre install`, both through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It then polls `GET /api/tailnet` at the check's `loopback` address every 500 ms, failing if nothing answers for 10 s, and waits as long as the service's node takes to run (while it needs signing in, the window shows the sign-in screen, see "Tailnet"); a node that runs with no `origin` (the tailnet has no HTTPS certificates) stops it with how to turn them on and restart the service, which the Server tab shows too; once the node runs it takes its `origin`, polls `GET /api/admin/status` there for up to 15 s while the app's node learns of the new one, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is byre running there?"), 401 (byre couldn't identify the app's node), 403 (a tagged node); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field. Once the app's node runs (see "Tailnet") and its first search of the tailnet for servers has finished (until then a spinner reads "Looking for byre servers on the tailnet…"), on first launch with no server found and from Add Server…, the window shows one field, Server URL (an `https` origin with no path, a byre node's `ts.net` origin), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `byre check`, then, unless the service is already loaded, `Contents/Helpers/byre install`, both through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It then polls `GET /api/tailnet` at the check's `loopback` address every 500 ms, failing if nothing answers for 10 s, and waits as long as the service's node takes to run (while it needs signing in, the window shows the sign-in screen, see "Tailnet"); a node that runs with no `origin` (the tailnet has no HTTPS certificates) stops it with how to turn them on and restart the service, which the Server tab shows too; once the node runs it takes its `origin`, polls `GET /api/admin/status` there for up to 15 s while the app's node learns of the new one, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is byre running there?"), 401 (byre couldn't identify the app's node), 403 (a tagged node); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host without a trailing dot, with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field.
## Updates ## Updates
+1 -1
View File
@@ -144,7 +144,7 @@ func parseOrigin(_ text: String) -> URL? {
} }
var origin = URLComponents() var origin = URLComponents()
origin.scheme = "https" origin.scheme = "https"
origin.host = host.lowercased() origin.host = (host.hasSuffix(".") ? String(host.dropLast()) : host).lowercased()
origin.port = c.port origin.port = c.port
return origin.url return origin.url
} }
+1 -1
View File
@@ -115,7 +115,7 @@ final class Remote {
case .success: case .success:
self.receive(task) self.receive(task)
case .failure(let error): case .failure(let error):
log.notice("client connection closed:\(errorText(error), privacy: .public)") log.notice("client connection closed:\(errorText(error), privacy: .public); \(tailnetNode.peerState(self.api.origin.host()!), privacy: .public)")
self.ws = nil self.ws = nil
try? await Task.sleep(for: retryAfter) try? await Task.sleep(for: retryAfter)
self.start() self.start()
+2 -1
View File
@@ -198,7 +198,7 @@ final class Connection {
private func start() async { private func start() async {
let old = store let old = store
while !stopped, !Tailnet.shared.running { while !stopped, !Tailnet.shared.reachable(origin) {
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(for: .milliseconds(500))
} }
while !stopped, !(await attempt(old)) { while !stopped, !(await attempt(old)) {
@@ -252,6 +252,7 @@ final class Connection {
Notifier.shared.deliver() Notifier.shared.deliver()
return true return true
} catch { } catch {
log.notice("connect \(self.origin.absoluteString, privacy: .public) failed: \(errorText(error), privacy: .public); \(tailnetNode.peerState(self.origin.host()!), privacy: .public)")
await store.stop() await store.stop()
if !stopped { if !stopped {
phase = .failed(explain(error, origin)) phase = .failed(explain(error, origin))
+76 -6
View File
@@ -40,6 +40,7 @@ final class TailnetNode: Sendable {
Self.check(sd, "set hostname", unsafe tailscale_set_hostname(sd, hostname)) Self.check(sd, "set hostname", unsafe tailscale_set_hostname(sd, hostname))
Self.check(sd, "set dir", unsafe tailscale_set_dir(sd, dir.path)) Self.check(sd, "set dir", unsafe tailscale_set_dir(sd, dir.path))
Self.check(sd, "set ephemeral", tailscale_set_ephemeral(sd, 0)) Self.check(sd, "set ephemeral", tailscale_set_ephemeral(sd, 0))
Self.check(sd, "set logfd", tailscale_set_logfd(sd, Self.logPipe()))
Self.check(sd, "start", tailscale_start(sd)) Self.check(sd, "start", tailscale_start(sd))
var addr = [CChar](repeating: 0, count: 64) var addr = [CChar](repeating: 0, count: 64)
var proxyCred = [CChar](repeating: 0, count: 33) var proxyCred = [CChar](repeating: 0, count: 33)
@@ -60,6 +61,49 @@ final class TailnetNode: Sendable {
} }
} }
private static let logs = Pipe()
private static func logPipe() -> Int32 {
let lines = LineBuffer()
logs.fileHandleForReading.readabilityHandler = { handle in
for line in lines.add(handle.availableData) {
log.notice("tsnet: \(line, privacy: .public)")
}
}
return dup(logs.fileHandleForWriting.fileDescriptor)
}
func peerState(_ host: String) -> String {
var out: UnsafeMutablePointer<CChar>?
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
return "no status: \(Self.errorMessage(sd))"
}
defer {
unsafe free(out)
}
struct Peer: Decodable {
let DNSName: String
let Online: Bool
let InNetworkMap: Bool
let InMagicSock: Bool
let InEngine: Bool
let LastHandshake: String
let CurAddr: String
let Relay: String
}
struct Status: Decodable {
let BackendState: String
let Peer: [String: Peer]?
}
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
return "undecodable status"
}
guard let p = (status.Peer ?? [:]).values.first(where: { $0.DNSName == host + "." || $0.DNSName == host }) else {
return "state=\(status.BackendState) peer \(host) not in status"
}
return "state=\(status.BackendState) online=\(p.Online) netmap=\(p.InNetworkMap) magicsock=\(p.InMagicSock) engine=\(p.InEngine) handshake=\(p.LastHandshake) addr=\(p.CurAddr) relay=\(p.Relay)"
}
static func errorMessage(_ sd: tailscale) -> String { static func errorMessage(_ sd: tailscale) -> String {
var buf = [CChar](repeating: 0, count: 1024) var buf = [CChar](repeating: 0, count: 1024)
_ = unsafe tailscale_errmsg(sd, &buf, buf.count) _ = unsafe tailscale_errmsg(sd, &buf, buf.count)
@@ -72,7 +116,7 @@ final class TailnetNode: Sendable {
return proxy return proxy
} }
func status() -> (state: String, authURL: URL?, servers: [URL])? { func status() -> (state: String, authURL: URL?, servers: [URL], peers: Set<String>)? {
var out: UnsafeMutablePointer<CChar>? var out: UnsafeMutablePointer<CChar>?
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else { guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
return nil return nil
@@ -99,15 +143,18 @@ final class TailnetNode: Sendable {
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else { guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
return nil return nil
} }
let servers = (status.Peer ?? [:]).values.compactMap { peer -> URL? in let peers = (status.Peer ?? [:]).values
let host = peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName let host = { (peer: Node) in
let name = host.split(separator: ".").first ?? "" (peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName).lowercased()
}
let servers = peers.compactMap { peer -> URL? in
let name = host(peer).split(separator: ".").first ?? ""
guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else { guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else {
return nil return nil
} }
return parseOrigin("https://\(host)") return parseOrigin("https://\(host(peer))")
} }
return (status.BackendState, URL(string: status.AuthURL), servers) return (status.BackendState, URL(string: status.AuthURL), servers, Set(peers.map(host)))
} }
// Blocks until the connection is made or fails. // Blocks until the connection is made or fails.
@@ -120,6 +167,20 @@ final class TailnetNode: Sendable {
} }
} }
private final class LineBuffer: @unchecked Sendable {
private var pending = Data()
func add(_ data: Data) -> [String] {
pending.append(data)
var lines: [String] = []
while let nl = pending.firstIndex(of: 0x0A) {
lines.append(String(decoding: pending[pending.startIndex..<nl], as: UTF8.self))
pending.removeSubrange(pending.startIndex...nl)
}
return lines
}
}
let tailnetNode = TailnetNode() let tailnetNode = TailnetNode()
@MainActor @MainActor
@@ -131,12 +192,18 @@ final class Tailnet {
private(set) var authURL: URL? private(set) var authURL: URL?
private(set) var service: TailnetInfo? private(set) var service: TailnetInfo?
private(set) var searched = false private(set) var searched = false
private(set) var peers: Set<String> = []
@ObservationIgnored private var servers: [URL] = [] @ObservationIgnored private var servers: [URL] = []
var running: Bool { var running: Bool {
state == "Running" state == "Running"
} }
// A node starting from its cached netmap reports Running before it knows any peer, and dials made then fail.
func reachable(_ origin: URL) -> Bool {
running && peers.contains(origin.host()!.lowercased())
}
var needsLogin: Bool { var needsLogin: Bool {
state == "NeedsLogin" || state == "NeedsMachineAuth" || service?.state == "NeedsLogin" state == "NeedsLogin" || state == "NeedsMachineAuth" || service?.state == "NeedsLogin"
} }
@@ -148,6 +215,9 @@ final class Tailnet {
state = status.state state = status.state
authURL = status.authURL authURL = status.authURL
servers = status.servers servers = status.servers
if peers != status.peers {
peers = status.peers
}
} }
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
} }