Run the tailnet SOCKS proxy in Swift so the iPhone app reconnects

This commit is contained in:
Ian Gulliver
2026-10-22 08:11:32 -07:00
parent e28071f380
commit 519199eba4
4 changed files with 202 additions and 18 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ Every build is signed for distribution outside the App Store: the team's Develop
## Startup and streams ## 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`. - 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. - 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.
+1 -1
View File
@@ -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 conn: NWConnection
private let fd: Int32 private let fd: Int32
private let io: DispatchIO private let io: DispatchIO
+196
View File
@@ -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()
}
})
}
}
+3 -16
View File
@@ -27,9 +27,7 @@ func slug(_ text: String) -> String {
final class TailnetNode: Sendable { final class TailnetNode: Sendable {
let sd: tailscale let sd: tailscale
let hostname = "byre-app-\(slug(deviceName))" let hostname = "byre-app-\(slug(deviceName))"
private let proxyHost: String private let socks = TailnetProxy()
private let proxyPort: UInt16
private let proxyCredential: String
init() { init() {
let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac/tsnet", directoryHint: .isDirectory) 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 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, "start", tailscale_start(sd)) 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) { static func check(_ sd: tailscale, _ what: String, _ result: Int32) {
@@ -69,8 +56,8 @@ final class TailnetNode: Sendable {
} }
func proxy() -> ProxyConfiguration { func proxy() -> ProxyConfiguration {
let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: NWEndpoint.Host(proxyHost), port: NWEndpoint.Port(rawValue: proxyPort)!)) let proxy = ProxyConfiguration(socksv5Proxy: .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: socks.port)!))
proxy.applyCredential(username: "tsnet", password: proxyCredential) proxy.applyCredential(username: "byre", password: socks.credential)
return proxy return proxy
} }