Skip to content

Experimental: Connect via Tailscale host option (iPhone/iPad) - #12

Draft
jhen0409 wants to merge 3 commits into
mainfrom
tailscale-host-option
Draft

Experimental: Connect via Tailscale host option (iPhone/iPad)#12
jhen0409 wants to merge 3 commits into
mainfrom
tailscale-host-option

Conversation

@jhen0409

Copy link
Copy Markdown
Member

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 commit 5e89501, 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)

  • iPhone/iPad only. Go's toolchain has no visionOS target at all, so the visionOS build compiles the feature out (#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.
  • Mutually exclusive with mosh. libtailscale relays every dialed connection (UDP included) through a SOCK_STREAM socketpair, 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 at MoshBootstrap.start for records synced from other devices.
  • C ABI directly, not TailscaleKit. The upstream Swift layer's outbound connection is write-only (cannot read bytes) and floors at iOS 18.1; the C archive floors at iOS 12. We bind CLibTailscale straight.
  • Auth-key login only (reusable key recommended — each device becomes its own tailnet node). Key stored via the synchronizable KeychainStore under a fixed namespace UUID, entered through the existing masked-secret field pattern (never isSecureTextEntry). Optional Headscale control URL (device-local UserDefaults).
  • Citadel/swift-nio-ssh freeze untouched: the tailnet fd is adopted with ClientBootstrap.withConnectedSocket(_:) and handed to Citadel 0.12.0's existing public SSHClient.connect(on:settings:) — the settings-based overload, which (unlike the authenticationMethod: channel overload) actually awaits authentication and wires its inbound handler correctly. Everything downstream — tmux probes, SFTP file drops, the PTY — rides the same SSHClient unchanged.

Architecture

  • Host.useTailscale (decode-defaults false, participates in connection identity, rides the synced record)
  • TailscaleTunnel actor: one node per app install; every blocking C call on a serial queue; 30 s startup deadline enforced via tailscale_close on a separate cancellation queue (close must not queue behind a stuck tailscale_up); state stream feeds the Settings section
  • Vendor/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 commit 5e89501 and verifies exported symbols.

Verification

  • visionOS build green (feature compiled out), iPad build green (archive links cleanly), 487/487 unit tests pass (new: dial-address formatting incl. IPv6 brackets/MagicDNS, node-hostname sanitization, decode defaults, connection-identity participation, mosh-guard, visionOS error copy)
  • E2E on the iPad simulator: seeded harness host connects over SSH, attaches tmux, and streams live bytes through SwiftTerm (verified via simctl launch --console-pty — DECSET traffic from the attach visible in the app console)
  • Known environment issue, not a regression: on the iOS 27 beta sim runtime (24A5390f), launching Multiplex — any build — triggers a backboardd Metal-texture-validation abort that SIGTERMs foreground apps ~10 s in. A/B-verified against an unmodified main build (identical crash) and against stock apps (clean). The verify screenshot 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.

jhen0409 added 2 commits July 23, 2026 17:37
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.
@jhen0409

Copy link
Copy Markdown
Member Author

Pushed b034f8d fixing both failures from the first on-device run:

1. NIOCore/ChannelPipeline.swift:1208: Precondition failed on every tailscale connect — Citadel's connect(on:settings:) adds its handlers via syncOperations, legal only on the channel's event loop (Citadel itself only runs it inside its bootstrap's channelInitializer). The fd-adoption seam called it from a Swift-concurrency thread. Replaced with TailscaleLoopbackRelay: the dialed tailscale fd splices to a one-shot 127.0.0.1:<ephemeral> listener (closed immediately after the single accept — iOS loopback is cross-app) and Citadel dials it through its completely stock connect(host:port:) path. The iOS 18-only task-executor alternative is out of reach of the iOS 17 floor. The investigation record's §9 correction is corrected accordingly.

2. Authkey is set; but state is NoState. Ignoring authkey. — tsnet only consumes an auth key inside its StartLoginInteractive branch (tsnet.go:763, v1.94.1), which a fresh state store never reaches; the documented escape is the TSNET_FORCE_LOGIN env knob, and the Go runtime freezes its environment snapshot at archive load — before any Swift runs. TailscaleForceLogin.c sets the knob from a C constructor (app objects link ahead of the archive, so it wins the race), gated on the state file's absence so an enrolled node never force-re-logins.

Also: tsnet logs now flow to the unified log (category tailscale, debug — use log stream, not log show), and three DEBUG hooks (MULTIPLEX_TAILSCALE_FAKE_DIAL, MULTIPLEX_SEED_TAILSCALE, MULTIPLEX_TAILSCALE_AUTHKEY) make the seam drivable headlessly.

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: StartLoginInteractiveAs fires, "Ignoring authkey" absent.

Ready for an on-device retest with a real auth key — tsnet's own log lines are now visible in Console under category tailscale if anything else surfaces.

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.
@jhen0409

Copy link
Copy Markdown
Member Author

9a7e4cd — two additions:

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. Vendor/libtailscale/lib/ios-simulator/ now holds a lipo'd fat archive and Tools/build-libtailscale.sh builds/verifies both slices. Anyone with the old lib/ios-arm64-simulator/ layout should re-run the script once.

491/491 tests, visionOS + iPad (Debug and Release-sim linking) green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant