Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Record breaking or significant changes here. All dates are UTC.

* **portmapper:** add `ts_portmapper`, a NAT-PMP and PCP client modelled on Go's `net/portmapper`, which only ever sends to the LAN gateway's port 5351.
* **runtime:** map the underlay UDP port on the LAN gateway with NAT-PMP or PCP and advertise the mapping to peers, as Go does; `Config::disable_port_mapper` or `TS_DISABLE_PORTMAPPER` turns it off.
* **exit node:** add `--portmap-probe`, a one-shot NAT-PMP/PCP check, and flags for the port mapper, with two runner defaults that differ from Go (`--portmap-advertise-nonpublic` and `--portmap-lazy-renew` restore Go's).

### Performance

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-test = "0.2"

ts_keys.workspace = true
# `examples/exit_node --portmap-probe`: the one-shot NAT-PMP/PCP check.
ts_portmapper.workspace = true
ts_test_util.workspace = true

# The in-process fake tsrecorder the SSH session-recording tests run against: `hyper`'s h2c server
Expand Down Expand Up @@ -414,3 +416,8 @@ required-features = ["tsnet"]
[[example]]
name = "ssh_peer_lookup"
required-features = ["ssh"]

# Run the argument-parsing tests in `cargo test` (examples default to `test = false`).
[[example]]
name = "exit_node"
test = true
24 changes: 14 additions & 10 deletions PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ where that behaviour lives", not "this is a transliteration of that file".
| `wgengine/router`, OS side of `net/dns` | [`ts_host_net`](ts_host_net/src/lib.rs) (Linux `ip`/`resolvectl`, macOS `route`/`scutil`) |
| `net/dns/resolver` wire encoding | [`ts_dns_wire`](ts_dns_wire/src/lib.rs) + the MagicDNS server in [`ts_runtime::magic_dns`](ts_runtime/src/magic_dns.rs) |
| `net/netmon` | [`ts_netmon`](ts_netmon/src/lib.rs) |
| `net/portmapper`, `feature/portmapper` — NAT-PMP and PCP (UPnP-IGD is roadmap) | [`ts_portmapper`](ts_portmapper/src/lib.rs) (the lifecycle client, codecs, gateway lookup and `GatewayCap`) + the driver in [`ts_runtime::portmap`](ts_runtime/src/portmap.rs); `--portmap-probe` in [`examples/exit_node`](examples/exit_node/main.rs). Deviations: [`docs/PORTMAPPER.md`](docs/PORTMAPPER.md) |
| `net/art` + `github.com/gaissmai/bart` | [`ts_bart`](ts_bart/src/lib.rs) (+ [`ts_array256`](ts_array256/src/lib.rs), [`ts_bitset`](ts_bitset/src/lib.rs), [`ts_dynbitset`](ts_dynbitset/src/lib.rs)) |

### Runtime, API and utilities
Expand Down Expand Up @@ -118,7 +119,8 @@ cut with its eyes open. Items already tracked in
the 3-way bind handshake and the Geneve-framed relay data path); this node never serves as a
relay itself, and does not send `AllocateUDPRelayEndpointRequest` — a relay-capable peer
allocates on our behalf and announces the endpoint with a `CallMeMaybeVia`.
- `net/portmapper`, `feature/portmapper`, `feature/debugportmapper` — UPnP / PCP / NAT-PMP *(roadmap)*.
- `feature/debugportmapper`, and UPnP-IGD (the third protocol of `net/portmapper`, whose NAT-PMP
and PCP halves are mapped above) — the debug CLI and SSDP/SOAP mapping *(roadmap)*.
- `appc`, `feature/conn25`, `types/appctype` — app connectors (classic and conn25) *(roadmap)*.
- `drive`, `feature/drive` — Taildrive.
- `ipn/ipnserver`, `cmd/tailscaled`, `cmd/tailscale` — the daemon and its CLI. This is an embedded
Expand Down Expand Up @@ -2002,8 +2004,8 @@ rows were ported in the interval.
- already §A rows, assessed there and not repeated: `DisableHostsFileUpdates` (132),
`ForceRegisterMagicDNSIPv4Only` (133, the one open §A row), `EmitRuntimeMetrics` (139), the four
GRO/GSO knobs (140), `NeverGSOEqualTail` (141), `ScopeQuad100OnMacOS` (145).
- no subsystem to switch: `DisableUPnP` and `ProbeUDPLifetime` (no portmapper, no UDP-lifetime
probing — *roadmap*), `PeerMTUEnable` (no path-MTU discovery on this datapath),
- no subsystem to switch: `DisableUPnP` and `ProbeUDPLifetime` (no UPnP in the port mapper,
which speaks NAT-PMP and PCP only, and no UDP-lifetime probing — *roadmap*), `PeerMTUEnable` (no path-MTU discovery on this datapath),
`LinuxForceIPTables` / `LinuxForceNfTables` (`ts_host_net` installs no firewall rules),
`AppCStoreRoutes` (no app connector — *roadmap*), `DisableCaptivePortalDetection` (no
captive-portal detection), `DisableSplitDNSWhenNoCustomResolvers` (an iOS battery optimisation
Expand All @@ -2030,20 +2032,22 @@ rows were ported in the interval.
socket-binding caps, and the debug caps `debug-no-wg-trim` (no lazy WireGuard config here) and
`debug-disable-subnets-if-pac` (no WPAD detection).

- **`NetInfo` is reported with four of the twelve facets Go fills**
- **`NetInfo` is reported with seven of the twelve facets Go fills**
(`wgengine/magicsock/magicsock.go`: `updateNetInfo`) — **not applicable**, on grounds this fork has
already settled elsewhere. (The thirteenth field this tree models is `HairPinning`, and that one is
a row of its own above.) Go builds a whole `tailcfg.NetInfo`
out of each netcheck report: `MappingVariesByDestIP`, `UPnP`, `PMP`, `PCP`, `HavePortMap`, the
`DERPLatency` map keyed `"<rid>-v4"`/`"-v6"`, `WorkingIPv6`, `OSHasIPv6`, `WorkingUDP`,
`WorkingICMPv4`, `PreferredDERP` and `FirewallMode`, and hands it to a callback that ships it on the
next map request — de-duplicated by `BasicallyEqual`, so an unchanged report is not re-uploaded.
`ts_control::tokio::client`'s `CarriedNetInfo` carries four of those — `preferred_derp`,
`derp_latency`, `working_udp`, `mapping_varies_by_dest_ip` — and applies the whole of it to every
map request, which is Go's `hi.NetInfo = c.netinfo.Clone()` invariant and is already tested here.
The eight it does not carry each have an answer already recorded in the *no counterpart here* list
or in this fork's design: `UPnP`, `PMP`, `PCP` and `HavePortMap` need `net/portmapper`, which is
roadmap; `FirewallMode` describes an iptables-vs-nftables choice `ts_host_net` never makes because
`ts_control::tokio::client`'s `CarriedNetInfo` models eight of those — `preferred_derp`,
`derp_latency`, `working_udp`, `mapping_varies_by_dest_ip`, and from the port mapper `UPnP`,
`PMP`, `PCP` and `HavePortMap` — and applies the whole of it to every map request, which is Go's
`hi.NetInfo = c.netinfo.Clone()` invariant and is already tested here. `UPnP` is never set (so
seven are reported): it is never probed, so it is never claimed false. `HavePortMap` here means an *advertised* mapping, so
control never sees it without a port-mapped endpoint (see
[`docs/PORTMAPPER.md`](docs/PORTMAPPER.md)). The four it does not carry each have an answer
already recorded in the *no counterpart here* list or in this fork's design: `FirewallMode` describes an iptables-vs-nftables choice `ts_host_net` never makes because
it installs no firewall rules (the same reason capability version 136 is not applicable);
`WorkingIPv6` and `OSHasIPv6` come from a netcheck that probes v6, and this fork's `ts_netcheck` is
HTTPS-over-IPv4 only by deliberate anti-leak design — the note at the top of
Expand Down
4 changes: 2 additions & 2 deletions docs/MIGRATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Verdict legend: **PRESENT** = works, no material gap · **PARTIAL** = works with
| Subsystem | Verdict | Notes | Open beads |
|---|---|---|---|
| Control protocol (register/auth, map-poll, Noise/TS2021, zstd, capver) | **PARTIAL** | + auto-reauth on key expiry, NeedsMachineAuth-transient, zstd map responses, Container/Env hostinfo — all shipped this cycle. Complete on an unlocked tailnet. On a locked tailnet the node never sends its own `NodeKeySignature` (2026-10-14 rows C1 to C3), and several MapResponse and PingRequest fields are parsed but not acted on (rows C9 to C14) | parity polish: `tsr-84mh`, `tsr-cbw4`, `tsr-ajyc`. Rows C1 to C3 and C9 to C14 have no bead |
| MagicSock / disco / direct paths | **PARTIAL** | STUN/DERP/CallMeMaybe/ping-pong/best-addr work; peer-relay receiving side present (disco 0x04–0x09 codecs, bind handshake, Geneve data path for a relay a peer allocated); the relay **client** side that discovers relay servers and allocates one is absent (2026-10-14 row M18); **portmapper absent**, relay **serving** absent | `tsr-x4yg` (portmapper), `tsr-a9x4` (peer relays), `tsr-dsjq` (full netcheck), `tsr-7s3` (ping cadence), `tsr-qc3o` (NoPath demand-ping), `tsr-51o6` (flaky test) |
| MagicSock / disco / direct paths | **PARTIAL** | STUN/DERP/CallMeMaybe/ping-pong/best-addr work; peer-relay receiving side present (disco 0x04–0x09 codecs, bind handshake, Geneve data path for a relay a peer allocated); the relay **client** side that discovers relay servers and allocates one is absent (2026-10-14 row M18); **portmapper: NAT-PMP and PCP present** (`ts_portmapper` + `ts_runtime::portmap`; UPnP-IGD absent), relay **serving** absent | `tsr-x4yg` (portmapper; UPnP is phase 2), `tsr-a9x4` (peer relays), `tsr-dsjq` (full netcheck), `tsr-7s3` (ping cadence), `tsr-qc3o` (NoPath demand-ping), `tsr-51o6` (flaky test) |
| WireGuard tunnel | **PARTIAL** | initiator path complete (handshake/session/rekey/replay/cookie-consume); responder cookie issuance missing | `tsr-rl3n` (responder cookie + IsUnderLoad), `tsr-qn2` (MTU-aware padding) |
| Netstack (userspace TCP/IP + TUN) | **PARTIAL** | TCP/UDP both modes; needs egress backpressure + icmp-echo | `tsr-fqus` (ingress bounded since #587 at 4096 packets and 4096×MTU bytes, `ts_runtime/src/netstack_actor.rs:49-64`; egress still unbounded, `ts_runtime/src/dataplane.rs:115`, `:141`), `tsr-rsu` (auto-icmp-echo), `tsr-4pp` (per-session sharding) |
| MagicDNS | **PARTIAL** | responder + plain-UDP upstream + recursive (TUN) work; encrypted upstream dropped | `tsr-3l78` (DoH/DoT upstream), `tsr-i2w` (exit-node DoH authoritative-vs-forward) |
Expand Down Expand Up @@ -139,7 +139,7 @@ merged or closed.

**P2 (next-highest leverage):**
- `tsr-doz6` netmon (in-progress: slice a shipped; `tsr-7wrn` Linux + `tsr-cnlh` macOS backends remain)
- `tsr-x4yg` portmapper (UPnP-IGD/NAT-PMP/PCP) — EPIC; direct paths behind mapping-NATs stay on DERP
- `tsr-x4yg` portmapper — EPIC. NAT-PMP and PCP landed (phases 0–1: lifecycle client, runtime driver, `PortMapped` endpoint, NetInfo `PMP`/`PCP`/`HavePortMap`); UPnP-IGD (phase 2) is still open, so a router with UPnP only stays on DERP
- `tsr-0xn` Serve Go-wire-compatibility (closed by `50611ab`)
- `tsr-fqus` netstack backpressure (ingress half closed by #587; egress still unbounded)
- `tsr-7s3` magicsock ping cadence / path-selection parity (in-progress)
Expand Down
9 changes: 5 additions & 4 deletions docs/PARITY_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ refused, `control_runner.rs:1430`).
| M19 | No WireGuard persistent keepalive anywhere. A grep for `PersistentKeepalive` over the whole Go tree finds 0 hits, and `wgengine/wgcfg/nmcfg` does not exist | A 25 s keepalive is on by default for every peer (`ts_control/src/config.rs:102-114`, `:837`) | product-decision | The idle-wedge fix. A deliberate divergence that the code comment called Go parity. Bead `tsr-0hd2` (blocked). |
| M25 | Static endpoints from config (`magicsock.go:1002`, `:1407`) and cloud-metadata public IPs (`magicsock.go:1373`) | Not found. A grep for `static_endpoints\|GetPublicIPs\|cloud_info` finds nothing | product-decision | A metadata query is an extra egress. |

Still open from earlier docs and beads: the portmapper (`tsr-x4yg`, nothing under
`portmap\|upnp\|nat_pmp\|pcp_`), the netmon OS backends (`tsr-7wrn`, `tsr-cnlh`; only
Still open from earlier docs and beads: the portmapper's UPnP-IGD half (`tsr-x4yg`; NAT-PMP and
PCP are in `ts_portmapper`), the netmon OS backends (`tsr-7wrn`, `tsr-cnlh`; only
`ManualLinkMonitor` and `NoopLinkMonitor` exist, `ts_netmon/src/lib.rs:260`, `:297`), a full netcheck
report (`tsr-dsjq`; `ts_netcheck/src` holds only `derp_latency.rs` and `https.rs`), the NoPath demand
ping (`tsr-qc3o`, `sock.rs:2832-2836`), `disable-relay-client` (`tsr-re0d2b5d`), Mullvad and
Expand Down Expand Up @@ -296,7 +296,7 @@ except two: #616 replaced #600 by taking the fix from smoltcp 0.14.0, and #604 c
| `tsr-7wrn` | P2 | bug | open | tsr-doz6 slice b: Linux netlink LinkMonitor backend |
| `tsr-cnlh` | P2 | bug | open | tsr-doz6 slice c: macOS PF_ROUTE LinkMonitor backend |
| `tsr-re0d2b5d` | P2 | bug | open | Honour disable-relay-client instead of opening relay paths control switched off |
| `tsr-x4yg` | P2 | bug | open | magicsock: no portmapper (UPnP-IGD/NAT-PMP/PCP), direct paths behind mapping-NATs stay on DERP (EPIC) |
| `tsr-x4yg` | P2 | bug | open | magicsock portmapper (EPIC): NAT-PMP and PCP done (`ts_portmapper`, `ts_runtime::portmap`); UPnP-IGD (phase 2) open, so UPnP-only routers stay on DERP |
| `tsr-0h2` | P3 | feature | open | Daemon ask: SSH check-mode (HoldAndDelegate) round-trip |
| `tsr-1rs` | P3 | task | open | harden flaky udp_forwarder_splices test (real-UDP round-trip on loaded CI) |
| `tsr-3l78` | P3 | bug | open | magic-dns: DoH/DoT/HttpWireguard upstream resolvers parsed-then-dropped, encrypted-upstream split-DNS silently ignored |
Expand Down Expand Up @@ -460,7 +460,8 @@ Most recent wave:
`tka_disable`, `src/lib.rs:1347-1403`). Still missing: add and remove keys, local disable, recovery
AUMs, affected-sigs and wrap-preauth-key (2026-10-14 row C8), and the node's own `NodeKeySignature`
on register (rows C1 to C3).
- **UPnP / PCP / NAT-PMP portmapper** — gateway port-mapping for better direct connectivity.
- **UPnP-IGD port mapping** — NAT-PMP and PCP are done ([`ts_portmapper`](../ts_portmapper/src/lib.rs));
UPnP-IGD (SSDP discovery, SOAP mapping) is phase 2.
- **App connector** — `4via6`-style application connectors.
- **`4via6`** subnet-route encoding.
- ~~**Serve get/set_serve_config + accept-loop runtime**~~: shipped. `Device::set_serve_config`
Expand Down
17 changes: 11 additions & 6 deletions docs/PORTMAPPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ behind a hard NAT can reach it directly instead of over DERP. NAT-PMP (RFC 6886)
codecs, the gateway lookup and `GatewayCap`, the only code that sends packets. (The gateway
lookup's kernel source-address check connects a UDP socket but never sends on it.)
- Driver: [`ts_runtime::portmap`](../ts_runtime/src/portmap.rs), spawned by the direct manager.
- One-shot field check: `examples/exit_node --portmap-probe`. Like Go's `tailscale debug
portmap`, it is refused when `TS_DISABLE_PORTMAPPER` is set.

## Turning it off

Expand All @@ -24,12 +26,15 @@ sets `port_mapper: None`, and the `portmapper_test_isolation` check enforces the

## Deviations from Go

- **Advertising (`advertise_nonpublic_external`).** The default is Go's: advertise every external
address. With the option off, a private or CGNAT external address (a double NAT) is kept and
renewed, but not advertised.
- **Renewal while idle (`renew_while_idle`).** The default is Go's: renewal rides on activity (the
periodic ReSTUN and other kicks). With the option on, a due mapping is renewed even while idle,
as long as there are peers. With no peers a mapping is never renewed and expires.
- **Advertising (`advertise_nonpublic_external`).** The library default is Go's: advertise every
external address. The exit-node runner turns it off. A private or CGNAT external address (a
double NAT) is then kept and renewed, but not advertised: it is not reachable from outside that
NAT, so advertising it only costs peers failed disco probes. `--portmap-advertise-nonpublic`
reverts the runner to Go's behaviour.
- **Renewal while idle (`renew_while_idle`).** The library default is Go's: renewal rides on
activity (the periodic ReSTUN and other kicks). The runner renews a due mapping even while idle,
as long as it has peers. With no peers a mapping is never renewed and expires.
`--portmap-lazy-renew` reverts the runner to Go's behaviour.
- **`HavePortMap` means an advertised mapping**, so control never sees it without a type-3
endpoint.
- **Notifications.** Go publishes every successful mapping (NAT-PMP, PCP, UPnP, including a
Expand Down
Loading
Loading