From 519199eba400255e74dec16d3bff019349d403dd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 22 Oct 2026 08:11:32 -0700 Subject: [PATCH] Run the tailnet SOCKS proxy in Swift so the iPhone app reconnects --- docs/macos.md | 3 +- mac/Sources/Byre/Forwards.swift | 2 +- mac/Sources/Byre/Proxy.swift | 196 ++++++++++++++++++++++++++++++++ mac/Sources/Byre/Tailnet.swift | 19 +--- 4 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 mac/Sources/Byre/Proxy.swift diff --git a/docs/macos.md b/docs/macos.md index de00af0..fcbbbb1 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -26,7 +26,8 @@ Every build is signed for distribution outside the App Store: the team's Develop ## Startup and streams -- Startup waits until the app's tailnet node lists the server's node among its peers, not just until it is `Running`: 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". +- Startup waits until the app's tailnet node lists the server's node among its peers, not just until it is `Running`: a node starting from libtailscale's netmap cache reports `Running` with no peers before control sends the real netmap, and a dial made then fails. +- Every request reaches the tailnet through byre's own SOCKS5 proxy on a loopback port (`Proxy.swift`), which dials with `tailscale_dial`, not libtailscale's `tailscale_loopback`. iOS reclaims a suspended app's listening sockets, and tsnet's proxy stops for good on the first failed accept, leaving the phone unable to reach any server. byre's proxy binds again on the same port each time the app returns to the foreground. - Run byre on this Mac and the Server tab run `byre install` through the user's login shell, 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`. - SwiftUI's window restoration is off, but the scene keeps the id `main` that earlier builds used: AppKit restores their saved window by that id, and SwiftUI then opens no launch window at all when the id names no scene. diff --git a/mac/Sources/Byre/Forwards.swift b/mac/Sources/Byre/Forwards.swift index 9dfc4db..1d8fedf 100644 --- a/mac/Sources/Byre/Forwards.swift +++ b/mac/Sources/Byre/Forwards.swift @@ -181,7 +181,7 @@ private final class Forward: @unchecked Sendable { } } -private final class Splice: @unchecked Sendable { +final class Splice: @unchecked Sendable { private let conn: NWConnection private let fd: Int32 private let io: DispatchIO diff --git a/mac/Sources/Byre/Proxy.swift b/mac/Sources/Byre/Proxy.swift new file mode 100644 index 0000000..b842a5d --- /dev/null +++ b/mac/Sources/Byre/Proxy.swift @@ -0,0 +1,196 @@ +import Foundation +import Network +#if os(iOS) +import UIKit +#endif + +private let succeeded: [UInt8] = [5, 0, 0, 1, 0, 0, 0, 0, 0, 0] +private let hostUnreachable: UInt8 = 4 +private let commandNotSupported: UInt8 = 7 +private let addressNotSupported: UInt8 = 8 + +final class TailnetProxy: @unchecked Sendable { + let credential = (0..<32).map { _ in String(UInt8.random(in: 0..<16), radix: 16) }.joined() + private(set) var port: UInt16 = 0 + private let queue = DispatchQueue(label: "run.fc.byre.proxy") + private var listener: NWListener? + private var splices: [ObjectIdentifier: Splice] = [:] + + init() { + let bound = DispatchSemaphore(value: 0) + queue.async { + self.listen { + bound.signal() + } + } + bound.wait() + #if os(iOS) + NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [queue] _ in + queue.async { + self.listen() + } + } + #endif + } + + // iOS reclaims a suspended app's listening sockets, so the listener is bound again on the same port each time the app returns to the foreground. + private func listen(_ bound: (@Sendable () -> Void)? = nil) { + self.listener?.cancel() + self.listener = nil + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: port == 0 ? .any : NWEndpoint.Port(rawValue: port)!) + let listener: NWListener + do { + listener = try NWListener(using: params) + } catch { + failed(nil, error) + return + } + self.listener = listener + listener.stateUpdateHandler = { [weak self, weak listener] state in + guard let self, let listener, self.listener === listener else { + return + } + switch state { + case .ready: + if self.port == 0 { + self.port = listener.port!.rawValue + } + bound?() + case .failed(let error): + self.failed(listener, error) + default: + break + } + } + listener.newConnectionHandler = { [weak self] conn in + self?.accept(conn) + } + listener.start(queue: queue) + } + + private func failed(_ listener: NWListener?, _ error: any Error) { + if port == 0 { + fatalError("tailnet proxy listener: \(error)") + } + log.error("tailnet proxy listener on port \(self.port): \(String(describing: error), privacy: .public)") + listener?.cancel() + queue.asyncAfter(deadline: .now() + 1) { + if self.listener === listener { + self.listen() + } + } + } + + private func accept(_ conn: NWConnection) { + conn.start(queue: queue) + read(conn, 2) { head in + self.read(conn, Int(head[1])) { methods in + guard head[0] == 5, methods.contains(2) else { + self.send(conn, [5, 0xff], close: true) + return + } + self.send(conn, [5, 2]) + self.authenticate(conn) + } + } + } + + private func authenticate(_ conn: NWConnection) { + read(conn, 2) { head in + self.read(conn, Int(head[1])) { _ in + self.read(conn, 1) { length in + self.read(conn, Int(length[0])) { password in + guard head[0] == 1, String(decoding: password, as: UTF8.self) == self.credential else { + self.send(conn, [1, 1], close: true) + return + } + self.send(conn, [1, 0]) + self.request(conn) + } + } + } + } + } + + private func request(_ conn: NWConnection) { + read(conn, 4) { head in + guard head[0] == 5, head[1] == 1 else { + self.refuse(conn, commandNotSupported) + return + } + let connect = { @Sendable (host: String) in + self.read(conn, 2) { port in + self.connect(conn, "\(host):\(Int(port[0]) << 8 | Int(port[1]))") + } + } + switch head[3] { + case 1: + self.read(conn, 4) { ip in + connect(ip.map(String.init).joined(separator: ".")) + } + case 3: + self.read(conn, 1) { length in + self.read(conn, Int(length[0])) { name in + connect(String(decoding: name, as: UTF8.self)) + } + } + case 4: + self.read(conn, 16) { ip in + connect("[\(IPv6Address(Data(ip))!)]") + } + default: + self.refuse(conn, addressNotSupported) + } + } + } + + private func connect(_ conn: NWConnection, _ address: String) { + DispatchQueue.global().async { + do { + let fd = try tailnetNode.dial(address) + self.queue.async { + self.send(conn, succeeded) + let splice = Splice(conn, fd, self.queue) + let id = ObjectIdentifier(splice) + self.splices[id] = splice + splice.start { + self.splices[id] = nil + } + } + } catch { + log.error("tailnet proxy: \(errorText(error), privacy: .public)") + self.queue.async { + self.refuse(conn, hostUnreachable) + } + } + } + } + + private func refuse(_ conn: NWConnection, _ code: UInt8) { + send(conn, [5, code, 0, 1, 0, 0, 0, 0, 0, 0], close: true) + } + + private func read(_ conn: NWConnection, _ count: Int, _ then: @escaping @Sendable ([UInt8]) -> Void) { + if count == 0 { + then([]) + return + } + conn.receive(minimumIncompleteLength: count, maximumLength: count) { data, _, _, error in + guard error == nil, let data, data.count == count else { + conn.cancel() + return + } + then([UInt8](data)) + } + } + + private func send(_ conn: NWConnection, _ bytes: [UInt8], close: Bool = false) { + conn.send(content: Data(bytes), completion: .contentProcessed { _ in + if close { + conn.cancel() + } + }) + } +} diff --git a/mac/Sources/Byre/Tailnet.swift b/mac/Sources/Byre/Tailnet.swift index 617d124..7a238f5 100644 --- a/mac/Sources/Byre/Tailnet.swift +++ b/mac/Sources/Byre/Tailnet.swift @@ -27,9 +27,7 @@ func slug(_ text: String) -> String { final class TailnetNode: Sendable { let sd: tailscale let hostname = "byre-app-\(slug(deviceName))" - private let proxyHost: String - private let proxyPort: UInt16 - private let proxyCredential: String + private let socks = TailnetProxy() init() { let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac/tsnet", directoryHint: .isDirectory) @@ -43,17 +41,6 @@ final class TailnetNode: Sendable { 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[.. ProxyConfiguration { - let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: NWEndpoint.Host(proxyHost), port: NWEndpoint.Port(rawValue: proxyPort)!)) - proxy.applyCredential(username: "tsnet", password: proxyCredential) + let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: socks.port)!)) + proxy.applyCredential(username: "byre", password: socks.credential) return proxy }