Experimental: Connect via Tailscale host option (iPhone/iPad) - #12
Experimental: Connect via Tailscale host option (iPhone/iPad)#12jhen0409 wants to merge 3 commits into
Conversation
A per-host flag routes the SSH connection through an embedded userspace Tailscale (tsnet) node — no system VPN, no NetworkExtension. The node is one per app install (TailscaleTunnel actor over the vendored CLibTailscale C ABI, auth-key login, blocking calls on a dedicated queue with a close-based startup timeout); the dialed fd is adopted into SwiftNIO and handed to Citadel's public connect(on:settings:) overload, so the pinned Citadel/swift-nio-ssh freeze is untouched and exec probes, SFTP drops, and the PTY all ride the tunneled client unchanged. Go cannot target visionOS, so the feature compiles out there (canImport(CLibTailscale) + SDK-conditional linking) and a flagged host fails with a specific error before credential validation. mosh is mutually exclusive with the tunnel — libtailscale's fd relay is stream-shaped and destroys UDP datagram boundaries — enforced in the host form both directions and guarded again in MoshBootstrap for synced records. The static archives (~27 MB per slice) stay out of git; Tools/build-libtailscale.sh rebuilds them at the pinned commit (5e89501) and verifies the exported symbols.
Two field failures from the first on-device run: The NIO precondition crash (ChannelPipeline.swift:1208): Citadel's connect(on:settings:) mutates the pipeline through syncOperations, which must run on the channel's event loop — Citadel only ever does so inside its own channelInitializer. The fd-adoption seam called it from a Swift concurrency thread, asserting on every tailscale connect. Replaced with TailscaleLoopbackRelay: a one-shot localhost listener (immediate close after the single accept — iOS loopback is cross-app) splicing the tailscale fd to an ordinary SSHClient.connect(host:port:) dial, so Citadel's whole path stays stock. Pinning the Task to the loop instead would need iOS 18, above the floor. The ignored auth key: tsnet only consumes an auth key in its StartLoginInteractive branch, which a fresh state store (NoState at the check) never reaches — the documented escape is the TSNET_FORCE_LOGIN env knob, and the Go runtime freezes its environment copy at archive load, before any Swift runs. TailscaleForceLogin.c sets the knob from a C constructor (app objects link ahead of the archive) gated on the state file's absence, so enrolled nodes never re-login. tsnet logs now flow to Logger (category tailscale) through a pipe sink — they were the diagnostic for both failures. DEBUG hooks MULTIPLEX_TAILSCALE_FAKE_DIAL / MULTIPLEX_SEED_TAILSCALE / MULTIPLEX_TAILSCALE_AUTHKEY drive the seam headlessly; verified end to end against the harness (tmux attach + round-trip through the relay) and the force-login branch observed via log stream on a fresh install.
|
Pushed 1. 2. Also: tsnet logs now flow to the unified log (category Verification: 491/491 unit tests (4 new relay tests: round-trip both directions, EOF propagation, one-shot refusal, close-before-accept); visionOS + iPad builds green; end-to-end on an iOS 26.4 simulator — seeded tailscale-flagged host attached tmux through the relay (socket table shows the fake-dial fd plus the loopback pair per connection; typed bytes round-tripped and rendered); force-login verified on a fresh install: Ready for an on-device retest with a real auth key — tsnet's own log lines are now visible in Console under category |
The Transport section now shows a caution note under an enabled Tailscale toggle while no auth key is stored (nil-gated so it never flashes during the keychain read; re-checked on every toggle flip — the sheet and Settings are never open together). The vendored simulator archive becomes universal (arm64 + x86_64): a Release simulator build links both slices (ONLY_ACTIVE_ARCH=NO) and the arm64-only archive failed at link the first time the regenerated scheme picked Release. lib/ios-arm64-simulator/ is now lib/ios-simulator/; Tools/build-libtailscale.sh builds both slices, lipos them, and verifies the fat output.
|
Missing-auth-key tip in Host Settings: with the Tailscale toggle on and no auth key stored, the Transport section now shows a caution note ("No Tailscale auth key is set yet — add a reusable one in Settings › Tailscale, or this host can't connect."). It's gated on the actual keychain read (nil until loaded, so it never flashes) and re-checks on every toggle flip. The connect-failure card already carries the same guidance when a key-less host is attached — that path is screenshot-verified. Universal simulator archive: the first Release-configuration simulator build (ONLY_ACTIVE_ARCH=NO → arm64 + x86_64) exposed that the vendored sim archive was arm64-only and failed at link. 491/491 tests, visionOS + iPad (Debug and Release-sim linking) green. |
What
A per-host Connect via Tailscale option: SSH runs through an embedded userspace Tailscale (tsnet) node inside the app — no system VPN app, no VPN slot, no NetworkExtension. Deliberately scoped as an experimental spike; the full investigation record and implementation brief live in
local-plan/(untracked): libtailscale studied at pinned commit5e89501, verdict "feasible on iPhone/iPad, blocked as a product by visionOS" — this branch is the try-it-anyway, scoped to sidestep each hard blocker.Scope decisions (from the investigation)
#if canImport(CLibTailscale)+ SDK-conditional linking; the xros SDK never sees the module). A tailscale-flagged host opened on Vision Pro fails with a specific error before credential validation — synced records stay honest.SOCK_STREAMsocketpair, destroying datagram boundaries; mosh packets are individually OCB3-sealed, so this is unfixable app-side. Enforced in the host form (both toggle directions, plus normalization on load/save) and guarded again atMoshBootstrap.startfor records synced from other devices.CLibTailscalestraight.KeychainStoreunder a fixed namespace UUID, entered through the existing masked-secret field pattern (neverisSecureTextEntry). Optional Headscale control URL (device-local UserDefaults).ClientBootstrap.withConnectedSocket(_:)and handed to Citadel 0.12.0's existing publicSSHClient.connect(on:settings:)— the settings-based overload, which (unlike theauthenticationMethod:channel overload) actually awaits authentication and wires its inbound handler correctly. Everything downstream — tmux probes, SFTP file drops, the PTY — rides the sameSSHClientunchanged.Architecture
Host.useTailscale(decode-defaults false, participates in connection identity, rides the synced record)TailscaleTunnelactor: one node per app install; every blocking C call on a serial queue; 30 s startup deadline enforced viatailscale_closeon a separate cancellation queue (close must not queue behind a stucktailscale_up); state stream feeds the Settings sectionVendor/libtailscale/: header + modulemap tracked; the ~27 MB static archives are git-ignored — run./Tools/build-libtailscale.sh(needs Go + network) before building for iOS. The script pins commit5e89501and verifies exported symbols.Verification
simctl launch --console-pty— DECSET traffic from the attach visible in the app console)mainbuild (identical crash) and against stock apps (clean). Theverifyscreenshot loop is unreliable on that runtime until Apple fixes it; the console-pty path is the workaround.Not verified (needs a real tailnet)
An actual tailnet dial needs a reusable auth key in Settings → Tailscale on an iPhone/iPad. Everything short of that is exercised; the no-auth-key path surfaces "Add a Tailscale auth key in Settings." Memory footprint, double-tunnel coexistence with the official VPN app, and foreground-reconnect latency are recorded as open questions in the investigation record.
Shipping caveats (why this stays experimental)
Upstream libtailscale has 0 tags/releases, no SwiftPM, a single Swift author, and has been dormant since 2026-02; Tailscale's visible embedding investment moved to tailscale-rs (Rust). Binary grows ~20-25 MB per slice. The tsnet state dir keeps node keys as files (iOS Data Protection applies) — a documented exception to the keychain-only house rule. Re-open gates are listed in the investigation record §10.