# tsnet Parity Roadmap

> **Goal:** a complete pure-Rust port of Go `tsnet`. This document is the durable plan; live
> status is tracked in beads (`bd list`, prefix `tsr`).

## Recently closed (v0.6.4–v0.6.5)

The consumer-facing tsnet gaps the embedders actually hit are now closed and live-verified against
`controlplane.tailscale.com`:

- **Runtime exit-node switch** — `Device::set_exit_node` (Go `EditPrefs(ExitNodeID/IP)`). v0.6.4.
- **Logout / deregister** — `Device::logout` (Go `LocalClient.Logout`): re-register with a past
  expiry. v0.6.5.
- **Typed registration outcome** — `Device::wait_until_running(timeout) -> Result<(), RegistrationError>`
  + `Device::watch_state()` (`DeviceState` stream). Replaces poll-`ipv4_addr` loops; distinguishes
  permanent (`AuthRejected`/`KeyExpired`) from recoverable (`NeedsLogin` — keeps retrying) and
  transient (`NetworkUnreachable`). v0.6.5.
- **Active exit node** — `Device::active_exit_node()` / `Status.active_exit_node` (Go
  `Status.ExitNodeStatus.ID`): the resolved, fail-closed engaged exit, not the configured selector.
  v0.6.5.
- **WhoIs user + capabilities** — `WhoIs.capabilities` from the node `CapMap`; `WhoIs.user` from the
  netmap `UserProfiles` join. v0.6.5. *(Per-node `online`/`last_seen` still not retained.)*
- **`russh` 0.61.2 security bump** (GHSA-wwx6-x28x-8259), ssh-feature-only; ring-only invariant
  preserved. v0.6.5.

Recently landed (tracked): **live Tailnet-Lock (TKA) enforcement** — per-peer key-signature
verification is now **active and fail-closed** at the peer-trust chokepoint, matching Go's
`tkaFilterNetmapLocked`. Once a verified `Authority` has been synced from control (over the internal
watch channel, only after `VerifiedAumChain::verify`), peers with a missing or unauthorized signature
are dropped; with no lock synced every peer is admitted, and a disabled lock clears enforcement. The
cross-peer rotation filter (Go's `rotationTracker`) also drops peers presenting a rotated-away key.
The remaining TKA parity items are narrower: disablement-secret verification, the `UnsignedPeerAPIOnly`
under-enforcement gap, and surfacing a self-locked-out health warning — see the deferred list below
and issue #7.

## Where we are (v0.8.1 — near-complete tsnet parity)

> Per-lane percentages below were last measured at v0.5.39; the numbered roadmap was reconciled
> against the tree at v0.8.1 (see "Roadmap" — 16/21 shipped, 1 genuinely open). Treat the lane
> percentages as a floor, not a ceiling.

Crucially, the fork is **more mature than its own stale README suggests**:

- **Direct-path P2P is real** — disco Ping/Pong/CallMeMaybe, RTT-based best-addr selection, trust
  windows, DERP-as-fallback. It is **not** DERP-relay-only. IPv6 direct paths negotiate when
  `enable_ipv6` is set (v0.5.37).
- **No `panic=abort`** — kameo actors isolate per-flow panics; crash isolation is intact.
- **Anti-leak is type-encoded** — `DirectDialer` structurally refuses exit egress; egress
  fail-closes when no `/0` route matches; no host-socket fallback exists in `Device::tcp_connect`.
- **musl static + ring-only** — CI musl lane + `aws-lc-rs`-absence guard; aws-lc confined to the
  optional `ssh` feature.

Per-lane parity estimate (updated v0.5.39): A (Status/WhoIs) ~85%, B (MagicDNS) ~85% (full netstack
`100.100.100.100:53` server; host-resolver redirect only missing in TUN mode), C (forwarding) ~85%,
D (Ping/direct) ~90% (IPv6 direct paths land; only symmetric-NAT spray skipped),
E (TLS/Serve/ACME) ~80% (client-side ACME DNS-01 issuance shipped behind `acme`; `listen_tls` issues
real certs against a `set-dns`-capable control plane; only the stored Serve-state runtime + the
external Funnel relay leg remain), FFI/Python/Elixir bindings ~90% (full Device surface propagated).

### Shipped since v0.4.0 (the parity push)
Taildrop send/recv, CapturePcap, TKA Ed25519 fix, node-key rotation, WIF/OAuth bootstrap, loopback
SOCKS5 (v0.5.27–v0.5.32); a multi-reviewer hardening pass (v0.5.33); then the parity sweep:
**FFI/Python/Elixir lane propagation** (v0.5.34), **turnkey `listen_ssh` login-shell** (v0.5.35),
**disco/STUN observability counters** (v0.5.36), **IPv6 local disco candidates** (v0.5.37),
**client-side ACME (RFC 8555 DNS-01) + `set-dns` RPC** (v0.5.38), **`listen_tls`→ACME wiring**
(v0.5.39). Tier 1 (direct-path glue, disco↔node-key binding, musl lane) and Tier 2 (tags, ephemeral,
upstream-proxy dialer, netmap resumption) were verified already-complete in-tree.

Most recent wave:
- **Serve `Path` / `Redirect` handlers** — HTTP path-prefix mux and HTTP 3xx redirect targets added
  to `ServeTarget`, validated and dispatched on the TLS-terminated stream, fail-closed (unmatched
  path → 404, backend dial failure → drop). Hand-rolled HTTP head parsing; no axum/hyper added.
- **Recursive MagicDNS in TUN mode** — the TUN-mode `100.100.100.100:53` resolver now forwards
  non-tailnet names recursively (previously inert in TUN mode), reusing the forwarder netstack's
  overlay-backed `Channel` and the same `decide`/`recursive_plan` path as netstack mode, so the
  IPv4-only egress filter and fail-closed NXDOMAIN default are inherited.
- **Tailnet Lock peer-key enforcement (active)** — per-peer node-key signature verification is
  threaded through the domain `Node` and **enforcing** at the `ts_runtime` peer-trust chokepoint,
  matching Go's `tkaFilterNetmapLocked`. A verified `ts_tka::Authority` is delivered from the control
  runner over an internal watch channel — only after `VerifiedAumChain::verify` — and the chokepoint
  then fails closed on missing/unauthorized signatures (self is structurally never filtered). With no
  lock synced, behavior is unchanged (admit-all); a disabled lock clears enforcement. A whole-netmap
  rotation filter (Go's `rotationTracker`) additionally drops a peer presenting a node key that a
  newer rotation chain has superseded (clone/replay defense). The narrower remaining items
  (disablement-secret verification, the `UnsignedPeerAPIOnly` under-enforcement gap, self-locked-out
  health warning) are in the deferred list below and [SECURITY.md](../SECURITY.md).

### Deferred (in-scope eventually, not blocked externally — with reasons)
- **TKA disablement-secret verification** — the cryptographic proof that a given lock *disable* is
  authorized is not yet checked, so a disable is currently taken at face value. Control is already
  trusted for the enable/disable toggle (it cannot forge-admit a peer either way — admission still
  requires a key in the verified chain), so this is a hardening item, not an open admit-all hole (see
  [SECURITY.md](../SECURITY.md)).
- **Self-locked-out health warning** — Go surfaces a health warning when the node's own key is not
  authorized under an active lock. Self is structurally never filtered here (the self node never
  enters the peer db), so there is no lockout risk, but the advisory warning is not yet surfaced.
- **`ts_tka` CTAP2-CBOR cross-validation against Go test vectors** — byte-for-byte wire
  compatibility is asserted by construction, not proven; a *successful* TKA verification should be
  treated as advisory until vectors land.
- **`UnsignedPeerAPIOnly`** — the peerAPI-only network-access carve-out for unsigned peers under
  tailnet lock; Go admits such peers unsigned, whereas an active lock here rejects unsigned peers
  outright (*more* restrictive — a connectivity gap in the safe direction, surfacing only if the node
  model ever ingests that field).
- **DERP mesh / private DERP server** — running our own DERP relay mesh (consuming public relays as
  a fallback path is implemented).
- **TKA signing** — initiating/mutating tailnet-lock state (only client-side *verification* is in
  scope here).
- **UPnP / PCP / NAT-PMP portmapper** — gateway port-mapping for better direct connectivity.
- **App connector** — `4via6`-style application connectors.
- **`4via6`** subnet-route encoding.
- **Serve get/set_serve_config + accept-loop runtime** — the handler *types* ship (`Path`/`Redirect`/
  `Proxy`/`Text`/`TcpForward`); the stored serve-state runtime and the Accept-handback loop remain,
  pending a serve-state design decision.
- **Service advertise-to-control** — consume-side done; advertise-side is low value (ACL-preassigned
  VIPs work) and needs a wire-field decision.
- **Symmetric-NAT birthday-paradox port spray** — deliberately skipped (low value for the
  DERP-acceptable userspace-netstack deployment; the single-port guess already covers easy cases).
- **Netstack sharding** (`tsr-4pp`) — benchmark-gated; needs a real exit-egress measurement
  first.
- `Sys()` internals — satisfied via typed accessors (`self_node`/`status`/`watch_netmap`/`whois`).

### Blocked by external dependency
These cannot be built against a self-hosted control plane and depend on Tailscale-operated infra or
out-of-band setup:
- **Funnel public ingress relay** — depends on the Tailscale-operated public relay leg; un-buildable
  against a self-hosted control plane. `listen_funnel` correctly fail-closed.
- **ACME on a self-hosted control plane** — a self-hosted control plane may return `501` for the
  ACME-over-control cert RPC; client-side ACME (DNS-01) is implemented but the control-plane leg is
  not available there.
- **OIDC / SSO** — identity-provider integration is a control-plane/deployment concern.
- **Network flow logs** — depends on the control-plane log-collection pipeline.
- **Taildrop relay** — the relayed (non-direct) Taildrop path depends on Tailscale infra (direct
  send/recv is implemented).
- **Node sharing** — cross-tailnet sharing is a control-plane feature.

## Upstream tracking

Upstream [`tailscale/tailscale-rs`](https://github.com/tailscale/tailscale-rs) is now active. Going
forward this fork tracks upstream and aims to upstream or re-base fork-specific work where it makes
sense, while keeping the anti-leak/egress posture (see `AGENTS.md`).

## Consumers and the seams they need

- **Userspace egress client** — holds a `Device` handle and obtains per-flow `AsyncRead+AsyncWrite`
  streams from `Device::tcp_connect`, gated by `Config::exit_node`. This **is** the dialer; do not
  reach for `ts_forwarder::RealDialer` (that is the *inbound* exit-node-server chokepoint, the wrong
  direction). Fail-closed composes because there is no host-socket fallback in the egress path.
- **Per-pod userspace client** — pure userspace netstack (no TUN/root), ephemeral auth-key join to
  the control plane, exit-node selection, graceful teardown. Most of this exists; gaps are tags,
  ephemeral config, and the upstream residential-proxy hop.

## Roadmap (ranked by leverage)

> **Status reconciled at v0.8.1** (2026-06-10) against the actual tree. The original Tier-1..5
> numbered list (authored ~v0.5.39) is **badly stale**: of its 21 items, **16 shipped, 4 are partial
> by design / external-blocked, and only 1 is genuinely unbuilt**. The closed items are preserved
> below (struck, with the proof) so the history is legible; the live work is the short
> "**Actually remaining**" list. Don't re-investigate a struck item as if open — that mis-step is
> exactly what the closed `~~item~~` lines exist to prevent (it cost a misdiagnosis on #24).

### Actually remaining (the real backlog)

- **Netstack sharding** (was Tier-3 #9; bead `tsr-4pp`) — the one fully-unbuilt numbered item. One
  shared smoltcp poll loop serves all flows; shard per ~50–100 sessions. smoltcp has no SACK/auto-tune,
  so **benchmark over a real exit before committing the dataplane** (deliberately gated, not neglected).
- **Tailnet-Lock (TKA) enforcement hardening** (part of old #20; issue #7) — core enforcement now
  **ships active and fail-closed**, matching Go's `tkaFilterNetmapLocked`: a verified `Authority`
  reaches the `peer_tracker` over an internal watch channel (only after `VerifiedAumChain::verify`)
  and drops peers with a missing/unauthorized signature, and a whole-netmap rotation filter (Go's
  `rotationTracker`) drops peers presenting a rotated-away key. What remains is narrower:
  disablement-secret verification, the `UnsignedPeerAPIOnly` carve-out, and a self-locked-out health
  warning (see the deferred list and "Invariants").
- **Own/private DERP server** (part of old #20) — `ts_derp` is client + mesh-frame types only (no
  server accept loop). Consuming public relays as fallback already works; running our own mesh is the
  open piece. Low priority for the egress use case.
- **DERP connectivity floor for no-region peers** — ✅ **shipped v0.8.1** (#24): a peer with no netmap
  home region was denied any underlay route; now the relay region is inferred (observed-route à la Go
  `c.derpRoute`, then home-region last resort). Listed here only as the most recent close.

A handful of **partial-by-design** items are NOT backlog (they are as-complete-as-they-can-be for our
deployment): client-side ACME is shipped but a self-hosted control plane 501s on `set-dns` (external,
old #10); TUN mode is shipped but userspace-only seams (`tcp_connect`/loopback) are `UnsupportedInTunMode`
by design (old #14); `listen_funnel` client leg is shipped but the public relay leg is Tailscale-operated
and external-blocked (old #16). These are environment limits, not engine work.

### Closed (shipped — verified in-tree at v0.8.1)

Tier 1 — ~~direct-path orchestration glue~~ (`direct.rs` `run_pinger`/`run_stun_prober`/`run_call_me_maybe`),
~~disco↔node-key binding~~ (`sock.rs` `BindingVerifier`, fail-closed; the `TODO(parity)` is gone),
~~musl static build + CI lane~~ (`ci.yml` `musl_static`, aws-lc-absence guard).
Tier 2 — ~~`config.tags`→`HostInfo.request_tags`~~ (`client.rs:298`, `register.rs`),
~~config-driven `ephemeral`~~ (`Config::ephemeral`), ~~upstream proxy dialer for the exit hop~~
(`ProxyExitDialer`, `ExitProxyConfig`), ~~netmap stream resumption~~ (`MapSession`/`advance_session`).
Tier 3 — ~~`tcp_buffer_size` knob~~ (default already 256 KiB; per-deployment `Config::tcp_buffer_size`).
Tier 4 — ~~Serve config + `RegisterFallbackTCPHandler`~~ (`ts_runtime/src/serve.rs` `ServeManager`),
~~bindings lane propagation~~ (FFI/Python/Elixir at ~90%, not the stale "~30%").
Tier 5 — ~~IPv6 on the tailnet (flag-gated)~~ (`Config::enable_ipv6`), ~~full MagicDNS server~~
(`magic_dns.rs`, `100.100.100.100:53` + exit-node DoH), ~~`ListenSSH`~~ (`src/ssh/`, feature-gated),
~~`ListenService`/VIP + ServiceMode~~ (consume + advertise), ~~symmetric-NAT traversal~~ (Go has no
256-port spray; the hard-NAT `Stun4LocalPort` guess **is** implemented — at parity), ~~Taildrop~~,
~~key rotation~~, ~~metrics/observability~~, ~~WIF fields + `Sys()` accessors~~.

> Reclassified, not "done by us": old #10 (ACME) — real Tailscale has no `cert/<domain>` RPC; the node
> is the ACME client, which is shipped. Old #19 (birthday-paradox spray) — a real spray was rejected as
> a Go divergence + SSRF risk; matching Go's actual `Stun4LocalPort` tactic is the parity bar and is met.

## Cross-cutting doc-hygiene
- ~~Reconcile README DERP-vs-direct~~ — **done.** The README now states direct NAT traversal is
  real and DERP is the fallback used only when no direct path is available.
- ~~Clarify `fallback_resolvers`~~ — **done.** The README states fail-closed NXDOMAIN is the
  *default* and that configuring a resolver opts in to forwarding query names upstream.
- Security posture is now documented in full in [SECURITY.md](../SECURITY.md) (unaudited crypto, the
  active TKA enforcement and its remaining gaps, peerAPI capability gap, at-rest key handling,
  anti-leak posture).

## Invariants that must never regress
- Real origin IP must never leak; no silent direct-dial fallback (fail-closed is sacred).
- IPv4-only on the tailnet by default (bind `0.0.0.0`, never `::`); any IPv6 work is opt-in and must
  not weaken the deployment posture.
- Stay on `ring` for the tailnet/TLS path; confine `aws-lc-rs` to the optional `ssh` feature.
- Keep `panic=unwind` (actor model isolates per-flow panics; `panic=abort` would weaken isolation).
- Unaudited crypto is acceptable *only* because the deployer owns both ends (their control plane +
  their exits) and pins the capability version.
