Skip to content

feat(portmapper): add ts_portmapper, a NAT-PMP and PCP client - #643

Merged
GeiserX merged 7 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-1-crate
Oct 18, 2026
Merged

GeiserX merged 7 commits into
GeiserX:mainfrom
kasper0406:feat/portmapper-1-crate

Conversation

@kasper0406

@kasper0406 kasper0406 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

This is PR 1 of 3 adding Go's NAT port mapper (net/portmapper), for NAT-PMP and PCP. With an inbound mapping on the LAN gateway, a peer behind a hard NAT can reach this node directly instead of over DERP. This PR adds only the crate. Nothing in the node uses it yet.

  1. This PR: 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 (runtime driver, magicsock endpoint, NetInfo, config). Depends on this PR.
  3. feat(exit_node): port-mapper flags and a one-shot --portmap-probe #645: exit-node runner flags and --portmap-probe. Depends on feat(runtime): map the underlay port with NAT-PMP or PCP, as Go does #644.

What it adds

  • Codecs (pmp, pcp): strict RFC 6886 and RFC 6887 encoders and parsers.
  • Gateway lookup (gateway): the default route's gateway, read from the macOS routing socket or Linux /proc/net/route. It must be private (RFC 1918) and on-link. Other OSes get Unsupported.
  • GatewayCap, the only code that sends to the LAN. Sockets are bound to the host's own address and, on macOS and on Linux with permission for SO_BINDTODEVICE, to its interface. Without interface binding, the route is checked again before each send. The only destination is gateway:5351, and a policy is checked before every send. No API takes an arbitrary address. The gateway lookup's kernel source-address check connects a UDP socket but never sends on it.
  • One-shot exchanges (oneshot, probe_tool): single probe, map and delete exchanges, and a probe that maps a test port and reports what the gateway supports. PR 3 exposes the probe as --portmap-probe.
  • Lifecycle client (client): creates, renews, expires and releases the mapping. One private owner settles every transition; the table is in docs/PORTMAPPER.md.
  • Test harness (feature test-harness, dev-dependencies only): a loopback fake NAT-PMP/PCP router with its own RFC decoder, and TestCap.
  • Fuzz targets for every parser, in ts_portmapper/fuzz (outside the workspace, like ts_tka/fuzz).
  • checks tripwire, portmapper_lan_only. It requires:
    • no TLS or HTTP crate in the crate's dependency closure;
    • no dependency on the crate from ts_control;
    • test-harness enabled only from dev-dependencies;
    • no socket, send or process spawn outside cap.rs and the harness.

It adds no new crate to the lockfile. Like the other published crates, it gets an audit-as-crates-io = false policy in supply-chain/config.toml.

Mapping to Go's net/portmapper

Same as Go:

  • NAT-PMP and PCP wire formats;
  • a 7200 s requested lifetime, renewed at about half the lifetime (see below for the clamp);
  • protocol choice as in preferPCP;
  • the probe-epoch rule for detecting a router reset;
  • release on close.

Go's tests are ported where they don't need a live router: TestProbeIntegration (the PMP/PCP half), TestPCPIntegration, TestParsePCPMapResponse, TestUpdateEvent, the PCP fuzzers (upstream Go's net/portmapper/fuzz_test.go, on tailscale main after v1.102.4), and netmon's TestLikelyHomeRouterIP* and routing-table tests.

Deliberate differences (full list in docs/PORTMAPPER.md):

  • Bounded rule ledger. Go keeps one mapping and forgets it once it sends a delete. Here the client remembers every rule the router may still hold, up to four. That includes rules whose delete went unacknowledged and rules whose network went away. A slot is freed by an acknowledged delete, once a matched grant has expired, or, for a MAP with no success reply, after the requested lifetime (7200 s) from its last send. A MAP reserves its slot before it is sent, but becomes a possible router grant only once its datagram reaches the send syscall: a local failure before that (socket, bind, route validation) frees a fresh slot at once and leaves an existing rule's grant unchanged. Four unresolved rules stop new creates for at most two hours instead of leaking more rules.
  • Stricter LAN boundary.
    • Only the most preferred default route counts, and it must be private and on-link. Go can fall through to another private route, such as a VPN's.
    • Sockets are bound to the host's own address and, where the OS allows it, to its interface. In Linux source-address mode (no SO_BINDTODEVICE permission), the route is checked again before every send. Release at shutdown and on Drop then fails closed, and the rules are left to expire.
    • Replies are accepted only from exactly gateway:5351, with every field validated. Go's probe checks only the source port.
  • IPv4 only. A PCP reply with a native IPv6 external address is rejected; Go accepts it.
  • Renewal timing. Renewal starts at half the trusted lifetime, clamped to between 10 s and 1 h and never later than expiry. Go uses half the granted lifetime.
  • Wire details.
    • A NAT-PMP delete suggests external port 0, as RFC 6886 §3.4 requires; Go sends the current port.
    • A renewal keeps the mapping's protocol, because PMP and PCP can share one router rule and switching could delete the replacement.
    • Local trust is capped at 2 h even when a PCP router grants more.
  • Notifications. Go publishes every successful mapping (NAT-PMP, PCP, UPnP, including a reused one) and never a loss, expiry or disable. Here loss, expiry and disable publish too, for both protocols.
  • Attempt spacing. Go retries a failed create on the next ReSTUN with no backoff (only a 5 s skip after a probe that found nothing). Here consecutive failures space attempts 10/30/60/120/300/600 s (±20 %), no two attempts start within 2 s, and a PCP error lifetime is honoured up to 30 min; while that backoff holds, a probe after one that found nothing is skipped too. NOT_AUTHORIZED takes the ordinary ladder, with no longer hold.
  • PCP nonce and delete. The nonce is kept for renewal and delete and every MAP reply must echo it; Go v1.102.4 draws a fresh nonce per request and never checks it. The PCP delete suggests port 0 and ::ffff:0.0.0.0; Go sends the current external address.
  • No UPnP-IGD. It is not implemented (tracked as tsr-x4yg).

Testing

  • cargo test -p geiserx_ts_portmapper: 244 unit tests and 3 doctests. One doctest checks that the test transport does not exist without the feature. All packets go to loopback fake routers. The only real-system reads are the routing table and the interface list.
  • An independent ownership model checks conservation in the shared mapping and settling helpers. Router-inventory checks compare the ledger with the fake router's rule table in the cancellation, cleanup and reset scenarios, which include every MAP and cleanup cancellation point.
  • Each fuzz target ran 100,000 inputs with seeded corpora and found no failures.
  • Branch gates on macOS, cargo +1.95.0, all passing:
    • workspace tests, default and all features;
    • clippy -D warnings: all targets, the CI lib/other/tun variants, all features, and bin/check-windows (x86_64-pc-windows-msvc via cargo-xwin);
    • fmt and cargo run -p checks;
    • cargo doc with -D warnings;
    • the fuzz crate builds;
    • clippy for x86_64-unknown-linux-gnu;
    • cargo vet --locked, cargo deny (all features, and the no-default-features lane) and cargo machete. Machete's one finding, bytes in ts_forwarder, is already on main.
  • Not run here: tests on Linux. Fixtures cover the Linux route parser, and SO_BINDTODEVICE is compiled and linted for Linux.

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

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Repository: GeiserX/tailscale-rs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5819e371-d5ce-4f72-83b4-03d269b77acf
📥 Commits

Reviewing files that changed from the base of the PR and between 96cdc2d and 249a7df.

⛔ Files ignored due to path filters: 2
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
  • ts_portmapper/fuzz/Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing: 41
  • .gitignore
  • CHANGELOG.md
  • Cargo.toml
  • checks/src/lints_enabled.rs
  • checks/src/main.rs
  • checks/src/portmapper_lan_only.rs
  • docs/PORTMAPPER.md
  • supply-chain/config.toml
  • ts_portmapper/Cargo.toml
  • ts_portmapper/README.md
  • ts_portmapper/fuzz/Cargo.toml
  • ts_portmapper/fuzz/fuzz_targets/parse_pcp.rs
  • ts_portmapper/fuzz/fuzz_targets/parse_pcp_map.rs
  • ts_portmapper/fuzz/fuzz_targets/parse_pmp.rs
  • ts_portmapper/fuzz/fuzz_targets/parse_proc_net_route.rs
  • ts_portmapper/fuzz/fuzz_targets/parse_rt_dump.rs
  • ts_portmapper/src/blocking.rs
  • ts_portmapper/src/cap.rs
  • ts_portmapper/src/client.rs
  • ts_portmapper/src/client_tests.rs
  • ts_portmapper/src/clock.rs
  • ts_portmapper/src/gateway/darwin.rs
  • ts_portmapper/src/gateway/linux.rs
  • ts_portmapper/src/gateway/mod.rs
  • ts_portmapper/src/gateway/other.rs
  • ts_portmapper/src/lib.rs
  • ts_portmapper/src/lifecycle.rs
  • ts_portmapper/src/oneshot.rs
  • ts_portmapper/src/owned.rs
  • ts_portmapper/src/ownership_model.rs
  • ts_portmapper/src/pcp.rs
  • ts_portmapper/src/pmp.rs
  • ts_portmapper/src/probe_tool.rs
  • ts_portmapper/src/schedule.rs
  • ts_portmapper/src/test_time.rs
  • ts_portmapper/src/testing/cap.rs
  • ts_portmapper/src/testing/clock.rs
  • ts_portmapper/src/testing/lifecycle.rs
  • ts_portmapper/src/testing/mod.rs
  • ts_portmapper/src/testing/router.rs
  • ts_portmapper/src/testing/tests.rs
+------------------------------------------+
| My GPUs are ready. Let's find some bugs. |
+------------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • 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 3 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 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>
@kasper0406
kasper0406 force-pushed the feat/portmapper-1-crate branch from 2065570 to 624278d 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.
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.
@GeiserX
GeiserX merged commit dcd5ec2 into GeiserX:main Oct 18, 2026
15 of 16 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