Connect via Tailscale over tailscale-rs (all three platforms, incl. visionOS) - #13
Connect via Tailscale over tailscale-rs (all three platforms, incl. visionOS)#13jhen0409 wants to merge 3 commits into
Conversation
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.
|
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.
|
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: ``` 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. |
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-visionostarget, 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
MULTIPLEX_TAILSCALE_FAKE_DIAL(the relay + Citadel path is identical whether the remote is a tailnet handle or a harness socket).Backend deltas from #12 (all from the rs C ABI,
CTailscaleRS)SecRandomand 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 ats_initparameter).ts_tcp_send/recv, so the one-shot loopback relay's remote side is now aTailscaleRelayRemoteprotocol (handle-backed for real dials, fd-backed for fake-dial). Citadel still dials127.0.0.1through its ordinary bootstrap — theconnect(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 ridesrelay.close().ts_inithangs on a rejected key, so init races a 30 s deadline (mandatory, not defensive).TS_RS_EXPERIMENTis set before init.ts_peer_ipv4_addr→ dial by IP; literals dial directly (TailscaleRSDialAddress, pure + tested).Scope / labelling
ts_udp_*), so mosh-over-tailnet is buildable later — this is the headline thing Go could never do — but it's deferred.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.shrebuilds them at pinned commit31b0079+ one upstreamts_netmonpatch. 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.