Files
byre/mac/Sources/Byre/Tailnet.swift
T

347 lines
12 KiB
Swift

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, "set logfd", tailscale_set_logfd(sd, Self.logPipe()))
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))")
}
}
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 {
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
}
func status() -> (state: String, authURL: URL?, servers: [URL], peers: Set<String>)? {
var out: UnsafeMutablePointer<CChar>?
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
return nil
}
defer {
unsafe free(out)
}
struct Node: Decodable {
let DNSName: String
let UserID: Int64
let Online: Bool
}
struct Status: Decodable {
let BackendState: String
let AuthURL: String
let me: Node?
let Peer: [String: Node]?
enum CodingKeys: String, CodingKey {
case BackendState, AuthURL, Peer
case me = "Self"
}
}
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
return nil
}
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 ?? ""
guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else {
return nil
}
return parseOrigin("https://\(host(peer))")
}
return (status.BackendState, URL(string: status.AuthURL), servers, Set(peers.map(host)))
}
// 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
}
}
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()
@MainActor
@Observable
final class Tailnet {
static let shared = Tailnet()
private(set) var state = "Starting"
private(set) var authURL: URL?
private(set) var service: TailnetInfo?
private(set) var searched = false
private(set) var peers: Set<String> = []
@ObservationIgnored private var servers: [URL] = []
var running: Bool {
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 {
state == "NeedsLogin" || state == "NeedsMachineAuth" || service?.state == "NeedsLogin"
}
private init() {
Task {
while true {
if let status = tailnetNode.status() {
state = status.state
authURL = status.authURL
servers = status.servers
if peers != status.peers {
peers = status.peers
}
}
try? await Task.sleep(for: .seconds(1))
}
}
Task {
await watchService()
}
Task {
await discover()
}
}
private func watchService() async {
var loopback: URL?
while true {
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 {
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)
}
}
return found
}
for origin in found {
log.notice("found \(origin.absoluteString, privacy: .public) on the tailnet")
Servers.shared.add(origin)
}
searched = true
try? await Task.sleep(for: .seconds(10))
}
}
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
}
}
}
struct TailnetLoginView: View {
var body: some View {
let tailnet = Tailnet.shared
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.")
.fixedSize(horizontal: false, vertical: true)
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))
}
}
.frame(width: 460)
.padding(.vertical, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
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)
}
}
}
}