Skip to content

feat(runtime): map the underlay port with NAT-PMP or PCP, as Go does - #644

Merged
GeiserX merged 12 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-2-integration
Oct 18, 2026
Merged

GeiserX merged 12 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-2-integration

Conversation

@kasper0406

@kasper0406 kasper0406 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

PR 2 of 3. Depends on #643; please review it after #643. Stacked PRs from a fork all target main, so until #643 merges this diff also shows its commits. This PR's own commits are the last two: feat(runtime): run the port mapper for the underlay socket and feat(checks): keep tests off the LAN gateway.

  1. feat(portmapper): add ts_portmapper, a NAT-PMP and PCP client #643: the ts_portmapper crate.
  2. This PR: run it in the node.
  3. feat(exit_node): port-mapper flags and a one-shot --portmap-probe #645: exit-node runner flags and --portmap-probe. Depends on this PR.

What it adds

  • Driver (ts_runtime/src/portmap.rs): spawned by the direct manager unless the mapper is off. It runs the lifecycle client for the magicsock's underlay port and installs the mapping as a PortMapped endpoint. It wakes on:

    • a 10 s network poll, which also notices a resume or clock jump;
    • the mapping's expiry;
    • the periodic ReSTUN;
    • kicks: start, first peer, rebind, only-tcp-443 lifted.

    A rebind invalidates synchronously. 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. Both fail closed in Linux source-address mode (no SO_BINDTODEVICE permission), where a send needs a fresh route check; the rules then expire.

  • magicsock: SelfEndpointType::PortMapped (Go EndpointPortmapped). self_endpoints() emits Go's priority order (port-mapped, STUN, STUN4LocalPort, local) and lists each address once. CallMeMaybe carries the mapping. A rebind clears it at once.

  • control: StunFacts and PortMapFacets travel with the endpoints. NetInfo carries PMP, PCP and HavePortMap on every map request, reconnects included. UPnP is never set, because it is never probed. ts_control still does not depend on the port mapper.

  • Config:

    • disable_port_mapper (Go DisablePortMapper) and PortMapperOptions on tailscale::Config, with Go's defaults;
    • TS_DISABLE_PORTMAPPER, read once at startup;
    • Device::port_map_status();
    • a portmapper cargo feature, on by default as in Go. Without it the driver is not compiled.
  • Test isolation:

    • every shared runtime test configuration sets port_mapper: None;
    • in ts_runtime's test build the production gateway lookup is a stub that panics;
    • integration tests that build devices set TS_DISABLE_PORTMAPPER;
    • a new check, portmapper_test_isolation, keeps all three in place.

Mapping to Go

Same as Go:

  • Off switch: DisablePortMapper (no client, NetInfo facets unset).
  • The port-mapped endpoint comes first in the endpoint list, as in determineEndpoints.
  • PMP and PCP in NetInfo, and the portmap= summary through PortMapFacets::summary(). Its active- prefix is Go's NetInfo portmap summary (tailcfg.NetInfo.portMapSummary); here it means an advertised mapping, where Go's means any held mapping.
  • Renewal rides on activity (ReSTUN), and the mapper is on by default.

Differences:

  • HavePortMap means an advertised mapping, so control never sees it without a type-3 endpoint.
  • TS_DISABLE_PORTMAPPER builds no client, so NetInfo facets are unset; Go keeps the client and reports UPnP/PMP/PCP as false.
  • only-tcp-443 stops every LAN send, withdraws the mapping and deletes nothing until lifted; Go only blocks new creates and probes, keeps advertising the cached mapping and still releases it on Close.
  • A same-port rebind withdraws the endpoint until the network re-validates; Go's SetLocalPort keeps the mapping. There is no endpoint tracker, so an expired or rebound mapping leaves the endpoint list at once (Go keeps it for 5 min 10 s).
  • Resume detection is a wall-vs-monotonic disagreement over 30 s on the 10 s poll; Go's netmon reacts to a wall-clock jump over 22.5 s on a 15 s poll.
  • The driver polls the default route every 10 s instead of subscribing to an OS link monitor, because the OS link-monitor backends are not ported yet.
  • Deletes at shutdown use cached capabilities with no fresh OS discovery, within the shutdown budget. The Drop path also releases, best-effort. Neither sends in Linux source-address mode.

Testing

  • ts_runtime (618 tests), ts_magicsock (212) and ts_control (351) cover:
    • the driver's wake-ups and idle behaviour;
    • rebind, network change and resume;
    • only-tcp-443;
    • shutdown and Drop release;
    • endpoint order and de-duplication;
    • NetInfo facets carried across reconnects.
  • One test runs through the real direct manager with a harness-backed client. It obtains a mapping, advertises it with wire type 3, survives a same-port rebind with no I/O, and releases on shutdown.
  • Branch gates on macOS, cargo +1.95.0, all passing:
    • workspace tests, default and all features, plus the tun lib tests;
    • ts_runtime tests and clippy with --no-default-features, and a check of the facade without default 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.

Field results

Before this series was split, a consumer router running MiniUPnPd was tested with --portmap-probe from #645. It 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.

Linux (x86_64, kernel 6.8): workspace tests 2,548 passed and 0 failed (one root-only TUN test ignored), including the Linux-only gateway and socket tests. Workspace clippy -D warnings is clean. --portmap-probe mapped and deleted with both protocols against the same router.

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 59 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: 6675ede4-d6b0-4a89-9e13-9e5847cef301
📥 Commits

Reviewing files that changed from the base of the PR and between dcd5ec2 and 0ef4cf8.

⛔ Files ignored due to path filters: 1
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing: 31
  • CHANGELOG.md
  • Cargo.toml
  • checks/src/main.rs
  • checks/src/portmapper_test_isolation.rs
  • docs/PORTMAPPER.md
  • src/config.rs
  • src/lib.rs
  • tests/basic.rs
  • tests/tailnet_e2e_campaign.rs
  • tests/tailnet_live.rs
  • tests/tsnet_facade_e2e.rs
  • ts_control/src/config.rs
  • ts_control/src/lib.rs
  • ts_control/src/map_request_builder.rs
  • ts_control/src/net_facets.rs
  • ts_control/src/tokio/client.rs
  • ts_magicsock/src/endpoint.rs
  • ts_magicsock/src/sock.rs
  • ts_runtime/Cargo.toml
  • ts_runtime/src/control_runner.rs
  • ts_runtime/src/derp_latency.rs
  • ts_runtime/src/direct.rs
  • ts_runtime/src/env.rs
  • ts_runtime/src/forwarder_actor.rs
  • ts_runtime/src/lib.rs
  • ts_runtime/src/netmon.rs
  • ts_runtime/src/packetfilter.rs
  • ts_runtime/src/peer_tracker/mod.rs
  • ts_runtime/src/peerapi_doh.rs
  • ts_runtime/src/portmap.rs
  • ts_runtime/src/tun_actor.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 5 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>
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>
@kasper0406
kasper0406 force-pushed the feat/portmapper-2-integration branch from 1e7bd1f to 80ea74a 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.
# Conflicts:
#	CHANGELOG.md
#	checks/src/main.rs
#	docs/PORTMAPPER.md
#	ts_magicsock/src/sock.rs
#	ts_runtime/src/direct.rs
@GeiserX
GeiserX merged commit 572d377 into GeiserX:main Oct 18, 2026
15 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