From 2a63eab905306d0625a3c0a1e146724d9d6cf42d Mon Sep 17 00:00:00 2001 From: Jhen-Jie Hong Date: Thu, 23 Jul 2026 20:37:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Add=20Connect=20via=20Tailscale=20over=20ta?= =?UTF-8?q?ilscale-rs=20=E2=80=94=20all=20three=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second, parallel embedded-Tailscale host option backed by tailscale-rs (Rust) rather than Go libtailscale (PR #12). Because Rust has an aarch64-apple-visionos target, the feature links and runs on Vision Pro — the platform the Go path can never reach. SSH is dialed through an in-process tsnet node; no system VPN, no NetworkExtension. Backend deltas from the Go path, all driven by the rs C ABI (CTailscaleRS): - The node identity is app-owned. tailscale-rs takes the three 32-byte node keys as an input and never exports them, so the app generates them with SecRandom and persists the 96 bytes in the Keychain — the tailnet node survives launches with no plaintext state directory, satisfying the keychain-only house rule the Go path had to except. The Go force-login constructor hack is gone (the auth key is a ts_init parameter). - A tailnet connection is an opaque handle with blocking send/recv, not an fd, so the one-shot loopback relay's remote side is now a TailscaleRelayRemote protocol (handle-backed for real dials, fd-backed for the DEBUG fake dial). Citadel still dials 127.0.0.1 through its ordinary bootstrap — the inEventLoop constraint that forced the relay is unchanged. Half-close forwards through the seam; the handle has no half-close so full teardown rides relay.close() on SSHConnection.close(). - ts_init hangs on a rejected auth key, so init is raced against a 30 s deadline. TS_RS_EXPERIMENT is set before init (upstream gates the FFI on it); all peer traffic relays through public DERP today. The UI compiles on all three platforms (no visionOS-disabled apology), carries experimental/relayed copy and the missing-auth-key tip, and keeps mosh mutually exclusive for v1 (the rs UDP ABI can lift that later). The four static archives (incl. xros) are git-ignored; Tools/build-tailscale-rs.sh rebuilds them at the pinned commit + one upstream patch. Full record: local-plan/tailscale-rs-investigation.md. --- .gitignore | 4 + AGENTS.md | 8 + Multiplex/Models/Host.swift | 5 + Multiplex/Models/TailscaleRSDialAddress.swift | 75 +++ Multiplex/Services/HostStore.swift | 7 + Multiplex/Services/HostTest.swift | 2 + Multiplex/Services/KeychainStore.swift | 18 + Multiplex/Services/Mosh/MoshBootstrap.swift | 6 + Multiplex/Services/SSHConnection.swift | 45 +- .../Tailscale/TailscaleLoopbackRelay.swift | 289 ++++++++++ .../Services/Tailscale/TailscaleTunnel.swift | 540 ++++++++++++++++++ Multiplex/Views/Deck/AddHostSheet.swift | 80 ++- Multiplex/Views/Settings/SettingsView.swift | 122 +++- MultiplexTests/HostSyncTests.swift | 17 + MultiplexTests/MoshBootstrapTests.swift | 25 + .../TailscaleLoopbackRelayTests.swift | 137 +++++ .../TailscaleRSDialAddressTests.swift | 73 +++ Tools/build-tailscale-rs.sh | 67 +++ Vendor/tailscale-rs/LICENSE | 28 + Vendor/tailscale-rs/README.md | 50 ++ Vendor/tailscale-rs/include/module.modulemap | 4 + Vendor/tailscale-rs/include/tailscale.h | 471 +++++++++++++++ .../patches/ts_netmon-apple-mobile-cfg.patch | 17 + project.yml | 16 + 24 files changed, 2096 insertions(+), 10 deletions(-) create mode 100644 Multiplex/Models/TailscaleRSDialAddress.swift create mode 100644 Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift create mode 100644 Multiplex/Services/Tailscale/TailscaleTunnel.swift create mode 100644 MultiplexTests/TailscaleLoopbackRelayTests.swift create mode 100644 MultiplexTests/TailscaleRSDialAddressTests.swift create mode 100755 Tools/build-tailscale-rs.sh create mode 100644 Vendor/tailscale-rs/LICENSE create mode 100644 Vendor/tailscale-rs/README.md create mode 100644 Vendor/tailscale-rs/include/module.modulemap create mode 100644 Vendor/tailscale-rs/include/tailscale.h create mode 100644 Vendor/tailscale-rs/patches/ts_netmon-apple-mobile-cfg.patch diff --git a/.gitignore b/.gitignore index 9eab3a86..aad182f7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ xcuserdata/ # Dev harness artifacts (generated keys, sshd runtime state) Tools/dev-sshd/state/ +# Vendored tailscale-rs static archives (~17-44 MB each) — rebuild with +# Tools/build-tailscale-rs.sh; the header/modulemap/patch stay tracked +Vendor/tailscale-rs/lib/ + local-plan/ # fastlane — generated reports and local secrets (see fastlane/SETUP.md) diff --git a/AGENTS.md b/AGENTS.md index 70ad8ea0..bc839e55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,14 @@ vars to drive the real SSH→PTY→tmux→SwiftTerm path headlessly: agent:cc 'Select login method:' Enter` — cat echoes it) into the harness's fake Claude pane and the rail tip renders without a genuinely locked keychain. +- `MULTIPLEX_SEED_TAILSCALE=1` — flips the seeded host to Connect via + Tailscale (mosh off), for headless checks of the tailscale connect seam. +- `MULTIPLEX_TAILSCALE_FAKE_DIAL=1` — tailscale dials become plain TCP + sockets (no tsnet node), so the loopback-relay → Citadel path can be + proven end-to-end against the harness sshd without a tailnet. +- `MULTIPLEX_TAILSCALE_AUTHKEY=tskey-…` — overrides the stored Tailscale + auth key (never persisted), for driving a real embedded-node login + headlessly; tailscale-rs logs to stderr (Xcode console) via `RUST_LOG`. - `MULTIPLEX_APP_LOCK=1|held` — starts this launch behind the app-lock veil regardless of the persisted setting (never persisted, like `MULTIPLEX_PRO_LOCKED`). `1` keeps the real authenticator — the veil's diff --git a/Multiplex/Models/Host.swift b/Multiplex/Models/Host.swift index f0c1b973..79184389 100644 --- a/Multiplex/Models/Host.swift +++ b/Multiplex/Models/Host.swift @@ -25,6 +25,10 @@ struct Host: Identifiable, Codable, Hashable { /// The credentials above still authenticate the SSH bootstrap that /// launches `mosh-server`; deck probing stays on SSH either way. var useMosh: Bool = false + /// Reach this host's SSH endpoint through the app's embedded userspace + /// Tailscale node (tailscale-rs backend). Works on all three platforms; + /// for v1 it cannot carry mosh's datagram transport. + var useTailscale: Bool = false /// Absolute path to `mosh-server` when it isn't on the exec PATH. var moshServerPath: String? /// UDP port or range ("60000:61000") handed to `mosh-server -p`. @@ -80,6 +84,7 @@ extension Host { username = try container.decode(String.self, forKey: .username) authMethod = try container.decodeIfPresent(AuthMethod.self, forKey: .authMethod) ?? .password useMosh = try container.decodeIfPresent(Bool.self, forKey: .useMosh) ?? false + useTailscale = try container.decodeIfPresent(Bool.self, forKey: .useTailscale) ?? false moshServerPath = try container.decodeIfPresent(String.self, forKey: .moshServerPath) moshPorts = try container.decodeIfPresent(String.self, forKey: .moshPorts) workingDirs = try container.decodeIfPresent([String].self, forKey: .workingDirs) ?? [] diff --git a/Multiplex/Models/TailscaleRSDialAddress.swift b/Multiplex/Models/TailscaleRSDialAddress.swift new file mode 100644 index 00000000..65d1e28e --- /dev/null +++ b/Multiplex/Models/TailscaleRSDialAddress.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Pure classification of a Host's address for the tailscale-rs dial path. +/// tailscale-rs has no MagicDNS: a literal 100.x/IPv6 is parsed straight to +/// a sockaddr, while a hostname must be resolved to a peer IP via +/// `ts_peer_ipv4_addr` before dialing. This type decides which, and +/// normalizes the string the C layer parses — the actual `ts_*` calls stay +/// in the actor, so this stays module-free and unit-testable. +enum TailscaleRSDialAddress { + enum Target: Equatable { + /// A literal address `ts_parse_ip` can consume directly. + case literalIP(String) + /// A tailnet peer name to resolve via `ts_peer_ipv4_addr`. Any + /// surrounding brackets are stripped — peer lookup wants the bare + /// name. + case peerName(String) + } + + static func classify(hostname: String) -> Target { + let trimmed = hostname.trimmingCharacters(in: .whitespaces) + let unbracketed: String + if trimmed.hasPrefix("["), trimmed.hasSuffix("]"), trimmed.count >= 2 { + unbracketed = String(trimmed.dropFirst().dropLast()) + } else { + unbracketed = trimmed + } + + if isIPv4(unbracketed) || isIPv6(unbracketed) { + return .literalIP(unbracketed) + } + return .peerName(unbracketed) + } + + static func isIPv4(_ s: String) -> Bool { + let parts = s.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + part.count >= 1 && part.count <= 3 + && part.allSatisfy(\.isNumber) + && (Int(part).map { $0 >= 0 && $0 <= 255 } ?? false) + } + } + + static func isIPv6(_ s: String) -> Bool { + // Loose but sufficient to tell a v6 literal from a hostname: hex + // groups and at least one colon, no characters a DNS/tailnet name + // would carry. `ts_parse_ip` is the real validator downstream. + guard s.contains(":") else { return false } + let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.") + return s.unicodeScalars.allSatisfy { allowed.contains($0) } + } +} + +enum TailscaleNodeHostname { + static func format(deviceName: String) -> String { + var sanitized = "" + var needsSeparator = false + + for scalar in deviceName.lowercased().unicodeScalars { + let isLowercaseLetter = scalar.value >= 97 && scalar.value <= 122 + let isDigit = scalar.value >= 48 && scalar.value <= 57 + if isLowercaseLetter || isDigit { + if needsSeparator, !sanitized.isEmpty { + sanitized.append("-") + } + sanitized.unicodeScalars.append(scalar) + needsSeparator = false + } else if !sanitized.isEmpty { + needsSeparator = true + } + } + + return sanitized.isEmpty ? "multiplex" : "multiplex-\(sanitized)" + } +} diff --git a/Multiplex/Services/HostStore.swift b/Multiplex/Services/HostStore.swift index f18c0812..117ab8d3 100644 --- a/Multiplex/Services/HostStore.swift +++ b/Multiplex/Services/HostStore.swift @@ -285,6 +285,13 @@ final class HostStore { // Absent mosh keys leave the host's current setting alone, so a // hand-trimmed seed doesn't silently flip transports. if let useMosh = seed.useMosh { host.useMosh = useMosh } + // Headless tailscale-seam checks flip the seeded host without + // touching the shared seed.json (pairs with + // MULTIPLEX_TAILSCALE_FAKE_DIAL). + if ProcessInfo.processInfo.environment["MULTIPLEX_SEED_TAILSCALE"] == "1" { + host.useTailscale = true + host.useMosh = false + } if let path = seed.moshServerPath { host.moshServerPath = path } if let ports = seed.moshPorts { host.moshPorts = ports } // Optional so existing seeds leave the host's dirs alone; used by diff --git a/Multiplex/Services/HostTest.swift b/Multiplex/Services/HostTest.swift index 317b974e..7fd42d4f 100644 --- a/Multiplex/Services/HostTest.swift +++ b/Multiplex/Services/HostTest.swift @@ -82,6 +82,8 @@ enum HostTest { return "That passphrase didn't unlock the private key. Try again." case .unsupportedKey: return "The private key couldn't be read. Paste an OpenSSH ed25519 or RSA key, including its BEGIN/END lines." + case .tailscaleUnavailable: + return ssh.userMessage(host: host) case .connectFailed(let detail): return connectFailureMessage(detail, host: host) case .notConnected: diff --git a/Multiplex/Services/KeychainStore.swift b/Multiplex/Services/KeychainStore.swift index 48b69f50..7308a75a 100644 --- a/Multiplex/Services/KeychainStore.swift +++ b/Multiplex/Services/KeychainStore.swift @@ -20,6 +20,15 @@ enum KeychainStore { case password case privateKey case keyPassphrase + /// The app-wide Tailscale auth key (tailscale-rs backend), stored + /// under a fixed namespace UUID rather than a real host. + case tailscaleAuthKey + /// The app-generated tailnet node identity: 96 bytes + /// (node ‖ machine ‖ network-lock, 32 each). tailscale-rs takes the + /// key state as an input and never exports it, so the app owns and + /// persists it here — this is why no plaintext state directory is + /// needed. Under the same fixed namespace UUID as the auth key. + case tailscaleKeyState } private static func account(_ hostID: UUID, _ kind: Kind) -> String { @@ -41,6 +50,15 @@ enum KeychainStore { deleteItem(service: secretService, account: account(hostID, kind)) } + /// Binary-secret accessors for the tailnet node identity (not UTF-8). + static func setData(_ value: Data, for hostID: UUID, kind: Kind) { + setItem(value, service: secretService, account: account(hostID, kind)) + } + + static func getData(for hostID: UUID, kind: Kind) -> Data? { + getItem(service: secretService, account: account(hostID, kind)) + } + static func delete(for hostID: UUID) { for kind in [Kind.password, .privateKey, .keyPassphrase] { deleteItem(service: secretService, account: account(hostID, kind)) diff --git a/Multiplex/Services/Mosh/MoshBootstrap.swift b/Multiplex/Services/Mosh/MoshBootstrap.swift index afc02fb7..05c02ffc 100644 --- a/Multiplex/Services/Mosh/MoshBootstrap.swift +++ b/Multiplex/Services/Mosh/MoshBootstrap.swift @@ -2,6 +2,7 @@ import Foundation enum MoshBootstrapError: Error { case dnsFailure + case tailscaleIncompatible case sshFailed(String) case serverFailed(String) @@ -9,6 +10,8 @@ enum MoshBootstrapError: Error { switch self { case .dnsFailure: "Couldn't resolve \(host.hostname)." + case .tailscaleIncompatible: + "mosh can't run over the embedded Tailscale connection — turn one of them off." case .sshFailed(let detail): "Couldn't reach \(host.name) to start mosh (\(detail))." case .serverFailed(let detail): @@ -138,6 +141,9 @@ enum MoshBootstrap { // MARK: - The bootstrap itself static func start(host: Host, secrets: HostSecrets, remoteCommand: String?) async throws -> Target { + guard !(host.useTailscale && host.useMosh) else { + throw MoshBootstrapError.tailscaleIncompatible + } let addresses = resolve(host.hostname) guard !addresses.isEmpty else { throw MoshBootstrapError.dnsFailure } diff --git a/Multiplex/Services/SSHConnection.swift b/Multiplex/Services/SSHConnection.swift index 5475ae0f..29dac8e1 100644 --- a/Multiplex/Services/SSHConnection.swift +++ b/Multiplex/Services/SSHConnection.swift @@ -158,6 +158,7 @@ enum SSHConnectionError: Error { case keyPassphraseRequired case incorrectKeyPassphrase case unsupportedKey + case tailscaleUnavailable case connectFailed(String) case notConnected @@ -165,7 +166,9 @@ enum SSHConnectionError: Error { switch self { case .keyPassphraseRequired: .required case .incorrectKeyPassphrase: .incorrect - case .missingCredentials, .unsupportedKey, .connectFailed, .notConnected: nil + case .missingCredentials, .unsupportedKey, .tailscaleUnavailable, + .connectFailed, .notConnected: + nil } } @@ -179,6 +182,8 @@ enum SSHConnectionError: Error { "The passphrase didn't unlock the private key for \(host.name). Try again." case .unsupportedKey: "The private key for \(host.name) couldn't be read. Paste an OpenSSH ed25519 or RSA key." + case .tailscaleUnavailable: + "The Tailscale backend isn't available in this build." case .connectFailed(let detail): "Couldn't reach \(host.name) (\(detail))." case .notConnected: @@ -270,11 +275,47 @@ actor SSHConnection { task = inFlight generation = connectGeneration } else { + #if !canImport(CTailscaleRS) + if host.useTailscale { + throw SSHConnectionError.tailscaleUnavailable + } + #endif let method = try Self.makeAuthenticationMethod(host: host, secrets: secrets) connectGeneration &+= 1 generation = connectGeneration task = Task { - try await SSHClient.connect( + if host.useTailscale { + #if canImport(CTailscaleRS) + let remote = try await TailscaleTunnel.shared.dial( + hostname: host.hostname, + port: host.port + ) + // Citadel's channel-injection overload asserts + // inEventLoop in its synchronous prefix, so the tailnet + // connection is spliced through a one-shot localhost + // relay and Citadel dials it via its ordinary bootstrap. + // The relay tears itself down when either side closes, so + // the client's own close() is its lifetime owner. + let relay = TailscaleLoopbackRelay() + let relayPort = try relay.start(spliceTo: remote) + do { + return try await SSHClient.connect( + host: "127.0.0.1", + port: Int(relayPort), + authenticationMethod: method, + hostKeyValidator: .acceptAnything(), + reconnect: .never + ) + } catch { + relay.close() + throw error + } + #else + throw SSHConnectionError.tailscaleUnavailable + #endif + } + + return try await SSHClient.connect( host: host.hostname, port: host.port, authenticationMethod: method, diff --git a/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift b/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift new file mode 100644 index 00000000..bf212163 --- /dev/null +++ b/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift @@ -0,0 +1,289 @@ +import Darwin +import Dispatch +import Foundation +import os + +/// The far side of the relay — a tailnet connection the local SSH client's +/// bytes are spliced onto. tailscale-rs hands back an opaque `ts_tcp_stream` +/// handle with blocking send/recv (NOT an fd), so the relay pumps through +/// this seam instead of a second file descriptor. The DEBUG fake-dial path +/// supplies an fd-backed remote so the whole splice is exercisable without a +/// node. `recv`/`send` mirror POSIX semantics: recv returns >0 bytes, 0 on +/// EOF, <0 on error; send returns bytes written (may be partial) or <0. +protocol TailscaleRelayRemote: Sendable { + func recv(into buffer: UnsafeMutableRawBufferPointer) -> Int + func send(_ buffer: UnsafeRawBufferPointer) -> Int + /// Half-close the write side after the local side stops sending, so the + /// peer sees EOF rather than stalling. A no-op where the transport has no + /// half-close (the tailnet handle) — there, full teardown rides `close()` + /// via the relay when the SSH client tears down. + func shutdownWrite() + func close() +} + +/// One-shot localhost TCP relay between Citadel and a tailnet connection. +/// Citadel 0.12.0's channel-injection overload cannot be called off the +/// event loop — its synchronous prefix asserts `inEventLoop` (NIOCore +/// ChannelPipeline.swift:1208) — and pinning the calling Task to the loop +/// needs iOS 18, above the app's floor. So Citadel dials `127.0.0.1:` +/// through its wholly ordinary bootstrap (handlers installed on-loop by its +/// own initializer) and this relay splices that connection onto the remote. +/// +/// iOS loopback is reachable cross-app, so the listener accepts exactly ONE +/// connection and closes immediately; a peer that never connects is bounded +/// by the accept poll timeout. The relay stays alive by self-retention in +/// its queue work items and tears itself down on EOF from either side — the +/// SSH client closing its localhost socket is what releases the tailnet +/// connection, so callers don't need to hold a reference. +final class TailscaleLoopbackRelay: @unchecked Sendable { + private static let logger = Logger( + subsystem: "app.multiplexterm.multiplex", + category: "tailscale" + ) + private static let acceptTimeoutMilliseconds: Int32 = 10_000 + private static let pumpBufferSize = 64 * 1024 + + private let queue = DispatchQueue( + label: "app.multiplexterm.multiplex.tailscale.relay", + attributes: .concurrent + ) + private let lock = NSLock() + private var listenerFD: CInt = -1 + private var acceptedFD: CInt = -1 + private var wakePipe: (read: CInt, write: CInt) = (-1, -1) + private var remote: TailscaleRelayRemote? + private var finishedPumps = 0 + private var isClosed = false + + struct Failure: Error, LocalizedError { + let message: String + var errorDescription: String? { message } + } + + /// Takes ownership of `remote` unconditionally: on throw it is closed, on + /// success the relay closes it when the splice ends. + func start(spliceTo remote: TailscaleRelayRemote) throws -> UInt16 { + guard lock.withLock({ !isClosed }) else { + remote.close() + throw Failure(message: "Relay already closed.") + } + let listener = socket(AF_INET, SOCK_STREAM, 0) + guard listener >= 0 else { + remote.close() + throw Failure(message: "Relay socket creation failed (errno \(errno)).") + } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = 0 + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(listener, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0, listen(listener, 1) == 0 else { + Darwin.close(listener) + remote.close() + throw Failure(message: "Relay bind/listen failed (errno \(errno)).") + } + + var bound = sockaddr_in() + var boundLength = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(listener, $0, &boundLength) + } + } + guard nameResult == 0 else { + Darwin.close(listener) + remote.close() + throw Failure(message: "Relay port lookup failed (errno \(errno)).") + } + let port = UInt16(bigEndian: bound.sin_port) + + var pipeFDs: [CInt] = [-1, -1] + guard pipe(&pipeFDs) == 0 else { + Darwin.close(listener) + remote.close() + throw Failure(message: "Relay wake pipe failed (errno \(errno)).") + } + + lock.withLock { + self.listenerFD = listener + self.remote = remote + self.wakePipe = (pipeFDs[0], pipeFDs[1]) + } + + queue.async { self.acceptOnce() } + Self.logger.debug("relay listening on 127.0.0.1:\(port)") + return port + } + + /// Idempotent teardown. Wakes a blocked accept poll via the pipe and + /// unblocks the local pump with shutdown; the pumps then close both + /// sides. + func close() { + let (wakeWrite, hadStarted): (CInt, Bool) = lock.withLock { + guard !isClosed else { return (-1, false) } + isClosed = true + if acceptedFD >= 0 { shutdown(acceptedFD, SHUT_RDWR) } + remote?.close() + return (wakePipe.write, true) + } + guard hadStarted else { return } + if wakeWrite >= 0 { + var byte: UInt8 = 0 + _ = write(wakeWrite, &byte, 1) + } + } + + private func acceptOnce() { + let (listener, wakeRead) = lock.withLock { (listenerFD, wakePipe.read) } + guard listener >= 0 else { return } + + var fds = [ + pollfd(fd: listener, events: Int16(POLLIN), revents: 0), + pollfd(fd: wakeRead, events: Int16(POLLIN), revents: 0), + ] + var pollResult: Int32 + repeat { + pollResult = poll(&fds, 2, Self.acceptTimeoutMilliseconds) + } while pollResult < 0 && errno == EINTR + + let listenerReadable = pollResult > 0 && (fds[0].revents & Int16(POLLIN)) != 0 + let wasClosed = lock.withLock { isClosed } + guard listenerReadable, !wasClosed else { + Self.logger.debug("relay accept ended without a connection (poll \(pollResult))") + closeEverything() + return + } + + let accepted = accept(listener, nil, nil) + // One-shot: no second connection can ever be accepted, and the + // cross-app-visible listening port disappears immediately. + lock.withLock { + Darwin.close(listenerFD) + listenerFD = -1 + acceptedFD = accepted + } + guard accepted >= 0 else { + Self.logger.debug("relay accept failed (errno \(errno))") + closeEverything() + return + } + + queue.async { self.pumpLocalToRemote(accepted) } + queue.async { self.pumpRemoteToLocal(accepted) } + } + + private func pumpLocalToRemote(_ local: CInt) { + guard let remote = lock.withLock({ self.remote }) else { return } + var buffer = [UInt8](repeating: 0, count: Self.pumpBufferSize) + outer: while true { + var bytesRead: Int + repeat { + bytesRead = read(local, &buffer, buffer.count) + } while bytesRead < 0 && errno == EINTR + guard bytesRead > 0 else { break } + + var offset = 0 + while offset < bytesRead { + let written = buffer[offset.. 0 else { break outer } + offset += written + } + } + // Forward the half-close so the tailnet peer sees EOF rather than a + // stall; the handle/fd closes only once both directions end. + remote.shutdownWrite() + pumpFinished() + } + + private func pumpRemoteToLocal(_ local: CInt) { + guard let remote = lock.withLock({ self.remote }) else { return } + var buffer = [UInt8](repeating: 0, count: Self.pumpBufferSize) + while true { + let bytesRead = buffer.withUnsafeMutableBytes { remote.recv(into: $0) } + guard bytesRead > 0 else { break } + + var offset = 0 + var failed = false + while offset < bytesRead { + var written: Int + repeat { + written = buffer[offset.. 0 else { failed = true; break } + offset += written + } + if failed { break } + } + // Forward the half-close so the SSH client's read sees EOF rather + // than a stall; fds/handle close only once both directions end. + shutdown(local, SHUT_WR) + pumpFinished() + } + + private func pumpFinished() { + let finished = lock.withLock { + finishedPumps += 1 + return finishedPumps + } + if finished == 2 { + closeEverything() + } + } + + private func closeEverything() { + let remoteToClose: TailscaleRelayRemote? = lock.withLock { + isClosed = true + for fd in [listenerFD, acceptedFD, wakePipe.read, wakePipe.write] where fd >= 0 { + Darwin.close(fd) + } + listenerFD = -1 + acceptedFD = -1 + wakePipe = (-1, -1) + let remote = self.remote + self.remote = nil + return remote + } + remoteToClose?.close() + Self.logger.debug("relay closed") + } +} + +/// fd-backed remote for the DEBUG fake-dial path (a plain kernel TCP socket +/// to the harness), so the relay is exercised end-to-end without a node. +struct TailscaleFDRemote: TailscaleRelayRemote { + let fd: CInt + + func recv(into buffer: UnsafeMutableRawBufferPointer) -> Int { + var result: Int + repeat { + result = read(fd, buffer.baseAddress, buffer.count) + } while result < 0 && errno == EINTR + return result + } + + func send(_ buffer: UnsafeRawBufferPointer) -> Int { + var result: Int + repeat { + result = write(fd, buffer.baseAddress, buffer.count) + } while result < 0 && errno == EINTR + return result + } + + func shutdownWrite() { + shutdown(fd, SHUT_WR) + } + + func close() { + Darwin.close(fd) + } +} diff --git a/Multiplex/Services/Tailscale/TailscaleTunnel.swift b/Multiplex/Services/Tailscale/TailscaleTunnel.swift new file mode 100644 index 00000000..e9195c05 --- /dev/null +++ b/Multiplex/Services/Tailscale/TailscaleTunnel.swift @@ -0,0 +1,540 @@ +#if canImport(CTailscaleRS) +import CTailscaleRS +import Darwin +import Foundation +import Security +import UIKit +import os + +struct TailscaleTunnelFailure: Error, LocalizedError, CustomStringConvertible, Sendable { + let message: String + var errorDescription: String? { message } + var description: String { message } +} + +/// One userspace tailnet node for this app install, over tailscale-rs +/// (`CTailscaleRS`). Unlike the Go libtailscale path, this backend takes the +/// node identity as an *input* and never writes a state directory: the app +/// generates the three 32-byte private keys itself and persists them in the +/// Keychain, so the "secrets never touch disk in plaintext" house rule holds +/// without exception. Backed by an experimental upstream — the FFI is +/// gated behind `TS_RS_EXPERIMENT` and all peer traffic relays through +/// public DERP servers today. +actor TailscaleTunnel { + enum State: Equatable, Sendable { + case stopped + case starting + case running(ips: [String]) + } + + struct Configuration: Equatable, Sendable { + var authKey: String + var controlURL: String + } + + static let shared = TailscaleTunnel() + private static let logger = Logger( + subsystem: "app.multiplexterm.multiplex", + category: "tailscale" + ) + /// Namespace UUID the app-wide auth key and node key-state live under in + /// the Keychain (not a real host). + static let identityNamespace = UUID( + uuidString: "7BD81B2F-B868-4C52-A968-70A03B65CB23" + )! + + private static let controlURLDefaultsKey = "TailscaleControlURL" + private static let startupTimeout: TimeInterval = 30 + private static let keyStateByteCount = 96 + + /// Blocking FFI calls run here — the ts_ffi runtime serializes + /// internally, so this is a concurrent queue used only to keep the + /// blocking `block_on` off the Swift cooperative pool. A rejected-key + /// `ts_init` can occupy one of its threads indefinitely without wedging + /// unrelated calls. + private let ffiQueue = DispatchQueue( + label: "app.multiplexterm.multiplex.tailscale", + attributes: .concurrent + ) + private let deadlineQueue = DispatchQueue( + label: "app.multiplexterm.multiplex.tailscale.deadline" + ) + + private(set) var state: State = .stopped + private var stateObservers: [UUID: AsyncStream.Continuation] = [:] + private var device: OpaquePointer? + private var startTask: Task? + private static let envConfigured: Void = { + setenv("TS_RS_EXPERIMENT", "this_is_unstable_software", 1) + #if !DEBUG + // Keep the release console quiet; DEBUG keeps INFO for field + // diagnosis. tailscale-rs logs to stderr via RUST_LOG only — there + // is no logfd/callback surface. + setenv("RUST_LOG", "error", 1) + #endif + }() + + func stateUpdates() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + stateObservers[id] = pair.continuation + pair.continuation.yield(state) + pair.continuation.onTermination = { @Sendable _ in + Task { await self.removeStateObserver(id) } + } + return pair.stream + } + + /// Returns the far side of the SSH splice — a tailnet TCP connection + /// wrapped as a `TailscaleRelayRemote`. The relay owns it thereafter. + func dial(hostname: String, port: Int) async throws -> TailscaleRelayRemote { + #if DEBUG + // Headless seam proof without a tailnet: the relay + Citadel path is + // identical whether the remote is a tailnet handle or a plain TCP + // socket to the harness sshd. + if ProcessInfo.processInfo.environment["MULTIPLEX_TAILSCALE_FAKE_DIAL"] == "1" { + let fd = try await Self.performBlocking(on: ffiQueue) { + try Self.debugPlainSocket(hostname: hostname, port: port) + } + return TailscaleFDRemote(fd: fd) + } + #endif + try await ensureRunning() + guard let device else { + throw TailscaleTunnelFailure( + message: "The embedded Tailscale node stopped before dialing." + ) + } + + return try await Self.performBlocking(on: ffiQueue) { + let stream = try Self.connect(device: device, hostname: hostname, port: port) + return TailscaleHandleRemote(stream: stream) + } + } + + static func loadConfiguration() async -> Configuration { + await Task.detached(priority: .userInitiated) { + #if DEBUG + if let override = ProcessInfo.processInfo + .environment["MULTIPLEX_TAILSCALE_AUTHKEY"], !override.isEmpty { + return Configuration( + authKey: override, + controlURL: UserDefaults.standard.string(forKey: controlURLDefaultsKey) ?? "" + ) + } + #endif + return Configuration( + authKey: KeychainStore.get(for: identityNamespace, kind: .tailscaleAuthKey) ?? "", + controlURL: UserDefaults.standard.string(forKey: controlURLDefaultsKey) ?? "" + ) + }.value + } + + static func saveConfiguration(_ configuration: Configuration) async { + await Task.detached(priority: .userInitiated) { + let authKey = configuration.authKey.trimmingCharacters(in: .whitespacesAndNewlines) + if authKey.isEmpty { + KeychainStore.delete(for: identityNamespace, kind: .tailscaleAuthKey) + } else { + KeychainStore.set(authKey, for: identityNamespace, kind: .tailscaleAuthKey) + } + + let controlURL = configuration.controlURL.trimmingCharacters(in: .whitespacesAndNewlines) + if controlURL.isEmpty { + UserDefaults.standard.removeObject(forKey: controlURLDefaultsKey) + } else { + UserDefaults.standard.set(controlURL, forKey: controlURLDefaultsKey) + } + }.value + } + + private func ensureRunning() async throws { + if device != nil, case .running = state { return } + if let startTask { + try await finishStart(startTask) + return + } + + transition(to: .starting) + let ffiQueue = ffiQueue + let deadlineQueue = deadlineQueue + let task = Task { + let configuration = await Self.loadConfiguration() + let authKey = configuration.authKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !authKey.isEmpty else { + throw TailscaleTunnelFailure(message: "Add a Tailscale auth key in Settings.") + } + let deviceName = await MainActor.run { UIDevice.current.name } + let keyState = await Self.loadOrCreateKeyState() + return try await Self.startNode( + configuration: Configuration(authKey: authKey, controlURL: configuration.controlURL), + deviceName: deviceName, + keyState: keyState, + ffiQueue: ffiQueue, + deadlineQueue: deadlineQueue + ) + } + startTask = task + try await finishStart(task) + } + + private func finishStart(_ task: Task) async throws { + do { + let started = try await task.value + if device == nil { + device = started.device + transition(to: .running(ips: started.ips)) + } + startTask = nil + } catch { + device = nil + startTask = nil + transition(to: .stopped) + throw error + } + } + + private func transition(to newState: State) { + guard state != newState else { return } + state = newState + var terminated: [UUID] = [] + for (id, continuation) in stateObservers { + if case .terminated = continuation.yield(newState) { terminated.append(id) } + } + for id in terminated { stateObservers[id] = nil } + } + + private func removeStateObserver(_ id: UUID) { + stateObservers[id] = nil + } + + private struct StartedNode: Sendable { + var device: OpaquePointer + var ips: [String] + } + + // MARK: - Key state (app-owned, Keychain-persisted) + + /// The three 32-byte node keys, generated once and reused so the node + /// keeps its tailnet identity across launches. tailscale-rs has no + /// export API, so the app is the only owner. + private static func loadOrCreateKeyState() async -> Data { + await Task.detached(priority: .userInitiated) { + if let existing = KeychainStore.getData(for: identityNamespace, kind: .tailscaleKeyState), + existing.count == keyStateByteCount { + return existing + } + var bytes = Data(count: keyStateByteCount) + let ok = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, keyStateByteCount, $0.baseAddress!) + } + precondition(ok == errSecSuccess, "SecRandomCopyBytes failed for tailnet keys") + KeychainStore.setData(bytes, for: identityNamespace, kind: .tailscaleKeyState) + return bytes + }.value + } + + // MARK: - Node startup + + private static func startNode( + configuration: Configuration, + deviceName: String, + keyState: Data, + ffiQueue: DispatchQueue, + deadlineQueue: DispatchQueue + ) async throws -> StartedNode { + _ = envConfigured + let hostname = TailscaleNodeHostname.format(deviceName: deviceName) + let controlURL = configuration.controlURL.trimmingCharacters(in: .whitespacesAndNewlines) + + let device = try await withDeadline(on: deadlineQueue) { + try await performBlocking(on: ffiQueue) { + try initDevice( + authKey: configuration.authKey, + hostname: hostname, + controlURL: controlURL.isEmpty ? nil : controlURL, + keyState: keyState + ) + } + } + + do { + let ips = try await performBlocking(on: ffiQueue) { readIPs(device: device) } + return StartedNode(device: device, ips: ips) + } catch { + await performBlockingIgnoringError(on: ffiQueue) { ts_deinit(device) } + throw error + } + } + + private static func initDevice( + authKey: String, + hostname: String, + controlURL: String?, + keyState: Data + ) throws -> OpaquePointer { + var keyBytes = [UInt8](keyState) + return try keyBytes.withUnsafeMutableBufferPointer { keyBuffer -> OpaquePointer in + var keyStruct = ts_persisted_key_state() + let base = keyBuffer.baseAddress! + withUnsafeMutableBytes(of: &keyStruct.node_private_key) { + $0.copyBytes(from: UnsafeRawBufferPointer(start: base, count: 32)) + } + withUnsafeMutableBytes(of: &keyStruct.machine_private_key) { + $0.copyBytes(from: UnsafeRawBufferPointer(start: base + 32, count: 32)) + } + withUnsafeMutableBytes(of: &keyStruct.network_lock_private_key) { + $0.copyBytes(from: UnsafeRawBufferPointer(start: base + 64, count: 32)) + } + + return try withUnsafeMutablePointer(to: &keyStruct) { keyStatePointer in + try hostname.withCString { hostnamePointer in + try "Multiplex".withCString { clientNamePointer in + func build(_ controlPointer: UnsafePointer?) throws -> OpaquePointer { + var config = ts_config() + config.control_server_url = controlPointer + config.hostname = hostnamePointer + config.tags = nil + config.client_name = clientNamePointer + config.key_state = keyStatePointer + return try authKey.withCString { authPointer in + guard let device = ts_init(&config, authPointer) else { + throw TailscaleTunnelFailure( + message: "The embedded Tailscale node failed to start." + ) + } + return device + } + } + if let controlURL { + return try controlURL.withCString { try build($0) } + } + return try build(nil) + } + } + } + } + } + + private static func connect( + device: OpaquePointer, + hostname: String, + port: Int + ) throws -> OpaquePointer { + var addr = ts_sockaddr() + switch TailscaleRSDialAddress.classify(hostname: hostname) { + case .literalIP(let ip): + let parsed = ip.withCString { ts_parse_ip($0, &addr) } + guard parsed == 0 else { + throw TailscaleTunnelFailure(message: "Couldn't parse tailnet address \(ip).") + } + case .peerName(let name): + var v4 = ts_in_addr_t(0, 0, 0, 0) + let result = name.withCString { ts_peer_ipv4_addr(device, $0, &v4) } + guard result > 0 else { + throw TailscaleTunnelFailure( + message: result == 0 + ? "No tailnet peer named \(name)." + : "Couldn't resolve tailnet peer \(name)." + ) + } + let dotted = "\(v4.0).\(v4.1).\(v4.2).\(v4.3)" + let parsed = dotted.withCString { ts_parse_ip($0, &addr) } + guard parsed == 0 else { + throw TailscaleTunnelFailure(message: "Couldn't form tailnet address for \(name).") + } + } + guard ts_sockaddr_set_port(&addr, UInt16(port)) == 0 else { + throw TailscaleTunnelFailure(message: "Invalid port \(port).") + } + guard let stream = ts_tcp_connect(device, &addr) else { + throw TailscaleTunnelFailure(message: "Tailscale couldn't reach \(hostname):\(port).") + } + return stream + } + + private static func readIPs(device: OpaquePointer) -> [String] { + var ips: [String] = [] + var v4 = ts_in_addr_t(0, 0, 0, 0) + if ts_ipv4_addr(device, &v4) == 0 { + ips.append("\(v4.0).\(v4.1).\(v4.2).\(v4.3)") + } + return ips + } + + // MARK: - Blocking-call plumbing + + private static func performBlocking( + on queue: DispatchQueue, + _ operation: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func performBlockingIgnoringError( + on queue: DispatchQueue, + _ operation: @escaping @Sendable () -> Void + ) async { + await withCheckedContinuation { continuation in + queue.async { + operation() + continuation.resume() + } + } + } + + /// Races a startup that can hang on a rejected auth key (measured >60 s + /// in a supervision retry loop) against a deadline. On timeout the caller + /// is freed with an error; a late success is cleaned up with `ts_deinit`. + /// The underlying blocking thread may stay parked until the process ends + /// — one leaked concurrent-queue thread, never the whole tunnel. + private static func withDeadline( + on deadlineQueue: DispatchQueue, + _ operation: @escaping @Sendable () async throws -> OpaquePointer + ) async throws -> OpaquePointer { + let gate = TailscaleStartupGate() + Task { + do { + let device = try await operation() + if !gate.resolveSuccess(device) { + ts_deinit(device) + } + } catch { + gate.resolveFailure(error) + } + } + deadlineQueue.asyncAfter(deadline: .now() + startupTimeout) { + gate.resolveTimeout(TailscaleTunnelFailure( + message: "Tailscale startup timed out after 30 seconds. Check the auth key." + )) + } + return try await gate.value() + } + + // MARK: - DEBUG fake dial + + #if DEBUG + private static func debugPlainSocket(hostname: String, port: Int) throws -> CInt { + var hints = addrinfo( + ai_flags: 0, ai_family: AF_UNSPEC, ai_socktype: SOCK_STREAM, + ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil + ) + var results: UnsafeMutablePointer? + guard getaddrinfo(hostname, String(port), &hints, &results) == 0, let first = results else { + throw TailscaleTunnelFailure(message: "Fake dial couldn't resolve \(hostname).") + } + defer { freeaddrinfo(results) } + let fd = socket(first.pointee.ai_family, first.pointee.ai_socktype, first.pointee.ai_protocol) + guard fd >= 0 else { + throw TailscaleTunnelFailure(message: "Fake dial socket failed (errno \(errno)).") + } + guard Darwin.connect(fd, first.pointee.ai_addr, first.pointee.ai_addrlen) == 0 else { + Darwin.close(fd) + throw TailscaleTunnelFailure(message: "Fake dial to \(hostname):\(port) failed (errno \(errno)).") + } + return fd + } + #endif +} + +/// Handle-backed remote wrapping a `ts_tcp_stream`. Send and recv are +/// independent runtime commands, so the relay's two pump threads are safe; +/// close must not race them, which the relay's finished-pump accounting +/// guarantees. +struct TailscaleHandleRemote: TailscaleRelayRemote { + let stream: OpaquePointer + + func recv(into buffer: UnsafeMutableRawBufferPointer) -> Int { + guard let base = buffer.baseAddress else { return 0 } + return Int(ts_tcp_recv(stream, base.assumingMemoryBound(to: UInt8.self), UInt(buffer.count))) + } + + func send(_ buffer: UnsafeRawBufferPointer) -> Int { + guard let base = buffer.baseAddress else { return 0 } + return Int(ts_tcp_send(stream, base.assumingMemoryBound(to: UInt8.self), UInt(buffer.count))) + } + + func shutdownWrite() { + // ts_tcp has no half-close, and calling ts_tcp_close here would race + // the concurrent ts_tcp_recv on this handle (documented-unsafe). The + // SSH client closing tears the whole session down via + // SSHConnection.close() → relay.close(), which is the handle's + // teardown owner. + } + + func close() { + ts_tcp_close(stream) + } +} + +/// Resolves the startup continuation exactly once across the success, +/// failure, and deadline racers. Buffers a result that arrives before the +/// awaiter has registered its continuation (the racing Task can win before +/// `value()` runs). +private final class TailscaleStartupGate: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + private var pending: Result? + private var continuation: CheckedContinuation? + + func value() async throws -> OpaquePointer { + try await withCheckedThrowingContinuation { continuation in + let ready: Result? = lock.withLock { + if let pending { + return pending + } + self.continuation = continuation + return nil + } + if let ready { + continuation.resume(with: ready) + } + } + } + + /// Returns false if the race was already decided (caller must deinit a + /// late device). + func resolveSuccess(_ device: OpaquePointer) -> Bool { + resolve(.success(device)) + } + + func resolveFailure(_ error: Error) { + _ = resolve(.failure(error)) + } + + func resolveTimeout(_ error: Error) { + _ = resolve(.failure(error)) + } + + private func resolve(_ result: Result) -> Bool { + enum Outcome { case lost, buffered, resume(CheckedContinuation) } + let outcome: Outcome = lock.withLock { + guard !finished else { return .lost } + finished = true + if let continuation { + self.continuation = nil + return .resume(continuation) + } + pending = result + return .buffered + } + switch outcome { + case .lost: + return false + case .buffered: + return true + case .resume(let continuation): + continuation.resume(with: result) + return true + } + } +} +#endif diff --git a/Multiplex/Views/Deck/AddHostSheet.swift b/Multiplex/Views/Deck/AddHostSheet.swift index a4f6ce95..fef0b99f 100644 --- a/Multiplex/Views/Deck/AddHostSheet.swift +++ b/Multiplex/Views/Deck/AddHostSheet.swift @@ -21,6 +21,12 @@ struct AddHostSheet: View { @State private var privateKeyConcealed = false @State private var passphrase = "" @State private var useMosh = false + @State private var useTailscale = false + #if canImport(CTailscaleRS) + /// nil until the keychain read lands, so the missing-key tip never + /// flashes during load. + @State private var tailscaleAuthKeyConfigured: Bool? + #endif @State private var moshServerPath = "" @State private var moshPorts = "" @State private var workingDirs: [WorkingDir] = [] @@ -221,7 +227,39 @@ struct AddHostSheet: View { } private var transportSection: some View { + transportSectionBody + #if canImport(CTailscaleRS) + // Re-reads on every toggle flip: the sheet and Settings are never + // open at once, so toggle-on is the freshest moment. + .task(id: useTailscale) { + guard useTailscale else { return } + let configuration = await TailscaleTunnel.loadConfiguration() + guard !Task.isCancelled else { return } + tailscaleAuthKeyConfigured = !configuration.authKey + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + #endif + } + + private var transportSectionBody: some View { TallyFormSection("Transport", detail: transportDetail) { + #if canImport(CTailscaleRS) + TallyFormBoolField( + "Connect via Tailscale", + isOn: tailscaleToggle, + accessibilityHint: "Routes this host's SSH connection through the embedded Tailscale node" + ) + if useTailscale, tailscaleAuthKeyConfigured == false { + TallyFormRow { + Text("No Tailscale auth key is set yet — add a reusable one in Settings › Tailscale, or this host can't connect.") + .font(.ui(10)) + .foregroundStyle(Theme.caution) + .fixedSize(horizontal: false, vertical: true) + } + } + #endif + TallyFormBoolField( "Connect with mosh", isOn: moshToggle, @@ -259,10 +297,15 @@ struct AddHostSheet: View { } private var transportDetail: String { + #if canImport(CTailscaleRS) + if useTailscale { + return "Experimental · SSH runs through this device's embedded Tailscale node, relayed through Tailscale's servers. Add a reusable auth key in Settings. Mosh is unavailable on this path." + } + #endif if useMosh { - "Terminals attach over UDP and survive roaming or sleep. SSH still signs in, starts mosh-server, and probes the deck." + return "Terminals attach over UDP and survive roaming or sleep. SSH still signs in, starts mosh-server, and probes the deck." } else { - "SSH carries both the control connection and attached terminals." + return "SSH carries both the control connection and attached terminals." } } @@ -320,7 +363,12 @@ struct AddHostSheet: View { } private var testDetail: String { - useMosh + #if canImport(CTailscaleRS) + if useTailscale { + return "Starts the embedded Tailscale node, signs in to SSH through it, then looks for tmux on the host." + } + #endif + return useMosh ? "Signs in over SSH with the settings above, then looks for tmux and mosh-server on the host." : "Signs in over SSH with the settings above, then looks for tmux on the host." } @@ -330,7 +378,8 @@ struct AddHostSheet: View { private var testFingerprint: [String] { [hostname, port, username, authMethod.rawValue, password, privateKey, passphrase, - useMosh ? "mosh" : "ssh", moshServerPath] + useMosh ? "mosh" : "ssh", + useTailscale ? "tailscale" : "direct", moshServerPath] } private func runTest() { @@ -649,6 +698,21 @@ struct AddHostSheet: View { return } useMosh = enabled + if enabled { + useTailscale = false + } + } + ) + } + + private var tailscaleToggle: Binding { + Binding( + get: { useTailscale }, + set: { enabled in + useTailscale = enabled + if enabled { + useMosh = false + } } ) } @@ -693,7 +757,8 @@ struct AddHostSheet: View { port = String(host.port) username = host.username authMethod = host.authMethod - useMosh = host.useMosh + useTailscale = host.useTailscale + useMosh = host.useMosh && !host.useTailscale moshServerPath = host.moshServerPath ?? "" moshPorts = host.moshPorts ?? "" workingDirs = host.workingDirs.map { WorkingDir(path: $0) } @@ -720,7 +785,8 @@ struct AddHostSheet: View { host.port = Int(port) ?? 22 host.username = username.trimmingCharacters(in: .whitespaces) host.authMethod = authMethod - host.useMosh = useMosh + host.useTailscale = useTailscale + host.useMosh = useMosh && !useTailscale let serverPath = moshServerPath.trimmingCharacters(in: .whitespaces) host.moshServerPath = serverPath.isEmpty ? nil : serverPath let ports = moshPorts.trimmingCharacters(in: .whitespaces) @@ -785,7 +851,7 @@ struct AddHostSheet: View { /// and the save-to-Passwords prompt to secure entry — every content-type /// opt-out is ignored — and one secure field marks the whole sheet as a /// login form, dragging User and Private key into the same treatment. -private struct RevealableSecureField: View { +struct RevealableSecureField: View { let title: String let prompt: String @Binding var text: String diff --git a/Multiplex/Views/Settings/SettingsView.swift b/Multiplex/Views/Settings/SettingsView.swift index cb2d3cba..ddf148d7 100644 --- a/Multiplex/Views/Settings/SettingsView.swift +++ b/Multiplex/Views/Settings/SettingsView.swift @@ -16,6 +16,13 @@ struct SettingsView: View { /// a new one and is added (and selected) on save. @State private var editingTheme: TerminalTheme? @State private var showingPaywall = false + #if canImport(CTailscaleRS) + @State private var tailscaleAuthKey = "" + @State private var tailscaleControlURL = "" + @State private var savedTailscaleConfiguration: TailscaleTunnel.Configuration? + @State private var tailscaleState: TailscaleTunnel.State = .stopped + @State private var savingTailscaleConfiguration = false + #endif var body: some View { NavigationStack { @@ -25,6 +32,9 @@ struct SettingsView: View { currentThemeSection builtInThemesSection customThemesSection + #if canImport(CTailscaleRS) + tailscaleSection + #endif alertsSection appLockSection proSection @@ -40,18 +50,128 @@ struct SettingsView: View { .toolbar { ChassisSheetTitle("Settings") ToolbarItem(placement: .confirmationAction) { - ChassisBarButton("Done") { dismiss() } + ChassisBarButton("Done", action: finish) + #if canImport(CTailscaleRS) + .disabled(savingTailscaleConfiguration) + #endif } } .navigationDestination(item: $editingTheme) { theme in ThemeEditorView(theme: theme, onSave: save) } + #if canImport(CTailscaleRS) + .interactiveDismissDisabled( + tailscaleConfigurationDirty || savingTailscaleConfiguration + ) + .task { + let configuration = await TailscaleTunnel.loadConfiguration() + guard !Task.isCancelled else { return } + tailscaleAuthKey = configuration.authKey + tailscaleControlURL = configuration.controlURL + savedTailscaleConfiguration = configuration + } + .task { + let updates = await TailscaleTunnel.shared.stateUpdates() + for await update in updates { + guard !Task.isCancelled else { return } + tailscaleState = update + } + } + #endif #if DEBUG .task { presentThemeEditorForVerificationIfRequested() } #endif } } + #if canImport(CTailscaleRS) + private var tailscaleSection: some View { + TallyFormSection( + "Tailscale", + detail: "Experimental · relayed through Tailscale's servers. Use a reusable auth key: every device becomes its own tailnet node, synced through iCloud Keychain. The optional Headscale control URL stays on this device. Changes apply the next time the embedded node starts." + ) { + TallyFormField("Auth key") { + RevealableSecureField( + "Tailscale auth key", + prompt: "tskey-auth-…", + text: $tailscaleAuthKey + ) + } + + TallyFormField("Control URL") { + TextField("Optional · Headscale URL", text: $tailscaleControlURL) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + TallyFormRow { + HStack(spacing: 12) { + TallyLamp(caption: tailscaleStatusCaption, color: tailscaleStatusColor) + Spacer(minLength: 12) + if case .running(let ips) = tailscaleState { + Text(ips.isEmpty ? "TAILNET READY" : ips.joined(separator: " · ")) + .font(.mono(10, weight: .medium)) + .foregroundStyle(Theme.signal2) + .multilineTextAlignment(.trailing) + } + } + } + } + .disabled(savedTailscaleConfiguration == nil || savingTailscaleConfiguration) + } + + private var tailscaleStatusCaption: String { + switch tailscaleState { + case .stopped: "STOPPED" + case .starting: "STARTING" + case .running: "RUNNING" + } + } + + private var tailscaleStatusColor: Color { + switch tailscaleState { + case .stopped: Theme.signal3 + case .starting: Theme.caution + case .running: Theme.ok + } + } + + private var tailscaleConfiguration: TailscaleTunnel.Configuration { + TailscaleTunnel.Configuration(authKey: tailscaleAuthKey, controlURL: tailscaleControlURL) + } + + private var tailscaleConfigurationDirty: Bool { + guard let savedTailscaleConfiguration else { return false } + return tailscaleConfiguration != savedTailscaleConfiguration + } + #endif + + private func finish() { + #if canImport(CTailscaleRS) + guard let savedTailscaleConfiguration, + tailscaleConfiguration != savedTailscaleConfiguration + else { + tailscaleAuthKey = "" + self.savedTailscaleConfiguration = nil + dismiss() + return + } + + let configuration = tailscaleConfiguration + savingTailscaleConfiguration = true + Task { + await TailscaleTunnel.saveConfiguration(configuration) + tailscaleAuthKey = "" + self.savedTailscaleConfiguration = nil + savingTailscaleConfiguration = false + dismiss() + } + #else + dismiss() + #endif + } + /// SYSTEM follows the device; LIGHT/DARK pin the chassis. The choice is /// free and device-local, like the terminal theme selection. private var appearanceSection: some View { diff --git a/MultiplexTests/HostSyncTests.swift b/MultiplexTests/HostSyncTests.swift index d6fd9a75..b489a90e 100644 --- a/MultiplexTests/HostSyncTests.swift +++ b/MultiplexTests/HostSyncTests.swift @@ -101,12 +101,29 @@ final class HostSyncTests: XCTestCase { XCTAssertEqual(host.name, "devbox") XCTAssertEqual(host.updatedAt, .distantPast) XCTAssertFalse(host.useMosh) + XCTAssertFalse(host.useTailscale) XCTAssertNil(host.moshServerPath) XCTAssertNil(host.moshPorts) XCTAssertEqual(host.workingDirs, []) XCTAssertTrue(host.agentCommandConfiguration.isEmpty) } + func testTailscaleFlagParticipatesInConnectionIdentity() { + let original = host("devbox") + var tailscale = original + tailscale.useTailscale = true + XCTAssertFalse(original.hasSameConnectionModelConfiguration(as: tailscale)) + } + + func testHostTailscaleFlagRoundTripsThroughRecordEncoding() throws { + var original = host("devbox", updatedAt: Date(timeIntervalSince1970: 1234)) + original.useTailscale = true + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Host.self, from: data) + XCTAssertEqual(decoded, original) + XCTAssertTrue(decoded.useTailscale) + } + func testHostRoundTripsThroughRecordEncoding() throws { let original = host("devbox", updatedAt: Date(timeIntervalSince1970: 1234)) let data = try JSONEncoder().encode(original) diff --git a/MultiplexTests/MoshBootstrapTests.swift b/MultiplexTests/MoshBootstrapTests.swift index c303ef16..3dfb2d6f 100644 --- a/MultiplexTests/MoshBootstrapTests.swift +++ b/MultiplexTests/MoshBootstrapTests.swift @@ -101,4 +101,29 @@ final class MoshBootstrapTests: XCTestCase { XCTAssertEqual(v4.datagramBudget, 1252 - 28) XCTAssertEqual(v6.datagramBudget, 1216 - 28) } + + func testRejectsEmbeddedTailscaleBeforeBootstrap() async { + var host = Host(name: "devbox", hostname: "unresolvable.invalid", username: "dev") + host.useMosh = true + host.useTailscale = true + + do { + _ = try await MoshBootstrap.start( + host: host, + secrets: HostSecrets(password: nil, privateKey: nil, passphrase: nil), + remoteCommand: nil + ) + XCTFail("Expected the mutually exclusive transports to fail") + } catch let error as MoshBootstrapError { + guard case .tailscaleIncompatible = error else { + return XCTFail("Expected tailscale incompatibility, got \(error)") + } + XCTAssertEqual( + error.userMessage(host: host), + "mosh can't run over the embedded Tailscale connection — turn one of them off." + ) + } catch { + XCTFail("Expected MoshBootstrapError, got \(error)") + } + } } diff --git a/MultiplexTests/TailscaleLoopbackRelayTests.swift b/MultiplexTests/TailscaleLoopbackRelayTests.swift new file mode 100644 index 00000000..ee869336 --- /dev/null +++ b/MultiplexTests/TailscaleLoopbackRelayTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import Multiplex + +/// In-process only: a socketpair-backed `TailscaleRelayRemote` stands in for +/// the tailnet handle, a plain TCP client for Citadel. Reads are poll-bounded +/// so a broken relay fails fast instead of hanging the suite. +final class TailscaleLoopbackRelayTests: XCTestCase { + /// Wraps one end of a socketpair as the relay's remote — the same seam a + /// real `ts_tcp_stream` uses, without a node. + private struct SocketPairRemote: TailscaleRelayRemote { + let fd: CInt + func recv(into buffer: UnsafeMutableRawBufferPointer) -> Int { + var r: Int + repeat { r = Darwin.read(fd, buffer.baseAddress, buffer.count) } while r < 0 && errno == EINTR + return r + } + func send(_ buffer: UnsafeRawBufferPointer) -> Int { + var r: Int + repeat { r = Darwin.write(fd, buffer.baseAddress, buffer.count) } while r < 0 && errno == EINTR + return r + } + func shutdownWrite() { shutdown(fd, SHUT_WR) } + func close() { Darwin.close(fd) } + } + + private func makeSocketPair() throws -> (relayEnd: CInt, testEnd: CInt) { + var pair: [CInt] = [0, 0] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { + throw XCTSkip("socketpair failed (errno \(errno))") + } + return (pair[0], pair[1]) + } + + private func connectClient(port: UInt16) -> CInt { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return -1 } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = port.bigEndian + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + close(fd) + return -1 + } + return fd + } + + private func read(_ fd: CInt, count: Int) -> [UInt8] { + var collected: [UInt8] = [] + var buffer = [UInt8](repeating: 0, count: count) + while collected.count < count { + var pollFD = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + guard poll(&pollFD, 1, 2000) > 0 else { return collected } + let n = Darwin.read(fd, &buffer, count - collected.count) + guard n > 0 else { return collected } + collected.append(contentsOf: buffer[0.. Bool { + var byte: UInt8 = 0 + for _ in 0..<20 { + var pollFD = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + if poll(&pollFD, 1, 100) > 0 { + let n = Darwin.read(fd, &byte, 1) + if n <= 0 { return true } + } + } + return false + } + + func testRoundTripBothDirectionsThenEOFPropagates() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + let port = try relay.start(spliceTo: SocketPairRemote(fd: relayEnd)) + + let client = connectClient(port: port) + XCTAssertGreaterThanOrEqual(client, 0) + + let toRemote: [UInt8] = Array("hello".utf8) + XCTAssertEqual(toRemote.withUnsafeBytes { write(client, $0.baseAddress, $0.count) }, 5) + XCTAssertEqual(read(testEnd, count: 5), toRemote) + + let toClient: [UInt8] = Array("world".utf8) + XCTAssertEqual(toClient.withUnsafeBytes { write(testEnd, $0.baseAddress, $0.count) }, 5) + XCTAssertEqual(read(client, count: 5), toClient) + + close(client) + XCTAssertTrue(reachesEOF(testEnd), "client close should reach the spliced remote as EOF") + } + + func testSecondConnectIsRefusedAfterOneShotAccept() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + let port = try relay.start(spliceTo: SocketPairRemote(fd: relayEnd)) + + let first = connectClient(port: port) + XCTAssertGreaterThanOrEqual(first, 0) + defer { close(first) } + + let probe: [UInt8] = [0x2A] + XCTAssertEqual(probe.withUnsafeBytes { write(first, $0.baseAddress, 1) }, 1) + XCTAssertEqual(read(testEnd, count: 1), probe) + + let second = connectClient(port: port) + if second >= 0 { close(second) } + XCTAssertEqual(second, -1, "the one-shot listener must be gone after the first accept") + } + + func testCloseBeforeAcceptReleasesRemote() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + _ = try relay.start(spliceTo: SocketPairRemote(fd: relayEnd)) + + relay.close() + XCTAssertTrue(reachesEOF(testEnd), "close() before any accept must close the remote") + } + + func testStartAfterCloseThrowsAndClosesRemote() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + relay.close() + XCTAssertThrowsError(try relay.start(spliceTo: SocketPairRemote(fd: relayEnd))) + XCTAssertTrue(reachesEOF(testEnd)) + } +} diff --git a/MultiplexTests/TailscaleRSDialAddressTests.swift b/MultiplexTests/TailscaleRSDialAddressTests.swift new file mode 100644 index 00000000..96c66a59 --- /dev/null +++ b/MultiplexTests/TailscaleRSDialAddressTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import Multiplex + +final class TailscaleRSDialAddressTests: XCTestCase { + func testClassifiesIPv4Literal() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: "100.64.0.8"), + .literalIP("100.64.0.8") + ) + } + + func testClassifiesIPv6Literal() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: "fd7a:115c:a1e0::1"), + .literalIP("fd7a:115c:a1e0::1") + ) + } + + func testStripsBracketsFromIPv6Literal() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: "[fd7a:115c:a1e0::1]"), + .literalIP("fd7a:115c:a1e0::1") + ) + } + + func testClassifiesMagicDNSNameAsPeer() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: "devbox.tail1234.ts.net"), + .peerName("devbox.tail1234.ts.net") + ) + } + + func testClassifiesBareHostnameAsPeer() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: "devbox"), + .peerName("devbox") + ) + } + + func testTrimsWhitespaceBeforeClassifying() { + XCTAssertEqual( + TailscaleRSDialAddress.classify(hostname: " devbox "), + .peerName("devbox") + ) + } + + func testIPv4Validation() { + XCTAssertTrue(TailscaleRSDialAddress.isIPv4("10.0.0.1")) + XCTAssertTrue(TailscaleRSDialAddress.isIPv4("255.255.255.255")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv4("256.0.0.1")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv4("10.0.0")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv4("devbox")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv4("10.0.0.1.2")) + } + + func testIPv6Validation() { + XCTAssertTrue(TailscaleRSDialAddress.isIPv6("::1")) + XCTAssertTrue(TailscaleRSDialAddress.isIPv6("fd7a:115c:a1e0::3101:7939")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv6("devbox.ts.net")) + XCTAssertFalse(TailscaleRSDialAddress.isIPv6("10.0.0.1")) + } + + func testFormatsNodeHostnameFromDeviceName() { + XCTAssertEqual( + TailscaleNodeHostname.format(deviceName: "Jhen's iPad Pro"), + "multiplex-jhen-s-ipad-pro" + ) + XCTAssertEqual( + TailscaleNodeHostname.format(deviceName: "🛰️"), + "multiplex" + ) + } +} diff --git a/Tools/build-tailscale-rs.sh b/Tools/build-tailscale-rs.sh new file mode 100755 index 00000000..ea74a066 --- /dev/null +++ b/Tools/build-tailscale-rs.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Builds the vendored tailscale-rs static archives for all four app slices +# (iOS device, universal iOS simulator, visionOS device, visionOS +# simulator) at the pinned commit and installs them under +# Vendor/tailscale-rs/lib/. See Vendor/tailscale-rs/README.md. +# +# Requires: rustup + network. Installs the pinned stable and nightly +# toolchains and targets on first run. The two xros slices need nightly + +# -Zbuild-std while aarch64-apple-visionos remains tier 3. +set -eu + +PINNED_COMMIT=31b007904be298b69c4af1ffbefa937ad9848dbe +NIGHTLY=nightly-2026-07-22 +REPO_URL=https://github.com/tailscale/tailscale-rs +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VENDOR="$ROOT/Vendor/tailscale-rs" +WORK="${TAILSCALE_RS_BUILD_DIR:-$(mktemp -d /tmp/tailscale-rs-build.XXXXXX)}" + +if [ ! -d "$WORK/.git" ]; then + git clone "$REPO_URL" "$WORK" +fi +git -C "$WORK" fetch --quiet origin "$PINNED_COMMIT" +git -C "$WORK" checkout --quiet "$PINNED_COMMIT" +git -C "$WORK" checkout -- . +git -C "$WORK" apply "$VENDOR/patches/ts_netmon-apple-mobile-cfg.patch" + +# The header must match the pinned commit's cbindgen output. +if ! cmp -s "$WORK/ts_ffi/tailscale.h" "$VENDOR/include/tailscale.h"; then + echo "error: Vendor/tailscale-rs/include/tailscale.h differs from the pinned commit's ts_ffi/tailscale.h — reconcile before building" >&2 + exit 1 +fi + +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios \ + --toolchain 1.95.0 2>/dev/null || true +rustup toolchain install "$NIGHTLY" --component rust-src 2>/dev/null || true + +cd "$WORK" +IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build -p ts_ffi --release --target aarch64-apple-ios +IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build -p ts_ffi --release --target aarch64-apple-ios-sim +IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build -p ts_ffi --release --target x86_64-apple-ios +XROS_DEPLOYMENT_TARGET=1.0 cargo "+$NIGHTLY" build -p ts_ffi --release \ + --target aarch64-apple-visionos -Zbuild-std=std,panic_abort +XROS_DEPLOYMENT_TARGET=1.0 cargo "+$NIGHTLY" build -p ts_ffi --release \ + --target aarch64-apple-visionos-sim -Zbuild-std=std,panic_abort + +mkdir -p "$VENDOR/lib/ios-arm64" "$VENDOR/lib/ios-simulator" \ + "$VENDOR/lib/xros-arm64" "$VENDOR/lib/xros-simulator" +cp target/aarch64-apple-ios/release/libtailscalers.a "$VENDOR/lib/ios-arm64/libtailscalers.a" +lipo -create -output "$VENDOR/lib/ios-simulator/libtailscalers.a" \ + target/aarch64-apple-ios-sim/release/libtailscalers.a \ + target/x86_64-apple-ios/release/libtailscalers.a +cp target/aarch64-apple-visionos/release/libtailscalers.a "$VENDOR/lib/xros-arm64/libtailscalers.a" +cp target/aarch64-apple-visionos-sim/release/libtailscalers.a "$VENDOR/lib/xros-simulator/libtailscalers.a" + +for slice in ios-arm64 ios-simulator xros-arm64 xros-simulator; do + if ! nm -gU "$VENDOR/lib/$slice/libtailscalers.a" 2>/dev/null | grep -q _ts_init; then + echo "error: $slice archive is missing _ts_init" >&2 + exit 1 + fi +done +lipo -info "$VENDOR/lib/ios-simulator/libtailscalers.a" | grep -q x86_64 || { + echo "error: ios-simulator archive is not universal" >&2 + exit 1 +} + +echo "Installed:" +ls -l "$VENDOR"/lib/*/libtailscalers.a diff --git a/Vendor/tailscale-rs/LICENSE b/Vendor/tailscale-rs/LICENSE new file mode 100644 index 00000000..adcd4c5b --- /dev/null +++ b/Vendor/tailscale-rs/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026 Tailscale Inc & contributors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Vendor/tailscale-rs/README.md b/Vendor/tailscale-rs/README.md new file mode 100644 index 00000000..fd3e0f17 --- /dev/null +++ b/Vendor/tailscale-rs/README.md @@ -0,0 +1,50 @@ +# tailscale-rs (vendored C ABI) + +Tailscale's Rust implementation as a `staticlib`, exposed to Swift as the +`CTailscaleRS` module. Backs the per-host "Connect via Tailscale" option on +**all three platforms — visionOS included** (Rust has an +`aarch64-apple-visionos` target; Go never will). Parallel to the Go-backed +spike on branch `tailscale-host-option` (PR #12); investigation record: +`local-plan/tailscale-rs-investigation.md`. + +**Upstream labels this experimental**: unaudited crypto ("assume … in the +clear"), a mandatory `TS_RS_EXPERIMENT=this_is_unstable_software` env var +(the tunnel sets it before init), no pre-1.0 API stability, and today ALL +peer traffic relays through public DERP servers (no NAT traversal yet — +"seamless upgrade" later). SSH/mosh payloads stay independently encrypted +regardless. + +## Provenance + +- Source: https://github.com/tailscale/tailscale-rs +- Pinned commit: `31b007904be298b69c4af1ffbefa937ad9848dbe` (main, + 2026-07-22; tags run v0.2.0–v0.4.0) + one local patch: + `patches/ts_netmon-apple-mobile-cfg.patch` (cfg-gates a `PlatformMon` + re-export that doesn't exist on iOS/visionOS — upstream-PR-able). +- Toolchains: Rust 1.95.0 (repo pin) for iOS/darwin slices; + **nightly-2026-07-22 + `-Zbuild-std=std,panic_abort`** for the two xros + slices (`aarch64-apple-visionos{,-sim}` are still tier 3). +- License BSD-3-Clause (+ Tailscale PATENTS grant upstream); dep licenses + constrained by upstream's deny.toml to permissive families. + +## Layout + +- `include/tailscale.h` — cbindgen-generated C ABI (23 `ts_*` functions) + from the pinned commit; `include/module.modulemap` wraps it as + `CTailscaleRS`. +- `lib/{ios-arm64,ios-simulator,xros-arm64,xros-simulator}/libtailscalers.a` + — **git-ignored** (~17-44 MB). The ios-simulator slice is universal + (arm64 + x86_64: Release simulator builds link both). Rebuild all four: + +```sh +./Tools/build-tailscale-rs.sh +``` + +Link needs beyond libSystem: `-framework CoreFoundation -liconv` +(project.yml carries them with the SDK-conditional settings). + +When bumping the pin: re-run the script (it re-applies the patch — drop it +once upstream merges), re-read `tailscale.h` for ABI drift (pre-1.0 churn +is expected), and re-check the investigation record's re-evaluate list +(§7): security audit / env-gate removal, direct connections, visionOS +tier-2 promotion. diff --git a/Vendor/tailscale-rs/include/module.modulemap b/Vendor/tailscale-rs/include/module.modulemap new file mode 100644 index 00000000..cdc1454f --- /dev/null +++ b/Vendor/tailscale-rs/include/module.modulemap @@ -0,0 +1,4 @@ +module CTailscaleRS { + header "tailscale.h" + export * +} diff --git a/Vendor/tailscale-rs/include/tailscale.h b/Vendor/tailscale-rs/include/tailscale.h new file mode 100644 index 00000000..e70f865d --- /dev/null +++ b/Vendor/tailscale-rs/include/tailscale.h @@ -0,0 +1,471 @@ +#ifndef TAILSCALE_H +#define TAILSCALE_H + +#include +#include +#include +#include + +/** + * A Tailscale device, also variously called a "node" or "peer". + * + * A device is the unit of identity in a tailnet; it has a tailnet IP and can send and + * receive IP datagrams to other peers. + */ +typedef struct ts_device ts_device; + +/** + * A Tailscale TCP listener handle. + */ +typedef struct ts_tcp_listener ts_tcp_listener; + +/** + * A Tailscale TCP stream handle. + */ +typedef struct ts_tcp_stream ts_tcp_stream; + +/** + * A Tailscale UDP socket handle. + */ +typedef struct ts_udp_socket ts_udp_socket; + +/** + * A Tailscale cryptographic key. + */ +typedef uint8_t ts_key[32]; + +/** + * Tailscale key state for running a device. + */ +typedef struct ts_persisted_key_state { + /** + * Private key for the node (device) identity. + */ + ts_key node_private_key; + /** + * Private key for the machine. + */ + ts_key machine_private_key; + /** + * Private key for tailnet lock. + */ + ts_key network_lock_private_key; +} ts_persisted_key_state; + +/** + * Tailscale configuration. + * + * This struct is safe to zero-initialize, in which case default values will be used. + * You _must_ actually zero-initialize this struct in this case (`struct ts_config config = {0};`); + * an uninitialized declaration (`struct ts_config config;`) is insufficient and may invoke UB. + * + * On the Rust side, the [`Default`] instance for this type is equivalent to a C-side zero- + * init. + */ +typedef struct ts_config { + /** + * The control server URL to use. + * + * May be `NULL` to use the default value. + */ + const char *control_server_url; + /** + * The hostname to use. This will be the device's MagicDNS name, if it's available. + * + * May be `NULL` to use the default (the OS-reported hostname). + */ + const char *hostname; + /** + * An array of tags to be requested. + * + * Use `NULL` as the sentinel for the end of the array. + * + * May be `NULL` to indicate that no tags are requested. + */ + const char *const *tags; + /** + * The client name to report to the control server. This is reported as `Hostinfo.App`. + * + * May be `NULL` to use the default (`ts_ffi`). + */ + const char *client_name; + /** + * The key state to use. + * + * If `NULL`, ephemeral key state is generated. + */ + struct ts_persisted_key_state *key_state; +} ts_config; + +/** + * IPv4 address. + */ +typedef uint8_t ts_in_addr_t[4]; + +/** + * IPv6 address. + */ +typedef uint16_t ts_in6_addr_t[8]; + +/** + * Socket address family. + */ +typedef uint16_t ts_sa_family_t; + +/** + * C-compatible IPv4 socket address. + */ +typedef struct ts_sockaddr_in { + /** + * Port number. + */ + uint16_t sin_port; + /** + * IPv4 address. + */ + ts_in_addr_t sin_addr; +} ts_sockaddr_in; + +/** + * C-compatible IPv6 socket address. + */ +typedef struct ts_sockaddr_in6 { + /** + * Port number. + */ + uint16_t sin6_port; + /** + * Flow label. + */ + uint32_t sin6_flowinfo; + /** + * IPv6 address. + */ + ts_in6_addr_t sin6_addr; + /** + * Scope id. + */ + uint32_t sin6_scope_id; +} ts_sockaddr_in6; + +/** + * Address-family-specific payload for a [`sockaddr`]. + * + * Only `AF_INET` and `AF_INET6` are supported. + */ +typedef union ts_sockaddr_data { + /** + * IPv4 sockaddr payload. + */ + struct ts_sockaddr_in sockaddr_in; + /** + * IPv6 sockaddr payload. + */ + struct ts_sockaddr_in6 sockaddr_in6; +} ts_sockaddr_data; + +/** + * Socket address. + * + * Meant for compat between `` `sockaddr`s and tailscale sockets. On most + * platforms, you should be able to cast directly from the sockaddr types into this struct, + * though this isn't guaranteed if your libc makes unusual choices. + */ +typedef struct ts_sockaddr { + /** + * Address family. + * + * Only `AF_INET` and `AF_INET6` are supported. + */ + ts_sa_family_t sa_family; + /** + * The address info payload for this `ts_sockaddr` type. + */ + union ts_sockaddr_data sa_data; +} ts_sockaddr; + +/** + * IPv4 address family. + */ +#define TS_AF_INET 2 + +/** + * IPv6 address family. + */ +#define TS_AF_INET6 23 + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Initialize the Rust tailscale tracing subsystem. + * + * This is automatically called during `ts_init`, but you may want to call this first to log any + * errors if initialization needs to be done before `ts_init`. + */ +void ts_init_tracing(void); + +/** + * Initialize a new Tailscale device. + * + * `config` is the configuration with which to initialize the device. You may pass `NULL`, and a + * default ephemeral configuration will be used. + * + * `auth_token` is an optional auth token (you may pass `NULL`) that is used to authenticate the + * device if required. If you pass `NULL`, the credentials in `config_path` must already be + * authorized to make a successful connection. + * + * # Safety + * + * `auth_token` must be able to be read according to [`CStr`] rules, i.e. + * it must be NUL-terminated and valid for reading up to and including the NUL. + * The string fields of `config` may be `NULL`, but if they are not, they must + * obey the same invariants. + */ +struct ts_device *ts_init(const struct ts_config *config, const char *auth_token); + +/** + * Initialize a new Tailscale device with a default configuration using the given key file for the + * key state. The file is created with new keys if it doesn't exist. + * + * `auth_token` is an optional auth token (you may pass `NULL`) that is used to authenticate the + * device if required. If you pass `NULL`, the credentials in `key_file` must already be + * authorized to make a successful connection. + * + * # Safety + * + * `auth_token` and `key_file` must be able to be read according to [`CStr`] rules, i.e. + * they must be NUL-terminated and valid for reading up to and including the NUL. + */ +struct ts_device *ts_init_from_key_file(const char *key_file, const char *auth_token); + +/** + * Deinitialize and shut down a Tailscale device. + */ +void ts_deinit(struct ts_device *dev); + +/** + * Get the IPv4 address of the Tailscale node, blocking until it's available. + * + * Returns a negative number on error. + */ +int ts_ipv4_addr(const struct ts_device *dev, ts_in_addr_t *dst); + +/** + * Get the IPv6 address of the Tailscale node, blocking until it's available. + * + * Returns a negative number on error. + */ +int ts_ipv6_addr(const struct ts_device *dev, ts_in6_addr_t *dst); + +/** + * Get the IPv4 address of a specified peer by name. + * + * `peer_name` can be a fully-qualified name (`$HOST.tail1234.ts.net`) or an unqualified + * hostname (`$HOST`). The first match is returned: shared-in nodes may cause ambiguity + * when unqualified hostnames are used. + * + * Returns a negative number if there was an error, zero if no match was found, and a + * positive number if `addr` has been populated with the address for the requested peer. + * + * # Safety + * + * `peer_name` must be able to be read according to [`CStr`] rules, i.e. + * it must be NUL-terminated and valid for reading up to and including the NUL. + */ +int ts_peer_ipv4_addr(const struct ts_device *dev, const char *peer_name, ts_in_addr_t *addr); + +/** + * Get the IPv6 address of a specified peer by name. + * + * `peer_name` can be a fully-qualified name (`$HOST.tail1234.ts.net`) or an unqualified + * hostname (`$HOST`). The first match is returned: shared-in nodes may cause ambiguity + * when unqualified hostnames are used. + * + * Returns a negative number if there was an error, zero if no match was found, and a + * positive number if `addr` has been populated with the address for the requested peer. + * + * # Safety + * + * `peer_name` must be able to be read according to [`CStr`] rules, i.e. + * it must be NUL-terminated and valid for reading up to and including the NUL. + */ +int ts_peer_ipv6_addr(const struct ts_device *dev, const char *peer_name, ts_in6_addr_t *addr); + +/** + * Load the key state from the given file path. + * + * The second parameter indicates whether to overwrite the file with a new key state if the + * contents couldn't be read. + * + * Returns a negative number on error. + * + * # Safety + * + * `path` must be safe to convert to a [`CStr`], i.e. it must be NUL-terminated and valid for read + * up to the NUL-terminator. + */ +int ts_load_key_file(const char *path, + bool overwrite_if_invalid, + struct ts_persisted_key_state *key_state); + +/** + * Parse a [`sockaddr`] from a C string. + * + * This helper is provided to avoid the need to use `inet_pton`, `getaddrinfo`, and the + * like if you know you have a string in a conventional `$ADDR:$PORT` shape. + * + * # Safety + * + * `s` must be able to be read according to [`CStr`] rules, i.e. + * it must be NUL-terminated and valid for reading up to and including the NUL. + */ +int ts_parse_sockaddr(const char *s, struct ts_sockaddr *addr); + +/** + * Parse an IP address from a string into a [`sockaddr`], setting `sa_family` and the + * address field. The port is zeroed, and flow info and scope id are left unchanged. + * + * This is a convenience to allow easily constructing a `sockaddr` with a string IP, + * but using a port from a different source. + * + * # Safety + * + * `s` must be able to be read according to [`CStr`] rules, i.e. + * it must be NUL-terminated and valid for reading up to and including the NUL. + */ +int ts_parse_ip(const char *s, struct ts_sockaddr *addr); + +/** + * Convenience to set a port on a [`sockaddr`] regardless of its `sa_family`. + * + * Returns a negative number if `sa_family` is invalid. + */ +int ts_sockaddr_set_port(struct ts_sockaddr *addr, uint16_t port); + +/** + * Start a TCP listener on the given `addr`. + * + * Returns null if the listener couldn't be created. + */ +struct ts_tcp_listener *ts_tcp_listen(const struct ts_device *dev, const struct ts_sockaddr *addr); + +/** + * Accept an incoming connection on the given listener. + * + * Returns null if there was an error. + */ +struct ts_tcp_stream *ts_tcp_accept(const struct ts_tcp_listener *listener); + +/** + * Get the local endpoint `listener` is listening on. + */ +struct ts_sockaddr ts_tcp_listener_local_addr(const struct ts_tcp_listener *listener); + +/** + * Close the specified socket. + */ +void ts_tcp_close_listener(struct ts_tcp_listener *sock); + +/** + * Open a TCP connection to the specified `remote`. + */ +struct ts_tcp_stream *ts_tcp_connect(const struct ts_device *dev, const struct ts_sockaddr *remote); + +/** + * Send bytes to the specified socket, blocking until at least one byte is sent. + * + * Returns the number of bytes written, or a negative number if an error occurred. This is + * guaranteed to be less than or equal to `len`. + * + * # Safety + * + * `buf` must be safe to convert into a Rust slice of length `len` (see + * [`core::slice::from_raw_parts`]). + */ +int ts_tcp_send(const struct ts_tcp_stream *stream, const uint8_t *buf, uintptr_t len); + +/** + * Receive bytes from the specified socket, blocking until at least one byte is received. + * + * Returns the number of bytes read, or a negative number if an error occurred. This is + * guaranteed to be less than or equal to `len`. + * + * # Safety + * + * `buf` must be safe to convert into a mutable Rust slice of length `len` (see + * [`core::slice::from_raw_parts_mut`]). + */ +int ts_tcp_recv(const struct ts_tcp_stream *stream, uint8_t *buf, uintptr_t len); + +/** + * Get the local endpoint for this TCP stream. + */ +struct ts_sockaddr ts_tcp_local_addr(const struct ts_tcp_stream *stream); + +/** + * Get the remote endpoint this TCP stream is connected to. + */ +struct ts_sockaddr ts_tcp_remote_addr(const struct ts_tcp_stream *stream); + +/** + * Close the specified socket. + */ +void ts_tcp_close(struct ts_tcp_stream *sock); + +/** + * Bind a UDP socket on `addr`. + * + * Returns null if an error occurred. + */ +struct ts_udp_socket *ts_udp_bind(const struct ts_device *dev, const struct ts_sockaddr *addr); + +/** + * Close the specified UDP socket. + */ +void ts_udp_close(struct ts_udp_socket *sock); + +/** + * Send data over the specified socket to the given `addr`. + * + * Returns a negative number if an error occurred. + * + * # Safety + * + * `buf` must be safe to convert into a Rust slice of length `len` (see + * [`core::slice::from_raw_parts`]). + */ +int ts_udp_sendto(const struct ts_udp_socket *sock, + const struct ts_sockaddr *addr, + const uint8_t *msg, + uintptr_t len); + +/** + * Receive a packet from the socket. + * + * `addr` may be `None` (null) if the sender's address isn't needed. + * + * Returns the length of the packet, or a negative number on error. This is guaranteed to + * be less than or equal to `len`. + * + * # Safety + * + * `buf` must be safe to convert into a mutable Rust slice of length `len` (see + * [`core::slice::from_raw_parts_mut`]). + */ +int ts_udp_recvfrom(const struct ts_udp_socket *sock, + struct ts_sockaddr *addr, + uint8_t *buf, + uintptr_t len); + +/** + * Get the local endpoint to which the socket is bound. + */ +struct ts_sockaddr ts_udp_local_addr(const struct ts_udp_socket *sock); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* TAILSCALE_H */ diff --git a/Vendor/tailscale-rs/patches/ts_netmon-apple-mobile-cfg.patch b/Vendor/tailscale-rs/patches/ts_netmon-apple-mobile-cfg.patch new file mode 100644 index 00000000..5450423a --- /dev/null +++ b/Vendor/tailscale-rs/patches/ts_netmon-apple-mobile-cfg.patch @@ -0,0 +1,17 @@ +diff --git a/ts_netmon/src/lib.rs b/ts_netmon/src/lib.rs +index 9d8ec90..f1aa7c3 100644 +--- a/ts_netmon/src/lib.rs ++++ b/ts_netmon/src/lib.rs +@@ -14,7 +14,11 @@ pub mod windows; + + pub use family::{Family, FamilyOrBoth}; + pub use id::{InterfaceId, MonType}; +-pub use netmon::{BoxStream, Netmon, PlatformMon, platform_mon}; ++pub use netmon::{BoxStream, Netmon, platform_mon}; ++// Multiplex investigation patch: PlatformMon only exists on macos/linux/windows; ++// iOS/visionOS have no platform monitor and hit E0432 on this re-export. ++#[cfg(any(target_os = "macos", target_os = "linux", windows))] ++pub use netmon::PlatformMon; + + /// An event produced by the network monitor. + #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/project.yml b/project.yml index edb9e41f..2f82eb81 100644 --- a/project.yml +++ b/project.yml @@ -131,6 +131,22 @@ targets: PRODUCT_NAME: Multiplex MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "202607181" + # tailscale-rs C ABI, linked per SDK — including the xros SDKs, which + # is the whole point vs the Go path (Rust has an aarch64-apple-visionos + # target; Go does not). Archives are git-ignored; rebuild with + # Tools/build-tailscale-rs.sh. + "SWIFT_INCLUDE_PATHS[sdk=iphoneos*]": "$(SRCROOT)/Vendor/tailscale-rs/include" + "SWIFT_INCLUDE_PATHS[sdk=iphonesimulator*]": "$(SRCROOT)/Vendor/tailscale-rs/include" + "SWIFT_INCLUDE_PATHS[sdk=xros*]": "$(SRCROOT)/Vendor/tailscale-rs/include" + "SWIFT_INCLUDE_PATHS[sdk=xrsimulator*]": "$(SRCROOT)/Vendor/tailscale-rs/include" + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": "$(SRCROOT)/Vendor/tailscale-rs/lib/ios-arm64" + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": "$(SRCROOT)/Vendor/tailscale-rs/lib/ios-simulator" + "LIBRARY_SEARCH_PATHS[sdk=xros*]": "$(SRCROOT)/Vendor/tailscale-rs/lib/xros-arm64" + "LIBRARY_SEARCH_PATHS[sdk=xrsimulator*]": "$(SRCROOT)/Vendor/tailscale-rs/lib/xros-simulator" + "OTHER_LDFLAGS[sdk=iphoneos*]": "$(inherited) -ltailscalers -framework CoreFoundation -liconv" + "OTHER_LDFLAGS[sdk=iphonesimulator*]": "$(inherited) -ltailscalers -framework CoreFoundation -liconv" + "OTHER_LDFLAGS[sdk=xros*]": "$(inherited) -ltailscalers -framework CoreFoundation -liconv" + "OTHER_LDFLAGS[sdk=xrsimulator*]": "$(inherited) -ltailscalers -framework CoreFoundation -liconv" # One target produces the universal iOS binary (iPhone + iPad) and the # separate visionOS binary. Fastlane archives Release, so family 1 must # live in the base setting rather than in a Debug-only override. From e0a96829c4aa35930e6d62d1039e14fd577d9e5e Mon Sep 17 00:00:00 2001 From: Jhen-Jie Hong Date: Thu, 23 Jul 2026 20:46:56 +0800 Subject: [PATCH 2/3] Fix tailnet dial: set the port directly, not via the broken ffi setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real tailnet dials failed with "unaddressable destination" and a remote_endpoint of :0 — the port never made it onto the sockaddr. ts_parse_ip fills the address with port 0, and tailscale-rs's ts_sockaddr_set_port is a no-op: it mutates a by-value copy of the union field (`unsafe { addr.sa_data.sockaddr_in }.sin_port = port`, net_types.rs :361/:365) and discards it. So the port stayed 0 for every dial. Write sin_port/sin6_port directly onto the struct after parsing, host byte order, keyed off the ffi's own TS_AF_INET/TS_AF_INET6 values — the pattern the C examples use (tcp_echo.c sets .sin_port = 1234 and prints it with %u). The fake-dial DEBUG path uses a plain kernel socket and never hit this. Verified by build on all platforms + the upstream source; needs a real tailnet to exercise end to end. --- .../Services/Tailscale/TailscaleTunnel.swift | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Multiplex/Services/Tailscale/TailscaleTunnel.swift b/Multiplex/Services/Tailscale/TailscaleTunnel.swift index e9195c05..00455469 100644 --- a/Multiplex/Services/Tailscale/TailscaleTunnel.swift +++ b/Multiplex/Services/Tailscale/TailscaleTunnel.swift @@ -344,15 +344,36 @@ actor TailscaleTunnel { throw TailscaleTunnelFailure(message: "Couldn't form tailnet address for \(name).") } } - guard ts_sockaddr_set_port(&addr, UInt16(port)) == 0 else { - throw TailscaleTunnelFailure(message: "Invalid port \(port).") - } + // ts_parse_ip fills the address with port 0, and the ffi's + // ts_sockaddr_set_port is a no-op (it mutates a by-value copy of the + // union field, upstream bug at net_types.rs:361/365). Write the port + // directly, host byte order (the ffi and its examples use host order: + // tcp_echo.c sets .sin_port = 1234 and prints it with %u). + try setPort(UInt16(port), on: &addr) guard let stream = ts_tcp_connect(device, &addr) else { throw TailscaleTunnelFailure(message: "Tailscale couldn't reach \(hostname):\(port).") } return stream } + /// TS_AF_INET / TS_AF_INET6 (tailscale.h) — the ffi's own family values, + /// not the platform's AF_INET (which differs). + private static let tsAFInet: UInt16 = 2 + private static let tsAFInet6: UInt16 = 23 + + private static func setPort(_ port: UInt16, on addr: inout ts_sockaddr) throws { + switch addr.sa_family { + case tsAFInet: + addr.sa_data.sockaddr_in.sin_port = port + case tsAFInet6: + addr.sa_data.sockaddr_in6.sin6_port = port + default: + throw TailscaleTunnelFailure( + message: "Unsupported tailnet address family \(addr.sa_family)." + ) + } + } + private static func readIPs(device: OpaquePointer) -> [String] { var ips: [String] = [] var v4 = ts_in_addr_t(0, 0, 0, 0) From e590a39fd0a1b55660f8adb594a362f78f2fc9b2 Mon Sep 17 00:00:00 2001 From: Jhen-Jie Hong Date: Thu, 23 Jul 2026 20:51:20 +0800 Subject: [PATCH 3/3] Retry tailnet dials while the DERP underlay comes up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the port fix, real dials reached the connect stage but failed with "no region stored in multiderp, no underlay route" then connection reset — the peer's DERP region is in the netmap, but the local node hadn't yet registered a transport for it. tailscale-rs establishes its DERP underlay (the only data path today) asynchronously after the netmap, and the C ABI exposes no readiness signal beyond ts_ipv4_addr (which unblocks at the netmap, earlier). So a dial right after startup races DERP setup. connectWithRetry retries the dial with backoff (8 × 900 ms) so the transient window self-heals as the region's Uniderp spawns and reports its transport. Bounded, so a genuinely unroutable peer still fails in ~7 s rather than hanging; each attempt logs to the tailscale category. If DERP never establishes on a device that's an upstream limit (DERP-only, no Apple netmon), not a race — needs on-device confirmation that later attempts land. --- .../Services/Tailscale/TailscaleTunnel.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Multiplex/Services/Tailscale/TailscaleTunnel.swift b/Multiplex/Services/Tailscale/TailscaleTunnel.swift index 00455469..7466c00f 100644 --- a/Multiplex/Services/Tailscale/TailscaleTunnel.swift +++ b/Multiplex/Services/Tailscale/TailscaleTunnel.swift @@ -107,11 +107,42 @@ actor TailscaleTunnel { } return try await Self.performBlocking(on: ffiQueue) { - let stream = try Self.connect(device: device, hostname: hostname, port: port) + let stream = try Self.connectWithRetry(device: device, hostname: hostname, port: port) return TailscaleHandleRemote(stream: stream) } } + /// tailscale-rs establishes its DERP underlay (the only data path today) + /// asynchronously after the netmap arrives, and the C ABI exposes no + /// "underlay ready" signal — only `ts_ipv4_addr`, which unblocks at the + /// netmap. So an early dial can fail with "no region stored in multiderp, + /// no underlay route" / connection reset before a DERP region's transport + /// registers. Retry with backoff so that transient window self-heals; + /// bounded so a genuinely unroutable peer still fails in reasonable time. + private static func connectWithRetry( + device: OpaquePointer, + hostname: String, + port: Int + ) throws -> OpaquePointer { + let maxAttempts = 8 + let backoffMicroseconds: useconds_t = 900_000 + var lastError: Error = TailscaleTunnelFailure( + message: "Tailscale couldn't reach \(hostname):\(port)." + ) + for attempt in 1...maxAttempts { + do { + return try connect(device: device, hostname: hostname, port: port) + } catch { + lastError = error + logger.debug("tailnet dial attempt \(attempt)/\(maxAttempts) failed: \(error.localizedDescription, privacy: .public)") + if attempt < maxAttempts { + usleep(backoffMicroseconds) + } + } + } + throw lastError + } + static func loadConfiguration() async -> Configuration { await Task.detached(priority: .userInitiated) { #if DEBUG