Skip to content

Connect via Tailscale over tailscale-rs (all three platforms, incl. visionOS) - #13

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

Connect via Tailscale over tailscale-rs (all three platforms, incl. visionOS)#13
jhen0409 wants to merge 3 commits into
mainfrom
tailscale-rs-host-option

Conversation

@jhen0409

Copy link
Copy Markdown
Member

What

A second, parallel Connect via Tailscale host option — SSH through an embedded userspace tailnet node — backed by tailscale-rs (Rust) instead of the Go libtailscale used in #12. The point: Rust has an aarch64-apple-visionos target, so this works on Vision Pro — the platform the Go path can structurally never reach. No system VPN, no NetworkExtension.

This is the experimental route the investigation flagged as the realistic future for visionOS. Full record + build matrix: local-plan/tailscale-rs-investigation.md (gitignored).

Verified

  • visionOS builds and links the archive (the acceptance test the Go path fails), iPad builds (universal sim slice), 492/492 unit tests.
  • End-to-end on an iOS 26.4 simulator: a tailscale-flagged host attached tmux through the loopback relay — the socket table shows Citadel → relay-pair → the dialed connection, and live tmux content rendered on screen. Proven via MULTIPLEX_TAILSCALE_FAKE_DIAL (the relay + Citadel path is identical whether the remote is a tailnet handle or a harness socket).
  • Investigation separately proved the real C ABI reaches Tailscale's control plane and gets an auth verdict.

Backend deltas from #12 (all from the rs C ABI, CTailscaleRS)

  • App-owned node identity → no plaintext state dir. tailscale-rs takes the three 32-byte node keys as input and never exports them, so the app generates them with SecRandom and persists the 96 bytes in the Keychain. The node keeps its identity across launches with no state directory — satisfying the keychain-only house rule the Go path had to break. The Go force-login constructor hack is gone (the auth key is a ts_init parameter).
  • Handle, not fd. A tailnet connection is an opaque handle with blocking ts_tcp_send/recv, so the one-shot loopback relay's remote side is now a TailscaleRelayRemote protocol (handle-backed for real dials, fd-backed for fake-dial). Citadel still dials 127.0.0.1 through its ordinary bootstrap — the connect(on:) inEventLoop constraint that forced the relay in Experimental: Connect via Tailscale host option (iPhone/iPad) #12 is unchanged. Half-close forwards through the seam; the handle has no half-close, so full teardown rides relay.close().
  • ts_init hangs on a rejected key, so init races a 30 s deadline (mandatory, not defensive). TS_RS_EXPERIMENT is set before init.
  • Peer resolution: no MagicDNS, so a hostname resolves via ts_peer_ipv4_addr → dial by IP; literals dial directly (TailscaleRSDialAddress, pure + tested).

Scope / labelling

  • All three platforms — no visionOS-disabled apology; the UI carries experimental · relayed copy and the missing-auth-key tip.
  • Mosh mutually exclusive for v1. The rs ABI has datagram UDP (ts_udp_*), so mosh-over-tailnet is buildable later — this is the headline thing Go could never do — but it's deferred.
  • Auth-key-only login, free-tier plumbing, nothing in the widget projection.

Shipping caveats (why this stays experimental)

Upstream tailscale-rs labels itself "unstable and insecure" (unaudited crypto — SSH payloads stay independently encrypted regardless), no pre-1.0 API stability, and all peer traffic relays through public DERP servers today (NAT traversal "coming soon"). The two visionOS slices need nightly Rust + -Zbuild-std (the target is still tier 3). The four static archives (~17-22 MB each) are git-ignored; Tools/build-tailscale-rs.sh rebuilds them at pinned commit 31b0079 + one upstream ts_netmon patch. Re-evaluate gates (audit, direct connections, visionOS tier-2) are in the investigation record §7.

Relationship to #12

Independent branch from main. #12 (Go) and this (Rust) are two backends for the same per-host option; if this proves out on device across all three platforms, it supersedes #12. Not intended to merge both.

jhen0409 added 2 commits July 23, 2026 20:37
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.
Real tailnet dials failed with "unaddressable destination" and a
remote_endpoint of <peer-ip>: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.
@jhen0409

Copy link
Copy Markdown
Member Author

Fixed the `unaddressable destination` / `:0` dial failure (`e0a9682`).

Root cause is an upstream tailscale-rs bug, not the app: `ts_sockaddr_set_port` is a no-op. It does `unsafe { addr.sa_data.sockaddr_in }.sin_port = port` — that copies the union field out by value, sets the port on the throwaway copy, and discards it (net_types.rs:361/365, both the v4 and v6 arms). `ts_parse_ip` fills the address with port 0, so with the setter doing nothing, every dial went out as `:0` and smoltcp rejected it.

The app now writes `sin_port`/`sin6_port` directly onto the sockaddr after parsing — host byte order, keyed off the ffi's own `TS_AF_INET`/`TS_AF_INET6` values (2/23), matching the pattern the C examples use (`tcp_echo.c` sets `.sin_port = 1234` and prints it with `%u`). The broken ffi setter is bypassed entirely.

Worth reporting upstream (their own examples avoid the setter, which is probably why it slipped through). All three platforms still build; the DEBUG fake-dial path uses a plain kernel socket so it never exercised this — needs a real tailnet dial to confirm, which is your retest.

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

Copy link
Copy Markdown
Member Author

Progress on the real-tailnet dial (`e590a39`).

The port fix worked — your second log shows `remote_endpoint: 100.77.122.118:22` (was `:0`). The new failure is a DERP-underlay readiness race, not addressing:

```
route_updater: no region stored in multiderp, no underlay route
tcp: connecting socket was reset or closed → connection reset
```

The peer's DERP region is in the netmap, but the local node hadn't registered a transport for that region yet. tailscale-rs brings up its DERP underlay (the only data path today — no direct connections) asynchronously after the netmap, and its C ABI has no "underlay ready" signal — `ts_ipv4_addr` unblocks at the netmap, which is earlier. So a dial fired right after node startup races DERP setup. The node starts lazily on the first dial, so the first attach races it hardest.

Mitigation: `connectWithRetry` retries the dial with backoff (8 × 900 ms ≈ 7 s) so the window self-heals once the region's Uniderp connects and reports its transport. Each attempt logs to the `tailscale` category (stderr / Console).

Please retest and watch Console: if a later attempt lands (attach succeeds within a few seconds), the race is covered. If it retries all 8 and still fails, DERP genuinely isn't establishing on your setup — that's the experimental backend's DERP-only + no-Apple-netmon limitation (investigation §1/§6), an upstream wall to raise with Tailscale rather than something I can fix app-side. The `RUST_LOG` lines (whether Uniderp connects to a DERP region at all) will tell us which.

@jhen0409
jhen0409 marked this pull request as draft July 23, 2026 13:26
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