2026-10-20 13:32:56 -07:00
|
|
|
import AppKit
|
|
|
|
|
import Foundation
|
|
|
|
|
import Network
|
|
|
|
|
import Observation
|
|
|
|
|
import SwiftUI
|
|
|
|
|
import TailscaleKit
|
|
|
|
|
|
|
|
|
|
func slug(_ text: String) -> String {
|
|
|
|
|
var out = ""
|
|
|
|
|
var dash = false
|
|
|
|
|
for c in text.lowercased() {
|
|
|
|
|
if c.isASCII, c.isLetter || c.isNumber {
|
|
|
|
|
if dash, !out.isEmpty {
|
|
|
|
|
out += "-"
|
|
|
|
|
}
|
|
|
|
|
out.append(c)
|
|
|
|
|
dash = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
dash = true
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class TailnetNode: Sendable {
|
|
|
|
|
let sd: tailscale
|
|
|
|
|
let hostname = "byre-app-\(slug(macName))"
|
|
|
|
|
private let proxyHost: String
|
|
|
|
|
private let proxyPort: UInt16
|
|
|
|
|
private let proxyCredential: String
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac/tsnet", directoryHint: .isDirectory)
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
|
} catch {
|
|
|
|
|
fatalError("create tsnet dir: \(error)")
|
|
|
|
|
}
|
|
|
|
|
sd = tailscale_new()
|
|
|
|
|
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 ephemeral", tailscale_set_ephemeral(sd, 0))
|
|
|
|
|
Self.check(sd, "start", tailscale_start(sd))
|
|
|
|
|
var addr = [CChar](repeating: 0, count: 64)
|
|
|
|
|
var proxyCred = [CChar](repeating: 0, count: 33)
|
|
|
|
|
var apiCred = [CChar](repeating: 0, count: 33)
|
|
|
|
|
Self.check(sd, "loopback", unsafe tailscale_loopback(sd, &addr, addr.count, &proxyCred, &apiCred))
|
|
|
|
|
let address = String(decoding: addr.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
|
|
|
|
guard let colon = address.lastIndex(of: ":"), let port = UInt16(address[address.index(after: colon)...]) else {
|
|
|
|
|
fatalError("tsnet loopback address \(address)")
|
|
|
|
|
}
|
|
|
|
|
proxyHost = String(address[..<colon])
|
|
|
|
|
proxyPort = port
|
|
|
|
|
proxyCredential = String(decoding: proxyCred.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func check(_ sd: tailscale, _ what: String, _ result: Int32) {
|
|
|
|
|
if result != 0 {
|
|
|
|
|
fatalError("tsnet \(what): \(errorMessage(sd))")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func errorMessage(_ sd: tailscale) -> String {
|
|
|
|
|
var buf = [CChar](repeating: 0, count: 1024)
|
|
|
|
|
_ = unsafe tailscale_errmsg(sd, &buf, buf.count)
|
|
|
|
|
return String(decoding: buf.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func proxy() -> ProxyConfiguration {
|
|
|
|
|
let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: NWEndpoint.Host(proxyHost), port: NWEndpoint.Port(rawValue: proxyPort)!))
|
|
|
|
|
proxy.applyCredential(username: "tsnet", password: proxyCredential)
|
|
|
|
|
return proxy
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 14:52:10 -07:00
|
|
|
func status() -> (state: String, authURL: URL?, servers: [URL], peers: Set<String>)? {
|
2026-10-20 13:32:56 -07:00
|
|
|
var out: UnsafeMutablePointer<CChar>?
|
|
|
|
|
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
defer {
|
|
|
|
|
unsafe free(out)
|
|
|
|
|
}
|
2026-10-20 14:08:35 -07:00
|
|
|
struct Node: Decodable {
|
|
|
|
|
let DNSName: String
|
|
|
|
|
let UserID: Int64
|
|
|
|
|
let Online: Bool
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
struct Status: Decodable {
|
|
|
|
|
let BackendState: String
|
|
|
|
|
let AuthURL: String
|
2026-10-20 14:08:35 -07:00
|
|
|
let me: Node?
|
|
|
|
|
let Peer: [String: Node]?
|
|
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
|
case BackendState, AuthURL, Peer
|
|
|
|
|
case me = "Self"
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-20 14:52:10 -07:00
|
|
|
let peers = (status.Peer ?? [:]).values
|
|
|
|
|
let host = { (peer: Node) in
|
|
|
|
|
(peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName).lowercased()
|
|
|
|
|
}
|
|
|
|
|
let servers = peers.compactMap { peer -> URL? in
|
|
|
|
|
let name = host(peer).split(separator: ".").first ?? ""
|
2026-10-20 14:08:35 -07:00
|
|
|
guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-20 14:52:10 -07:00
|
|
|
return parseOrigin("https://\(host(peer))")
|
2026-10-20 14:08:35 -07:00
|
|
|
}
|
2026-10-20 14:52:10 -07:00
|
|
|
return (status.BackendState, URL(string: status.AuthURL), servers, Set(peers.map(host)))
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Blocks until the connection is made or fails.
|
|
|
|
|
func dial(_ address: String) throws -> Int32 {
|
|
|
|
|
var conn: tailscale_conn = 0
|
|
|
|
|
guard unsafe tailscale_dial(sd, "tcp", address, &conn) == 0 else {
|
|
|
|
|
throw ProcessFailure(errorDescription: "dial \(address) on the tailnet: \(Self.errorMessage(sd))")
|
|
|
|
|
}
|
|
|
|
|
return conn
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let tailnetNode = TailnetNode()
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
@Observable
|
|
|
|
|
final class Tailnet {
|
|
|
|
|
static let shared = Tailnet()
|
|
|
|
|
|
|
|
|
|
private(set) var state = "Starting"
|
|
|
|
|
private(set) var authURL: URL?
|
2026-10-20 14:08:35 -07:00
|
|
|
private(set) var service: TailnetInfo?
|
2026-10-20 14:20:35 -07:00
|
|
|
private(set) var searched = false
|
2026-10-20 14:52:10 -07:00
|
|
|
private(set) var peers: Set<String> = []
|
2026-10-20 14:08:35 -07:00
|
|
|
@ObservationIgnored private var servers: [URL] = []
|
2026-10-20 16:08:38 -07:00
|
|
|
@ObservationIgnored var serviceWatchers = 0
|
2026-10-20 13:32:56 -07:00
|
|
|
|
|
|
|
|
var running: Bool {
|
|
|
|
|
state == "Running"
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 14:52:10 -07:00
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 14:20:35 -07:00
|
|
|
var needsLogin: Bool {
|
|
|
|
|
state == "NeedsLogin" || state == "NeedsMachineAuth" || service?.state == "NeedsLogin"
|
2026-10-20 14:08:35 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-20 13:32:56 -07:00
|
|
|
private init() {
|
|
|
|
|
Task {
|
|
|
|
|
while true {
|
|
|
|
|
if let status = tailnetNode.status() {
|
|
|
|
|
state = status.state
|
|
|
|
|
authURL = status.authURL
|
2026-10-20 14:08:35 -07:00
|
|
|
servers = status.servers
|
2026-10-20 14:52:10 -07:00
|
|
|
if peers != status.peers {
|
|
|
|
|
peers = status.peers
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
try? await Task.sleep(for: .seconds(1))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-20 14:08:35 -07:00
|
|
|
Task {
|
|
|
|
|
await watchService()
|
|
|
|
|
}
|
|
|
|
|
Task {
|
|
|
|
|
await discover()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func watchService() async {
|
|
|
|
|
var loopback: URL?
|
2026-10-20 16:08:38 -07:00
|
|
|
var asked: ContinuousClock.Instant?
|
2026-10-20 14:08:35 -07:00
|
|
|
while true {
|
2026-10-20 16:08:38 -07:00
|
|
|
if serviceWatchers == 0, let asked, asked.duration(to: .now) < .seconds(30) {
|
|
|
|
|
try? await Task.sleep(for: .seconds(2))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
asked = .now
|
2026-10-20 14:08:35 -07:00
|
|
|
if loopback == nil {
|
|
|
|
|
do {
|
|
|
|
|
loopback = URL(string: try await Server.checks().loopback)
|
|
|
|
|
} catch {
|
|
|
|
|
log.error("find the local service: \(errorText(error), privacy: .public)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let loopback {
|
|
|
|
|
service = try? await Server.tailnet(loopback)
|
|
|
|
|
if let service, service.state == "Running", let origin = parseOrigin(service.origin), !Servers.shared.origins.contains(origin.absoluteString) {
|
|
|
|
|
Servers.shared.add(origin)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try? await Task.sleep(for: .seconds(2))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func discover() async {
|
|
|
|
|
while true {
|
2026-10-20 14:20:35 -07:00
|
|
|
guard running else {
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
let candidates = servers.filter { !Servers.shared.origins.contains($0.absoluteString) }
|
|
|
|
|
let found = await withTaskGroup(of: URL?.self) { group in
|
|
|
|
|
for origin in candidates {
|
|
|
|
|
group.addTask {
|
|
|
|
|
await Self.probe(origin) ? origin : nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var found: [URL] = []
|
|
|
|
|
for await origin in group {
|
|
|
|
|
if let origin {
|
|
|
|
|
found.append(origin)
|
2026-10-20 14:08:35 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-20 14:20:35 -07:00
|
|
|
return found
|
2026-10-20 14:08:35 -07:00
|
|
|
}
|
2026-10-20 14:20:35 -07:00
|
|
|
for origin in found {
|
|
|
|
|
log.notice("found \(origin.absoluteString, privacy: .public) on the tailnet")
|
|
|
|
|
Servers.shared.add(origin)
|
|
|
|
|
}
|
|
|
|
|
searched = true
|
2026-10-20 14:08:35 -07:00
|
|
|
try? await Task.sleep(for: .seconds(10))
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
2026-10-20 14:20:35 -07:00
|
|
|
|
|
|
|
|
private nonisolated static func probe(_ origin: URL) async -> Bool {
|
|
|
|
|
let api = API(origin: origin)
|
|
|
|
|
defer {
|
|
|
|
|
api.session.invalidateAndCancel()
|
|
|
|
|
}
|
|
|
|
|
return await withTaskGroup(of: Bool.self) { group in
|
|
|
|
|
group.addTask {
|
|
|
|
|
(try? await api.client.getStatus().ok) != nil
|
|
|
|
|
}
|
|
|
|
|
group.addTask {
|
|
|
|
|
try? await Task.sleep(for: .seconds(5))
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
let ok = await group.next() ?? false
|
|
|
|
|
group.cancelAll()
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct TailnetLoginView: View {
|
|
|
|
|
var body: some View {
|
|
|
|
|
let tailnet = Tailnet.shared
|
2026-10-20 14:08:35 -07:00
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
Text("byre reaches its servers through Tailscale. Sign each of these nodes in to the tailnet your byre servers are on.")
|
2026-10-20 13:32:56 -07:00
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-10-20 14:08:35 -07:00
|
|
|
NodeLoginRow(hostname: tailnetNode.hostname, role: "this app", state: tailnet.state, authURL: tailnet.authURL)
|
|
|
|
|
if let service = tailnet.service {
|
|
|
|
|
NodeLoginRow(hostname: service.hostname, role: "this Mac's server", state: service.state, authURL: URL(string: service.authUrl))
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 460)
|
|
|
|
|
.padding(.vertical, 24)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
2026-10-20 16:08:38 -07:00
|
|
|
.onAppear {
|
|
|
|
|
Tailnet.shared.serviceWatchers += 1
|
|
|
|
|
}
|
|
|
|
|
.onDisappear {
|
|
|
|
|
Tailnet.shared.serviceWatchers -= 1
|
|
|
|
|
}
|
2026-10-20 13:32:56 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-20 14:08:35 -07:00
|
|
|
|
|
|
|
|
private struct NodeLoginRow: View {
|
|
|
|
|
let hostname: String
|
|
|
|
|
let role: String
|
|
|
|
|
let state: String
|
|
|
|
|
let authURL: URL?
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Text(hostname).font(Fonts.font(13, weight: 700))
|
|
|
|
|
Text(role).foregroundStyle(Palette.muted)
|
|
|
|
|
}
|
|
|
|
|
if state == "Running" {
|
|
|
|
|
Text("Signed in").foregroundStyle(Palette.ok)
|
|
|
|
|
} else {
|
|
|
|
|
Button("Sign in to Tailscale") {
|
|
|
|
|
if let authURL {
|
|
|
|
|
NSWorkspace.shared.open(authURL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.disabled(authURL == nil)
|
|
|
|
|
Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…")
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|