diff --git a/.gitignore b/.gitignore index 9eab3a86..b360f971 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ xcuserdata/ # Dev harness artifacts (generated keys, sshd runtime state) Tools/dev-sshd/state/ +# Vendored libtailscale static archives (~27 MB each) — rebuild with +# Tools/build-libtailscale.sh; the header/modulemap stay tracked +Vendor/libtailscale/lib/ + local-plan/ # fastlane — generated reports and local secrets (see fastlane/SETUP.md) diff --git a/AGENTS.md b/AGENTS.md index 70ad8ea0..f0ba663b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,15 @@ 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; tsnet's own log lines land in the unified log under + category `tailscale` (debug level — `log stream`). - `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..5b96115c 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. The node is unavailable on visionOS and 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/TailscaleDialAddress.swift b/Multiplex/Models/TailscaleDialAddress.swift new file mode 100644 index 00000000..e6c42c08 --- /dev/null +++ b/Multiplex/Models/TailscaleDialAddress.swift @@ -0,0 +1,38 @@ +import Foundation + +enum TailscaleDialAddress { + static func format(hostname: String, port: Int) -> String { + let formattedHostname: String + if hostname.hasPrefix("[") && hostname.hasSuffix("]") { + formattedHostname = hostname + } else if hostname.contains(":") { + formattedHostname = "[\(hostname)]" + } else { + formattedHostname = hostname + } + return "\(formattedHostname):\(port)" + } +} + +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..f664ec2d 100644 --- a/Multiplex/Services/KeychainStore.swift +++ b/Multiplex/Services/KeychainStore.swift @@ -3,9 +3,10 @@ import Security /// Minimal Keychain wrapper. Every item is written as *synchronizable*, so /// iCloud Keychain carries it to the user's other devices — end-to-end -/// encrypted, no entitlement or CloudKit container required. Two item +/// encrypted, no entitlement or CloudKit container required. Three item /// families share the same primitives: /// - per-host secrets (password / private key / passphrase) +/// - the app-wide Tailscale auth key, under a fixed namespace UUID /// - mirrored host records: the non-secret `Host` JSON, one item per host, /// which is how the host list itself crosses devices /// @@ -20,6 +21,7 @@ enum KeychainStore { case password case privateKey case keyPassphrase + case tailscaleAuthKey } private static func account(_ hostID: UUID, _ kind: Kind) -> String { 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..4ef36c9e 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: + "Tailscale connections aren't available on this device (Vision Pro)." 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(CLibTailscale) + 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(CLibTailscale) + let descriptor = try await TailscaleTunnel.shared.dial( + hostname: host.hostname, + port: host.port + ) + // Citadel's channel-injection overload asserts + // inEventLoop in its synchronous prefix, so the dialed + // fd 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: descriptor) + 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/TailscaleForceLogin.c b/Multiplex/Services/Tailscale/TailscaleForceLogin.c new file mode 100644 index 00000000..1c83326a --- /dev/null +++ b/Multiplex/Services/Tailscale/TailscaleForceLogin.c @@ -0,0 +1,41 @@ +// tsnet only consumes a configured auth key inside its +// StartLoginInteractive branch, and reaches that branch when the backend +// state is NeedsLogin OR the TSNET_FORCE_LOGIN env knob is set. A fresh +// state store reads as NoState at that check, so a first-ever login with +// an auth key is silently skipped ("Authkey is set; but state is NoState. +// Ignoring authkey." — tsnet v1.94.1, tsnet.go:763-770, observed on +// device 2026-07-23). +// +// The knob must be in the environment BEFORE the Go runtime captures it, +// which happens in the libtailscale archive's own load-time constructor — +// a setenv from Swift is too late. This constructor is compiled into the +// app's object files, which the linker places ahead of OTHER_LDFLAGS +// libraries, so its mod_init_func entry runs first. +// +// The knob is set ONLY while the tsnet state file does not exist: forcing +// it unconditionally would make every tailscale_up on an already-enrolled +// node re-run interactive login (env values are frozen for the process +// lifetime once Go captures them). On visionOS the file compiles and the +// state path never exists, but nothing links or reads the knob — inert. +#include +#include +#include + +__attribute__((constructor)) +static void multiplex_tsnet_force_login(void) { + const char *home = getenv("HOME"); + if (home == NULL) { + return; + } + char path[1024]; + int written = snprintf( + path, sizeof path, + "%s/Library/Application Support/tailscale-node/tailscaled.state", + home); + if (written <= 0 || (size_t)written >= sizeof path) { + return; + } + if (access(path, F_OK) != 0) { + setenv("TSNET_FORCE_LOGIN", "1", 1); + } +} diff --git a/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift b/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift new file mode 100644 index 00000000..d5523a2a --- /dev/null +++ b/Multiplex/Services/Tailscale/TailscaleLoopbackRelay.swift @@ -0,0 +1,213 @@ +import Darwin +import Dispatch +import Foundation +import os + +/// One-shot localhost TCP relay between Citadel and an already-connected +/// socket (the `tailscale_dial` socketpair end). 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 fd. +/// +/// 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 tailscale fd, 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 remoteFD: CInt = -1 + private var acceptedFD: CInt = -1 + private var wakePipe: (read: CInt, write: CInt) = (-1, -1) + private var finishedPumps = 0 + private var isClosed = false + + struct Failure: Error, LocalizedError { + let message: String + var errorDescription: String? { message } + } + + /// Takes ownership of `remoteFD` unconditionally: on throw it has been + /// closed, on success the relay closes it when the splice ends. + func start(spliceTo remoteFD: CInt) throws -> UInt16 { + guard lock.withLock({ !isClosed }) else { + Darwin.close(remoteFD) + throw Failure(message: "Relay already closed.") + } + let listener = socket(AF_INET, SOCK_STREAM, 0) + guard listener >= 0 else { + Darwin.close(remoteFD) + 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) + Darwin.close(remoteFD) + 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) + Darwin.close(remoteFD) + 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) + Darwin.close(remoteFD) + throw Failure(message: "Relay wake pipe failed (errno \(errno)).") + } + + lock.withLock { + self.listenerFD = listener + self.remoteFD = remoteFD + 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 pump reads with shutdown; the pumps then close the fds. + func close() { + let (wakeWrite, hadStarted): (CInt, Bool) = lock.withLock { + guard !isClosed else { return (-1, false) } + isClosed = true + if acceptedFD >= 0 || remoteFD >= 0 { + if acceptedFD >= 0 { shutdown(acceptedFD, SHUT_RDWR) } + if remoteFD >= 0 { shutdown(remoteFD, SHUT_RDWR) } + } + 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 + } + + let remote = lock.withLock { remoteFD } + queue.async { self.pump(from: accepted, to: remote) } + queue.async { self.pump(from: remote, to: accepted) } + } + + private func pump(from source: CInt, to destination: CInt) { + var buffer = [UInt8](repeating: 0, count: Self.pumpBufferSize) + outer: while true { + var bytesRead: Int + repeat { + bytesRead = read(source, &buffer, buffer.count) + } while bytesRead < 0 && errno == EINTR + guard bytesRead > 0 else { break } + + var offset = 0 + while offset < bytesRead { + var written: Int + repeat { + written = buffer[offset...].withUnsafeBytes { + write(destination, $0.baseAddress, bytesRead - offset) + } + } while written < 0 && errno == EINTR + guard written > 0 else { break outer } + offset += written + } + } + // Forward the half-close so the far side's read sees EOF rather + // than a stall; fds are closed only once both directions end. + shutdown(destination, SHUT_WR) + let finished = lock.withLock { + finishedPumps += 1 + return finishedPumps + } + if finished == 2 { + closeEverything() + } + } + + private func closeEverything() { + lock.withLock { + isClosed = true + for fd in [listenerFD, remoteFD, acceptedFD, wakePipe.read, wakePipe.write] where fd >= 0 { + Darwin.close(fd) + } + listenerFD = -1 + remoteFD = -1 + acceptedFD = -1 + wakePipe = (-1, -1) + } + Self.logger.debug("relay closed") + } +} diff --git a/Multiplex/Services/Tailscale/TailscaleTunnel.swift b/Multiplex/Services/Tailscale/TailscaleTunnel.swift new file mode 100644 index 00000000..2a617de6 --- /dev/null +++ b/Multiplex/Services/Tailscale/TailscaleTunnel.swift @@ -0,0 +1,621 @@ +#if canImport(CLibTailscale) +import CLibTailscale +import Foundation +import UIKit +import os + +struct TailscaleTunnelFailure: Error, LocalizedError, CustomStringConvertible, Sendable { + let message: String + var nodeWasClosed = false + + var errorDescription: String? { message } + var description: String { message } +} + +/// One userspace tailnet node for this app install. tsnet persists its node +/// identity in Application Support; that plaintext state directory is the +/// documented exception for this experimental spike because the C ABI does +/// not expose a Keychain-backed state store. +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" + ) + static let authKeyNamespace = UUID( + uuidString: "7BD81B2F-B868-4C52-A968-70A03B65CB23" + )! + + private static let controlURLDefaultsKey = "TailscaleControlURL" + private static let startupTimeout: TimeInterval = 30 + + private let cQueue = DispatchQueue( + label: "app.multiplexterm.multiplex.tailscale" + ) + /// `tailscale_up` occupies `cQueue`; its documented cancellation call + /// must therefore be able to run concurrently when the deadline expires. + private let cancellationQueue = DispatchQueue( + label: "app.multiplexterm.multiplex.tailscale.cancel" + ) + + private(set) var state: State = .stopped + private var stateObservers: [ + UUID: AsyncStream.Continuation + ] = [:] + private var node: CInt? + private var startTask: Task? + + 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 + } + + func dial(hostname: String, port: Int) async throws -> CInt { + #if DEBUG + // Headless seam proof without a tailnet: the relay + Citadel path + // is identical from here on whether the fd came from tsnet or a + // plain TCP connect to the harness sshd. + if ProcessInfo.processInfo.environment["MULTIPLEX_TAILSCALE_FAKE_DIAL"] == "1" { + let address = TailscaleDialAddress.format(hostname: hostname, port: port) + return try await Self.performC(on: cQueue) { + try Self.debugPlainSocket(hostname: hostname, port: port, address: address) + } + } + #endif + try await ensureRunning() + guard let node else { + throw TailscaleTunnelFailure( + message: "The embedded Tailscale node stopped before dialing." + ) + } + + let address = TailscaleDialAddress.format( + hostname: hostname, + port: port + ) + return try await Self.performC(on: cQueue) { + var connection: CInt = -1 + let result = address.withCString { addressPointer in + "tcp".withCString { networkPointer in + tailscale_dial( + node, + networkPointer, + addressPointer, + &connection + ) + } + } + try Self.check( + result, + operation: "Tailscale dial to \(address)", + node: node + ) + guard connection >= 0 else { + throw TailscaleTunnelFailure( + message: "Tailscale dial to \(address) returned no connection." + ) + } + return connection + } + } + + 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: authKeyNamespace, + 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: authKeyNamespace, + kind: .tailscaleAuthKey + ) + } else { + KeychainStore.set( + authKey, + for: authKeyNamespace, + 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 node != nil, case .running = state { + return + } + + if let startTask { + try await finishStart(startTask) + return + } + + transition(to: .starting) + let cQueue = cQueue + let cancellationQueue = cancellationQueue + 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 } + return try await Self.startNode( + configuration: Configuration( + authKey: authKey, + controlURL: configuration.controlURL + ), + deviceName: deviceName, + cQueue: cQueue, + cancellationQueue: cancellationQueue + ) + } + startTask = task + try await finishStart(task) + } + + private func finishStart(_ task: Task) async throws { + do { + let started = try await task.value + if node == nil { + node = started.node + transition(to: .running(ips: started.ips)) + } + startTask = nil + } catch { + node = 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 node: CInt + var ips: [String] + } + + private static func startNode( + configuration: Configuration, + deviceName: String, + cQueue: DispatchQueue, + cancellationQueue: DispatchQueue + ) async throws -> StartedNode { + let stateDirectory = try await prepareStateDirectory() + let hostname = TailscaleNodeHostname.format(deviceName: deviceName) + let node = try await createNode( + stateDirectory: stateDirectory, + hostname: hostname, + configuration: configuration, + on: cQueue + ) + + do { + try await waitUntilReady( + node, + cQueue: cQueue, + cancellationQueue: cancellationQueue + ) + let ips = try await readIPs(node, on: cQueue) + return StartedNode(node: node, ips: ips) + } catch { + if (error as? TailscaleTunnelFailure)?.nodeWasClosed != true { + await closeNode(node, on: cQueue) + } + throw error + } + } + + private static func prepareStateDirectory() async throws -> String { + try await Task.detached(priority: .utility) { + let manager = FileManager.default + let applicationSupport = try manager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = applicationSupport.appendingPathComponent( + "tailscale-node", + isDirectory: true + ) + try manager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.path + }.value + } + + private static func createNode( + stateDirectory: String, + hostname: String, + configuration: Configuration, + on queue: DispatchQueue + ) async throws -> CInt { + try await performC(on: queue) { + let node = tailscale_new() + guard node >= 0 else { + throw TailscaleTunnelFailure( + message: "Tailscale couldn't allocate its userspace node." + ) + } + + do { + try stateDirectory.withCString { + try check( + tailscale_set_dir(node, $0), + operation: "Tailscale state-directory setup", + node: node + ) + } + try hostname.withCString { + try check( + tailscale_set_hostname(node, $0), + operation: "Tailscale hostname setup", + node: node + ) + } + try configuration.authKey.withCString { + try check( + tailscale_set_authkey(node, $0), + operation: "Tailscale auth-key setup", + node: node + ) + } + + let controlURL = configuration.controlURL.trimmingCharacters( + in: .whitespacesAndNewlines + ) + if !controlURL.isEmpty { + try controlURL.withCString { + try check( + tailscale_set_control_url(node, $0), + operation: "Tailscale control-URL setup", + node: node + ) + } + } + + try check( + tailscale_set_ephemeral(node, 0), + operation: "Tailscale persistent-node setup", + node: node + ) + // tsnet's logs are the only field-debugging signal this + // node has (the auth-key/NoState failure was diagnosed + // from them); -1 (discard) also proved leaky on device. + try check( + tailscale_set_logfd(node, makeLogSink()), + operation: "Tailscale logging setup", + node: node + ) + try check( + tailscale_start(node), + operation: "Tailscale startup", + node: node + ) + return node + } catch { + closeNodeSynchronously(node) + throw error + } + } + } + + private static func waitUntilReady( + _ node: CInt, + cQueue: DispatchQueue, + cancellationQueue: DispatchQueue + ) async throws { + try await withCheckedThrowingContinuation { continuation in + let gate = TailscaleStartupGate(continuation) + + cQueue.async { + let result = tailscale_up(node) + if result == 0 { + gate.resolveFromUp(.success(())) + } else { + gate.resolveFromUp(.failure(apiFailure( + operation: "Tailscale startup", + node: node + ))) + } + } + + cancellationQueue.asyncAfter( + deadline: .now() + startupTimeout + ) { + guard gate.claimTimeout() else { return } + let closeResult = tailscale_close(node) + let closeDetail: String? + if closeResult == 0 { + closeDetail = nil + } else { + closeDetail = errorMessage(node: node) + } + let suffix = closeDetail.map { + " Cancellation also reported: \($0)" + } ?? "" + gate.resolveTimeout(TailscaleTunnelFailure( + message: "Tailscale startup timed out after 30 seconds.\(suffix)", + nodeWasClosed: true + )) + } + } + } + + private static func readIPs( + _ node: CInt, + on queue: DispatchQueue + ) async throws -> [String] { + try await performC(on: queue) { + var buffer = [CChar](repeating: 0, count: 4096) + let result = buffer.withUnsafeMutableBufferPointer { + tailscale_getips(node, $0.baseAddress, $0.count) + } + try check( + result, + operation: "Tailscale address lookup", + node: node + ) + return String(cString: buffer) + .split(separator: ",") + .map(String.init) + } + } + + private static func closeNode( + _ node: CInt, + on queue: DispatchQueue + ) async { + _ = try? await performC(on: queue) { + closeNodeSynchronously(node) + } + } + + private static func closeNodeSynchronously(_ node: CInt) { + let result = tailscale_close(node) + if result != 0 { + _ = errorMessage(node: node) + } + } + + #if DEBUG + /// Fake-dial escape hatch: a plain blocking TCP connect standing in + /// for `tailscale_dial`, so the relay + Citadel seam can be driven + /// end-to-end against the local harness without a tailnet. + private static func debugPlainSocket( + hostname: String, + port: Int, + address: String + ) 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 \(address)." + ) + } + 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 connect(fd, first.pointee.ai_addr, first.pointee.ai_addrlen) == 0 else { + close(fd) + throw TailscaleTunnelFailure( + message: "Fake dial to \(address) failed (errno \(errno))." + ) + } + return fd + } + #endif + + /// Write end of a pipe whose reader forwards each tsnet log line to + /// the unified log (category `tailscale`, debug). The reader exits on + /// EOF when the node closes its end; falls back to -1 (discard) if + /// the pipe can't be made. + private static func makeLogSink() -> CInt { + var fds: [CInt] = [-1, -1] + guard pipe(&fds) == 0 else { return -1 } + let readFD = fds[0] + DispatchQueue.global(qos: .utility).async { + guard let stream = fdopen(readFD, "r") else { + close(readFD) + return + } + var line = [CChar](repeating: 0, count: 4096) + while fgets(&line, Int32(line.count), stream) != nil { + let text = String(cString: line) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + logger.debug("tsnet: \(text, privacy: .public)") + } + } + fclose(stream) + } + return fds[1] + } + + private static func check( + _ result: CInt, + operation: String, + node: CInt + ) throws { + guard result == 0 else { + throw apiFailure(operation: operation, node: node) + } + } + + private static func apiFailure( + operation: String, + node: CInt + ) -> TailscaleTunnelFailure { + TailscaleTunnelFailure( + message: "\(operation) failed: \(errorMessage(node: node))" + ) + } + + private static func errorMessage(node: CInt) -> String { + var buffer = [CChar](repeating: 0, count: 4096) + let result = buffer.withUnsafeMutableBufferPointer { + tailscale_errmsg(node, $0.baseAddress, $0.count) + } + guard result == 0 else { + return "the embedded node did not provide an error message" + } + let message = String(cString: buffer) + return message.isEmpty ? "unknown error" : message + } + + private static func performC( + 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 final class TailscaleStartupGate: @unchecked Sendable { + private enum Phase: Equatable { + case waiting + case cancelling + case finished + } + + private let lock = NSLock() + private var phase = Phase.waiting + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resolveFromUp(_ result: Result) { + let continuation: CheckedContinuation? = lock.withLock { + guard phase == .waiting else { return nil } + phase = .finished + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume(with: result) + } + + func claimTimeout() -> Bool { + lock.withLock { + guard phase == .waiting else { return false } + phase = .cancelling + return true + } + } + + func resolveTimeout(_ error: Error) { + let continuation: CheckedContinuation? = lock.withLock { + guard phase == .cancelling else { return nil } + phase = .finished + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume(throwing: error) + } +} +#endif diff --git a/Multiplex/Views/Deck/AddHostSheet.swift b/Multiplex/Views/Deck/AddHostSheet.swift index a4f6ce95..367dcd91 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(CLibTailscale) + /// 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,53 @@ struct AddHostSheet: View { } private var transportSection: some View { + transportSectionBody + #if canImport(CLibTailscale) + // 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(CLibTailscale) + 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) + } + } + #else + TallyFormBoolField( + "Connect via Tailscale", + isOn: $useTailscale, + status: "UNAVAILABLE", + accessibilityHint: "Embedded Tailscale is unavailable on this device" + ) + .disabled(true) + TallyFormRow { + Text("Embedded Tailscale is unavailable on this device.") + .font(.ui(10)) + .foregroundStyle(Theme.signal2) + .fixedSize(horizontal: false, vertical: true) + } + #endif + TallyFormBoolField( "Connect with mosh", isOn: moshToggle, @@ -259,10 +311,17 @@ struct AddHostSheet: View { } private var transportDetail: String { + if useTailscale { + #if canImport(CLibTailscale) + return "SSH runs through this device's embedded Tailscale node. Add a reusable auth key in Settings. Mosh is unavailable on this path." + #else + return "This host requests an embedded Tailscale connection, which isn't available on Vision Pro." + #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,9 +379,17 @@ struct AddHostSheet: View { } private var testDetail: String { - 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." + if useTailscale { + #if canImport(CLibTailscale) + "Starts the embedded Tailscale node, signs in to SSH through it, then looks for tmux on the host." + #else + "Reports that embedded Tailscale is unavailable on this device." + #endif + } else if useMosh { + "Signs in over SSH with the settings above, then looks for tmux and mosh-server on the host." + } else { + "Signs in over SSH with the settings above, then looks for tmux on the host." + } } /// Everything a test's outcome depends on. Edits reset the shown result, @@ -330,7 +397,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 +717,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 +776,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 +804,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 +870,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..572fad05 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(CLibTailscale) + @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(CLibTailscale) + tailscaleSection + #endif alertsSection appLockSection proSection @@ -40,12 +50,34 @@ struct SettingsView: View { .toolbar { ChassisSheetTitle("Settings") ToolbarItem(placement: .confirmationAction) { - ChassisBarButton("Done") { dismiss() } + ChassisBarButton("Done", action: finish) + #if canImport(CLibTailscale) + .disabled(savingTailscaleConfiguration) + #endif } } .navigationDestination(item: $editingTheme) { theme in ThemeEditorView(theme: theme, onSave: save) } + #if canImport(CLibTailscale) + .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 @@ -161,6 +193,81 @@ struct SettingsView: View { return "New themes begin with the active palette. Use the row menu to edit, duplicate, or delete one." } + #if canImport(CLibTailscale) + private var tailscaleSection: some View { + TallyFormSection( + "Tailscale", + detail: "Use a reusable auth key: every iPhone or iPad becomes its own tailnet node. The key syncs 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 var alertsSection: some View { TallyFormSection( "Agent alerts", @@ -312,6 +419,31 @@ struct SettingsView: View { } } + private func finish() { + #if canImport(CLibTailscale) + 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 + } + #if DEBUG private func presentThemeEditorForVerificationIfRequested() { guard ProcessInfo.processInfo.environment["MULTIPLEX_AUTO_SETTINGS"] == "theme" diff --git a/MultiplexTests/HostSyncTests.swift b/MultiplexTests/HostSyncTests.swift index d6fd9a75..d76080fe 100644 --- a/MultiplexTests/HostSyncTests.swift +++ b/MultiplexTests/HostSyncTests.swift @@ -101,6 +101,7 @@ 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, []) @@ -184,4 +185,28 @@ final class HostSyncTests: XCTestCase { XCTAssertEqual(decoded.moshServerPath, "/opt/homebrew/bin/mosh-server") XCTAssertEqual(decoded.moshPorts, "60000:61000") } + + 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) + } } diff --git a/MultiplexTests/HostTestTests.swift b/MultiplexTests/HostTestTests.swift index ebfe2eb1..7ed0e353 100644 --- a/MultiplexTests/HostTestTests.swift +++ b/MultiplexTests/HostTestTests.swift @@ -60,6 +60,20 @@ final class HostTestTests: XCTestCase { "Paste a private key first.") } + func testVisionOSTailscaleFailureIsSpecific() { + XCTAssertEqual( + SSHConnectionError.tailscaleUnavailable.userMessage(host: host()), + "Tailscale connections aren't available on this device (Vision Pro)." + ) + XCTAssertEqual( + HostTest.failureMessage( + for: SSHConnectionError.tailscaleUnavailable, + host: host() + ), + "Tailscale connections aren't available on this device (Vision Pro)." + ) + } + func testEncryptedKeyFailuresAskForThePassphrase() { XCTAssertEqual( HostTest.failureMessage( diff --git a/MultiplexTests/MoshBootstrapTests.swift b/MultiplexTests/MoshBootstrapTests.swift index c303ef16..284e3d43 100644 --- a/MultiplexTests/MoshBootstrapTests.swift +++ b/MultiplexTests/MoshBootstrapTests.swift @@ -101,4 +101,37 @@ 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/TailscaleDialAddressTests.swift b/MultiplexTests/TailscaleDialAddressTests.swift new file mode 100644 index 00000000..7be91233 --- /dev/null +++ b/MultiplexTests/TailscaleDialAddressTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import Multiplex + +final class TailscaleDialAddressTests: XCTestCase { + func testFormatsPlainHostname() { + XCTAssertEqual( + TailscaleDialAddress.format(hostname: "devbox", port: 22), + "devbox:22" + ) + } + + func testPreservesMagicDNSName() { + XCTAssertEqual( + TailscaleDialAddress.format( + hostname: "devbox.tail1234.ts.net", + port: 22 + ), + "devbox.tail1234.ts.net:22" + ) + } + + func testFormatsIPv4Literal() { + XCTAssertEqual( + TailscaleDialAddress.format(hostname: "100.64.0.8", port: 22), + "100.64.0.8:22" + ) + } + + func testBracketsIPv6Literal() { + XCTAssertEqual( + TailscaleDialAddress.format(hostname: "::1", port: 22), + "[::1]:22" + ) + } + + func testPreservesBracketedIPv6Literal() { + XCTAssertEqual( + TailscaleDialAddress.format(hostname: "[::1]", port: 22), + "[::1]:22" + ) + } + + func testFormatsNonDefaultPort() { + XCTAssertEqual( + TailscaleDialAddress.format(hostname: "devbox", port: 2222), + "devbox:2222" + ) + } + + func testFormatsNodeHostnameFromDeviceName() { + XCTAssertEqual( + TailscaleNodeHostname.format(deviceName: "Jhen’s iPad Pro"), + "multiplex-jhen-s-ipad-pro" + ) + XCTAssertEqual( + TailscaleNodeHostname.format(deviceName: "🛰️"), + "multiplex" + ) + } +} diff --git a/MultiplexTests/TailscaleLoopbackRelayTests.swift b/MultiplexTests/TailscaleLoopbackRelayTests.swift new file mode 100644 index 00000000..2a1a33cb --- /dev/null +++ b/MultiplexTests/TailscaleLoopbackRelayTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import Multiplex + +/// In-process only: one end of a socketpair stands in for the tailscale +/// fd, a plain TCP client stands in for Citadel. Reads are poll-bounded so +/// a broken relay fails fast instead of hanging the suite. +final class TailscaleLoopbackRelayTests: XCTestCase { + 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 + } + + /// Reads exactly `count` bytes with a 2 s poll bound per chunk. + 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 } + 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: 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 fd as EOF") + } + + func testSecondConnectIsRefusedAfterOneShotAccept() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + let port = try relay.start(spliceTo: relayEnd) + + let first = connectClient(port: port) + XCTAssertGreaterThanOrEqual(first, 0) + defer { close(first) } + + // A round-trip proves the accept has happened (and therefore the + // listener is closed) before the second attempt. + 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 testCloseBeforeAcceptReleasesSplicedFD() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + _ = try relay.start(spliceTo: relayEnd) + + relay.close() + XCTAssertTrue(reachesEOF(testEnd), "close() before any accept must close the spliced fd") + } + + func testStartAfterCloseThrowsAndClosesFD() throws { + let (relayEnd, testEnd) = try makeSocketPair() + defer { close(testEnd) } + let relay = TailscaleLoopbackRelay() + relay.close() + XCTAssertThrowsError(try relay.start(spliceTo: relayEnd)) + XCTAssertTrue(reachesEOF(testEnd)) + } +} diff --git a/Tools/build-libtailscale.sh b/Tools/build-libtailscale.sh new file mode 100755 index 00000000..48837939 --- /dev/null +++ b/Tools/build-libtailscale.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Builds the vendored libtailscale static archives (device + simulator) at +# the pinned commit and installs them under Vendor/libtailscale/lib/. +# Requires: go (any recent version; GOTOOLCHAIN fetches the one go.mod +# wants), git, network access. See Vendor/libtailscale/README.md. +set -eu + +PINNED_COMMIT=5e89501def80a6579ca5d0f9a02f336be62b8f2e +REPO_URL=https://github.com/tailscale/libtailscale +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VENDOR="$ROOT/Vendor/libtailscale" +WORK="${LIBTAILSCALE_BUILD_DIR:-$(mktemp -d /tmp/libtailscale-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" + +# Sanity: the vendored header must match the pinned commit's. +if ! cmp -s "$WORK/tailscale.h" "$VENDOR/include/tailscale.h"; then + echo "error: Vendor/libtailscale/include/tailscale.h differs from the pinned commit's tailscale.h — reconcile before building" >&2 + exit 1 +fi + +# The simulator archive must be UNIVERSAL (arm64 + x86_64): a Release +# simulator build sets ONLY_ACTIVE_ARCH=NO and links both slices. +make -C "$WORK" libtailscale_ios.a libtailscale_ios_sim_arm64.a libtailscale_ios_sim_x86_64.a +lipo -create -output "$WORK/libtailscale_ios_sim.a" \ + "$WORK/libtailscale_ios_sim_x86_64.a" "$WORK/libtailscale_ios_sim_arm64.a" + +mkdir -p "$VENDOR/lib/ios-arm64" "$VENDOR/lib/ios-simulator" +cp "$WORK/libtailscale_ios.a" "$VENDOR/lib/ios-arm64/libtailscale.a" +cp "$WORK/libtailscale_ios_sim.a" "$VENDOR/lib/ios-simulator/libtailscale.a" + +for slice in ios-arm64 ios-simulator; do + if ! nm -gU "$VENDOR/lib/$slice/libtailscale.a" 2>/dev/null | grep -q _tailscale_dial; then + echo "error: $slice archive is missing _tailscale_dial" >&2 + exit 1 + fi +done +lipo -info "$VENDOR/lib/ios-simulator/libtailscale.a" | grep -q x86_64 || { + echo "error: simulator archive is not universal" >&2 + exit 1 +} + +echo "Installed:" +ls -l "$VENDOR/lib/ios-arm64/libtailscale.a" "$VENDOR/lib/ios-simulator/libtailscale.a" diff --git a/Vendor/libtailscale/LICENSE b/Vendor/libtailscale/LICENSE new file mode 100644 index 00000000..8c9cccd6 --- /dev/null +++ b/Vendor/libtailscale/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023 Tailscale & AUTHORS. +All rights reserved. + +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/libtailscale/README.md b/Vendor/libtailscale/README.md new file mode 100644 index 00000000..d1854e04 --- /dev/null +++ b/Vendor/libtailscale/README.md @@ -0,0 +1,40 @@ +# libtailscale (vendored C ABI) + +Embedded userspace Tailscale (tsnet) as a Go `c-archive`, exposed to Swift as +the `CLibTailscale` module. Powers the per-host "Connect via Tailscale" +option (SSH over an in-process tailnet node — no system VPN, no +NetworkExtension). **iPhone/iPad only**: Go has no visionOS target, so the +visionOS build never links this and the feature is compiled out there +(`#if canImport(CLibTailscale)`). Full investigation record: +`local-plan/libtailscale-investigation.md`. + +## Provenance + +- Source: https://github.com/tailscale/libtailscale +- Pinned commit: `5e89501def80a6579ca5d0f9a02f336be62b8f2e` (main, 2026-02-27) +- Embeds `tailscale.com v1.94.1` (tsnet), built with Go 1.25.5 (via + GOTOOLCHAIN), `-ldflags -w -tags ios`, upstream Makefile targets. +- Licenses: BSD-3-Clause (this library and tailscale.com), MIT-lineage + (tailscale/wireguard-go), Apache-2.0 (gvisor netstack). `LICENSE` here is + libtailscale's. + +## Layout + +- `include/tailscale.h` — the hand-written public C API from the pinned + commit (verbatim). `include/module.modulemap` wraps it as `CLibTailscale`. +- `lib/ios-arm64/libtailscale.a` (device), `lib/ios-simulator/libtailscale.a` + (universal arm64 + x86_64 — Release simulator builds link both slices) + — **git-ignored** (~27-54 MB each). Rebuild them with: + +```sh +./Tools/build-libtailscale.sh +``` + +(Requires Go and network access; the script pins the commit above and +verifies the `_tailscale_dial` symbol.) `project.yml` links the archives via +`[sdk=iphoneos*]`/`[sdk=iphonesimulator*]`-conditional settings only — the +xros SDK never sees them. + +When bumping the pinned commit: re-read `tailscale.h` for ABI changes, +re-run the script, and update the investigation record's gates (§10) — +especially the UDP/datagram and SwiftPM ones. diff --git a/Vendor/libtailscale/include/module.modulemap b/Vendor/libtailscale/include/module.modulemap new file mode 100644 index 00000000..7cf3b93e --- /dev/null +++ b/Vendor/libtailscale/include/module.modulemap @@ -0,0 +1,4 @@ +module CLibTailscale { + header "tailscale.h" + export * +} diff --git a/Vendor/libtailscale/include/tailscale.h b/Vendor/libtailscale/include/tailscale.h new file mode 100644 index 00000000..4531f06c --- /dev/null +++ b/Vendor/libtailscale/include/tailscale.h @@ -0,0 +1,209 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// +// Tailscale C library. +// +// Use this library to compile Tailscale into your program and get +// an entirely userspace IP address on a tailnet. +// +// From here you can listen for other programs on your tailnet dialing +// you, or connect directly to other services. +// + + +#include + +#ifndef TAILSCALE_H +#define TAILSCALE_H + +#ifdef __cplusplus +extern "C" { +#endif + + +// tailscale is a handle onto a Tailscale server. +typedef int tailscale; + +// tailscale_new creates a tailscale server object. +// +// No network connection is initialized until tailscale_start is called. +extern tailscale tailscale_new(); + +// tailscale_start connects the server to the tailnet. +// +// Calling this function is optional as it will be called by the first use +// of tailscale_listen or tailscale_dial on a server. +// +// See also: tailscale_up. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_start(tailscale sd); + +// tailscale_up connects the server to the tailnet and waits for it to be usable. +// +// To cancel an in-progress call to tailscale_up, use tailscale_close. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_up(tailscale sd); + +// tailscale_close shuts down the server. +// +// Returns: +// 0 - success +// EBADF - sd is not a valid tailscale +// -1 - other error, details printed to the tsnet logger +extern int tailscale_close(tailscale sd); + +// The following set tailscale configuration options. +// +// Configure these options before any explicit or implicit call to tailscale_start. +// +// For details of each value see the godoc for the fields of tsnet.Server. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_set_dir(tailscale sd, const char* dir); +extern int tailscale_set_hostname(tailscale sd, const char* hostname); +extern int tailscale_set_authkey(tailscale sd, const char* authkey); +extern int tailscale_set_control_url(tailscale sd, const char* control_url); +extern int tailscale_set_ephemeral(tailscale sd, int ephemeral); + +// tailscale_set_logfd instructs the tailscale instance to write logs to fd. +// +// An fd value of -1 means discard all logging. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_set_logfd(tailscale sd, int fd); + +// A tailscale_conn is a connection to an address on the tailnet. +// +// It is a pipe(2) on which you can use read(2), write(2), and close(2). +// For extra control over the connection, see the tailscale_conn_* functions. +typedef int tailscale_conn; + +// Returns the IP addresses of the the Tailscale server as +// a comma separated list. +// +// The provided buffer must be of sufficient size to hold the concatenated +// IPs as strings. This is typically , but maybe empty, or +// contain any number of ips. The caller is responsible for parsing +// the output. You may assume the output is a list of well-formed IPs. +// +// Returns: +// 0 - Success +// EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections +// ERANGE - insufficient storage for buf +extern int tailscale_getips(tailscale sd, char* buf, size_t buflen); + +// tailscale_dial connects to the address on the tailnet. +// +// The newly allocated connection is written to conn_out. +// +// network is a NUL-terminated string of the form "tcp", "udp", etc. +// addr is a NUL-terminated string of an IP address or domain name. +// +// It will start the server if it has not been started yet. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscale_conn* conn_out); + +// A tailscale_listener is a socket on the tailnet listening for connections. +// +// It is much like allocating a system socket(2) and calling listen(2). +// Accept connections with tailscale_accept and close the listener with close. +// +// Under the hood, a tailscale_listener is one half of a socketpair itself, +// used to move the connection fd from Go to C. This means you can use epoll +// or its equivalent on a tailscale_listener to know if there is a connection +// read to accept. +typedef int tailscale_listener; + +// tailscale_listen listens for a connection on the tailnet. +// +// It is the spiritual equivalent to listen(2). +// The newly allocated listener is written to listener_out. +// +// network is a NUL-terminated string of the form "tcp", "udp", etc. +// addr is a NUL-terminated string of an IP address or domain name. +// +// It will start the server if it has not been started yet. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_listen(tailscale sd, const char* network, const char* addr, tailscale_listener* listener_out); + +// Returns the remote address for an incoming connection for a particular listener. The address (eitehr ip4 or ip6) +// will ge written to buf on on success. +// Returns: +// 0 - Success +// EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections +// ERANGE - insufficient storage for buf +extern int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen); + + +// tailscale_accept accepts a connection on a tailscale_listener. +// +// It is the spiritual equivalent to accept(2). +// +// The newly allocated connection is written to conn_out. +// +// Returns: +// 0 - success +// EBADF - listener is not a valid tailscale +// -1 - call tailscale_errmsg for details +extern int tailscale_accept(tailscale_listener listener, tailscale_conn* conn_out); + +// tailscale_loopback starts a loopback address server. +// +// The server has multiple functions. +// +// It can be used as a SOCKS5 proxy onto the tailnet. +// Authentication is required with the username "tsnet" and +// the value of proxy_cred used as the password. +// +// The HTTP server also serves out the "LocalAPI" on /localapi. +// As the LocalAPI is powerful, access to endpoints requires BOTH passing a +// "Sec-Tailscale: localapi" HTTP header and passing local_api_cred as +// the basic auth password. +// +// The pointers proxy_cred_out and local_api_cred_out must be non-NIL +// and point to arrays that can hold 33 bytes. The first 32 bytes are +// the credential and the final byte is a NUL terminator. +// +// If tailscale_loopback returns, then addr_our, proxy_cred_out, +// and local_api_cred_out are all NUL-terminated. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_loopback(tailscale sd, char* addr_out, size_t addrlen, char* proxy_cred_out, char* local_api_cred_out); + +// tailscale_enable_funnel_to_localhost_plaintext_http1 configures sd to have +// Tailscale Funnel enabled, routing requests from the public web +// (without any authentication) down to this Tailscale node, requesting new +// LetsEncrypt TLS certs as needed, terminating TLS, and proxying all incoming +// HTTPS requests to http://127.0.0.1:localhostPort without TLS. +// +// There should be a plaintext HTTP/1 server listening on 127.0.0.1:localhostPort +// or tsnet will serve HTTP 502 errors. +// +// Expect junk traffic from the internet from bots watching the public CT logs. +// +// Returns: +// 0 - success +// -1 - other error, details printed to the tsnet logger +extern int tailscale_enable_funnel_to_localhost_plaintext_http1(tailscale sd, int localhostPort); + +// tailscale_errmsg writes the details of the last error to buf. +// +// After returning, buf is always NUL-terminated. +// +// Returns: +// 0 - success +// EBADF - sd is not a valid tailscale +// ERANGE - insufficient storage for buf +extern int tailscale_errmsg(tailscale sd, char* buf, size_t buflen); + + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/project.yml b/project.yml index edb9e41f..b4bd8472 100644 --- a/project.yml +++ b/project.yml @@ -131,6 +131,12 @@ targets: PRODUCT_NAME: Multiplex MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "202607181" + "SWIFT_INCLUDE_PATHS[sdk=iphoneos*]": "$(SRCROOT)/Vendor/libtailscale/include" + "SWIFT_INCLUDE_PATHS[sdk=iphonesimulator*]": "$(SRCROOT)/Vendor/libtailscale/include" + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": "$(SRCROOT)/Vendor/libtailscale/lib/ios-arm64" + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": "$(SRCROOT)/Vendor/libtailscale/lib/ios-simulator" + "OTHER_LDFLAGS[sdk=iphoneos*]": "$(inherited) -ltailscale" + "OTHER_LDFLAGS[sdk=iphonesimulator*]": "$(inherited) -ltailscale" # 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.