Skip to content

feat(exit_node): port-mapper flags and a one-shot --portmap-probe - #645

Merged
GeiserX merged 16 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-3-runner
Oct 18, 2026
Merged

GeiserX merged 16 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-3-runner

Conversation

@kasper0406

@kasper0406 kasper0406 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

PR 3 of 3. Depends on #644; please review it after #644. Stacked PRs from a fork all target main, so until #643 and #644 merge this diff also shows their commits. This PR's own commits are the last two: feat(exit_node): port-mapper flags and a one-shot --portmap-probe and docs: record the port mapper in the ledger and roadmap.

  1. feat(portmapper): add ts_portmapper, a NAT-PMP and PCP client #643: the ts_portmapper crate.
  2. feat(runtime): map the underlay port with NAT-PMP or PCP, as Go does #644: run it in the node.
  3. This PR: runner controls, the probe, and the docs.

What it adds

  • --portmap-probe runs instead of the node, before any identity, state or control connection exists. It maps a dedicated test port on the LAN gateway with each protocol separately. It can hold the mapping open to count inbound datagrams (--portmap-hold; it counts only, payloads are not read), then deletes the mapping and exits:

    • 0: every protocol mapped and deleted;
    • 2: some did;
    • 1: none did, or there was no usable gateway.

    --portmap-wait retries the gateway lookup for a job started before the network is up. Interrupts end the wait or the hold early, and the delete still runs. The node's own ports (41641, 41642, --port) are refused as test ports, and the probe is refused when TS_DISABLE_PORTMAPPER is set, as Go's debug portmap is. --portmap-hold must be shorter than --portmap-lifetime. The output may contain the router's public address.

  • Runner defaults that differ from the library's, which are Go's:

    • A private or CGNAT external address (a double NAT) is 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 restores Go's behaviour.
    • A due mapping is renewed while idle as long as there are peers, so an idle exit node stays directly reachable. Go renews only on activity; --portmap-lazy-renew restores that.
  • Other flags: --portmap-gateway GW[,SELF] overrides the gateway, validated like a discovered one. --no-portmapper turns the mapper off.

  • Status line: gains the portmap= summary (Go's NetInfo summary; here active- means an advertised mapping, in Go any held mapping; e.g. active-_MC). It also shows the held mapping, marked when it is not advertised.

  • Docs:

    • docs/PORTMAPPER.md describes the runner's defaults;
    • PORTING.md maps net/portmapper's NAT-PMP and PCP halves and now counts seven of Go's twelve NetInfo facets as reported (an eighth, UPnP, is modelled but never set);
    • the migration status and parity roadmap mark NAT-PMP and PCP done and keep tsr-x4yg open for UPnP-IGD.

Mapping to Go

The closest Go tool is tailscale debug portmap (feature/debugportmapper), which this repository does not have. --portmap-probe covers the same need for an embedded node. It deletes what it created, can count inbound traffic, and never uses the node's own ports. This PR changes two runner policy defaults relative to Go, and each has a flag that restores Go's default. The library defaults stay Go's. The library's other documented differences (#643, #644) are unaffected by these flags.

Testing

  • Unit tests for the argument parsing: the probe needs no state directory; probe-only flags require --portmap-probe; ranges, reserved ports and a hold not shorter than the lifetime are rejected; TS_DISABLE_PORTMAPPER refuses the probe; the runner defaults and the flags that revert them. exit_node is now an [[example]] with test = true, so cargo test runs these in CI.
  • The probe itself is tested in feat(portmapper): add ts_portmapper, a NAT-PMP and PCP client #643 against the fake router. The tests cover map and delete per protocol, the hold window, interrupts, a lease shorter than the hold, waiting for a late route, partial and refused service, and reserved ports.
  • Branch gates on macOS, cargo +1.95.0, all passing:
    • workspace tests, default and all features;
    • clippy -D warnings across all CI variants, including bin/check-windows;
    • fmt, cargo run -p checks, cargo doc, cargo vet --locked and cargo deny;
    • the fuzz crate builds.

Field results

--portmap-probe against a consumer router running MiniUPnPd: the router answered both NAT-PMP and PCP. Map and delete were acknowledged for both protocols, and datagrams sent from an outside network arrived at the mapped port during the hold.

On Linux (x86_64, kernel 6.8), the same probe found the gateway through /proc/net/route, bound to the interface, and mapped and deleted with both protocols. On that machine the workspace tests passed (2,548 passed, 0 failed; one root-only TUN test ignored), and so did workspace clippy -D warnings.

Hard-NAT client test (2026-10-18): a laptop on a network with endpoint-dependent mapping (tailscale netcheck: MappingVariesByDestIP: true) reached a node running this series directly through the NAT-PMP mapping (tailscale ping: pong … via <mapped address> in 26ms). The node logged the path as direct to a source port the laptop's NAT chose for that destination alone, which only an open inbound mapping can accept. Before the port mapper, the same node was reachable only through DERP from such networks, while a Go client on the same host, which holds its own mapping, was direct. The node also renewed its two-hour lease overnight, and re-mapped by itself after the router rebooted.

Created using Claude Code
The code was written and reviewed with GPT-6.1 (Codex); the split, polish and gates were done with Claude Code.

🤖 Generated with Claude Code

Add the ts_portmapper crate's wire layer, modelled on Go's
net/portmapper:

- strict NAT-PMP (RFC 6886) and PCP (RFC 6887) codecs;
- default-gateway lookup from the macOS routing socket and Linux
  /proc/net/route, validated as private and on-link;
- GatewayCap, the only code that sends to the LAN: sockets bound to the
  host's own address and, where the OS allows it, its interface,
  gateway:5351 as the only destination, and a policy checked before
  every send;
- a one-shot probe, map and delete, and a probe tool that maps a test
  port and reports what the gateway supports;
- behind the test-harness feature, a loopback fake NAT-PMP/PCP router
  with its own RFC decoder, and TestCap, a loopback transport.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 58 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: GeiserX/tailscale-rs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0a03b7e6-f102-4eac-b4b5-05543fd5089d
📥 Commits

Reviewing files that changed from the base of the PR and between 572d377 and 5d83cba.

⛔ Files ignored due to path filters: 1
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing: 7
  • CHANGELOG.md
  • Cargo.toml
  • PORTING.md
  • docs/MIGRATION_STATUS.md
  • docs/PARITY_ROADMAP.md
  • docs/PORTMAPPER.md
  • examples/exit_node/main.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

kasper0406 and others added 7 commits October 18, 2026 15:06
Add the port mapper's lifecycle client: create, renew, expire and
release a mapping, with generation-guarded transitions and Go's
retry, renewal and probe-epoch rules.

A bounded ledger of four rules tracks every rule the router may hold
for us, including unacknowledged deletes and rules on networks that
went away. A slot is freed only by an acknowledged delete or proven
router expiry. One private owner settles every transition (see the
table in docs/PORTMAPPER.md). Shutdown and Drop release with cached
capabilities, without a runtime and without fresh OS discovery.

Tests run against the fake router. An independent ownership model
checks conservation in the shared mapping and settling helpers, and
router-inventory checks compare the ledger with the fake router's rule
table in the cancellation, cleanup and reset scenarios, including
every MAP and cleanup cancellation point.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cargo-fuzz crate with the same layout as ts_tka/fuzz, kept out of
the workspace: NAT-PMP and PCP replies (including the MAP validators)
and both routing-table parsers (the macOS routing-socket dump and Linux
/proc/net/route).

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A driver (ts_runtime/src/portmap.rs), spawned by the direct manager
unless the mapper is off, runs the ts_portmapper lifecycle client for
the magicsock's underlay port and installs the mapping as the
PortMapped endpoint.

- magicsock: SelfEndpointType::PortMapped (Go EndpointPortmapped).
  self_endpoints() emits Go's priority order (port-mapped, STUN,
  STUN4LocalPort, local) and lists each address once. A rebind clears
  the mapping at once. CallMeMaybe carries it.
- control: StunFacts and PortMapFacets travel with the endpoints, and
  NetInfo carries PMP, PCP and HavePortMap on every map request. UPnP is
  never set. ts_control still does not depend on the port mapper.
- driver: wakes on a 10 s network poll (resume and clock-jump aware),
  the mapping's expiry, the periodic ReSTUN and kicks (start, first
  peer, rebind, only-tcp-443 lifted). A rebind invalidates
  synchronously. only-tcp-443 stops every LAN send at once, deletes
  included. A graceful shutdown closes the client first, within
  min(1 s, timeout), and deletes with cached capabilities; dropping
  the runtime sends one best-effort delete per rule without a Tokio
  runtime. In Linux source-address mode (no SO_BINDTODEVICE
  permission) both fail closed and leave the rules to expire.
- config: disable_port_mapper (Go DisablePortMapper) and
  PortMapperOptions on tailscale::Config and ts_control::Config, with
  Go's defaults. TS_DISABLE_PORTMAPPER is read once at startup.
  Device::port_map_status() reports the facets and the mapping.
- feature portmapper, on by default as in Go, on ts_runtime and the
  facade. Without it the driver is not compiled and the facets stay
  unset.

Tests never reach a real gateway. Every shared runtime test
configuration sets port_mapper: None. In ts_runtime's test build the
production gateway lookup is a stub that panics. The integration tests
that build devices set TS_DISABLE_PORTMAPPER.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--portmap-probe runs before any identity, state or control connection
exists. It maps a dedicated test port on the LAN gateway with each
protocol separately, can hold the mapping open to count inbound
datagrams, deletes it and exits (0: every protocol mapped and deleted,
2: some, 1: none or no usable gateway). Interrupts end the wait or the
hold early, and the delete still runs.

For the node, the runner defaults differ from the library's (Go's)
defaults in two places. It does not advertise a private or CGNAT
mapping (a double NAT). It renews a due mapping while idle as long as
it has peers, so an idle exit node stays directly reachable.
--portmap-advertise-nonpublic and --portmap-lazy-renew restore Go's
behaviour. --portmap-gateway overrides the gateway (validated like a
discovered one), and --no-portmapper turns the mapper off. The status
line gains Go's netcheck portmap= summary and the mapping.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tripwire for what the compiler cannot check. ts_portmapper's
dependency closure has no TLS or HTTP crate, ts_control does not depend
on it, and only dev-dependencies may enable its test-harness feature.
Outside cap.rs and the test harness, no line of ts_portmapper/src may
name a socket type, a send or connect call, a raw fd, a child process
or the IPv6 wildcard. The kernel source-address lookup and a macOS
netstat cross-check test are allowlisted by function name.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The port mapper is on by default, so a test that builds a real device
would ask the host's router for a mapping. A new check,
portmapper_test_isolation, makes the guards a gate:

- tests/*.rs that build a Device or Server must set
  TS_DISABLE_PORTMAPPER;
- runtime sources outside env.rs and portmap.rs may not enable the
  mapper;
- the cfg(test) gateway-lookup stub in portmap.rs must stay.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PORTING.md maps net/portmapper's NAT-PMP and PCP halves to
ts_portmapper and the runtime driver, leaves UPnP-IGD and
feature/debugportmapper as roadmap, and counts the NetInfo facets now
reported: seven of Go's twelve, with UPnP modelled but never set. The
migration status and parity roadmap mark NAT-PMP and PCP done and keep
tsr-x4yg open for UPnP-IGD.

Signed-off-by: Kasper Nielsen <kasper0406@gmail.com>
Co-Authored-By: GPT-6.1 (codex) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kasper0406
kasper0406 force-pushed the feat/portmapper-3-runner branch from 8fa46d0 to 7cfbe24 Compare October 18, 2026 13:07
…ifetime

Drop the 30 min NOT_AUTHORIZED hold (Go retries on the next ReSTUN),
run the epoch-rollback check before recording the fresh probe answer as
Go does, and bound an unanswered MAP's possible grant to the requested
7200 s so four left networks cannot stop port mapping for the process.
State the remaining deliberate differences from Go v1.102.4 in
docs/PORTMAPPER.md and correct the notification and fuzzer provenance
claims.
# Conflicts:
#	docs/PORTMAPPER.md
Print active-___ for a held mapping with nothing checked, as Go's
NetInfo.portMapSummary does, sort the STUN endpoint group so the
advertiser's ordered comparison is stable (Go compares sets), and stop
attributing the only-tcp-443 behaviour and the CallMeMaybe gate to Go.
List only-tcp-443, TS_DISABLE_PORTMAPPER, same-port rebind, the missing
endpoint tracker, the network poll and resume detection as deviations.
Two tests pinned the old rule that an unanswered MAP holds its slot for
ever. Now its slot frees after the requested lifetime from the last
send; network loss alone still retires nothing.
…SABLE_PORTMAPPER

The exit_node tests never ran: examples default to test = false. The
probe now refuses to run when TS_DISABLE_PORTMAPPER is set, as Go's
debug portmap does, --portmap-hold says it must be shorter than the
lifetime, and the runner's non-public suppression and the portmap=
summary are explained and attributed correctly.
# Conflicts:
#	CHANGELOG.md
#	docs/PORTMAPPER.md
#	ts_magicsock/src/sock.rs
#	ts_runtime/src/direct.rs
@GeiserX
GeiserX merged commit b6bf137 into GeiserX:main Oct 18, 2026
17 checks passed
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.

2 participants