Repository navigation
feat(runtime): map the underlay port with NAT-PMP or PCP, as Go does - #644
Merged
Merged
Conversation
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>
This was referenced Oct 18, 2026
|
Warning Review limit reachedYou'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. View limit details
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. Comment |
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
force-pushed
the
feat/portmapper-2-integration
branch
from
October 18, 2026 13:07
1e7bd1f to
80ea74a
Compare
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 socketandfeat(checks): keep tests off the LAN gateway.ts_portmappercrate.--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 aPortMappedendpoint. It wakes on:only-tcp-443lifted.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 (noSO_BINDTODEVICEpermission), where a send needs a fresh route check; the rules then expire.magicsock:
SelfEndpointType::PortMapped(GoEndpointPortmapped).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:
StunFactsandPortMapFacetstravel with the endpoints. NetInfo carriesPMP,PCPandHavePortMapon every map request, reconnects included.UPnPis never set, because it is never probed.ts_controlstill does not depend on the port mapper.Config:
disable_port_mapper(GoDisablePortMapper) andPortMapperOptionsontailscale::Config, with Go's defaults;TS_DISABLE_PORTMAPPER, read once at startup;Device::port_map_status();portmappercargo feature, on by default as in Go. Without it the driver is not compiled.Test isolation:
port_mapper: None;ts_runtime's test build the production gateway lookup is a stub that panics;TS_DISABLE_PORTMAPPER;portmapper_test_isolation, keeps all three in place.Mapping to Go
Same as Go:
DisablePortMapper(no client, NetInfo facets unset).determineEndpoints.PMPandPCPin NetInfo, and theportmap=summary throughPortMapFacets::summary(). Itsactive-prefix is Go's NetInfo portmap summary (tailcfg.NetInfo.portMapSummary); here it means an advertised mapping, where Go's means any held mapping.Differences:
HavePortMapmeans an advertised mapping, so control never sees it without a type-3 endpoint.TS_DISABLE_PORTMAPPERbuilds no client, so NetInfo facets are unset; Go keeps the client and reportsUPnP/PMP/PCPas false.only-tcp-443stops 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.SetLocalPortkeeps 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).Droppath also releases, best-effort. Neither sends in Linux source-address mode.Testing
ts_runtime(618 tests),ts_magicsock(212) andts_control(351) cover:only-tcp-443;Droprelease;cargo +1.95.0, all passing:tunlib tests;ts_runtimetests and clippy with--no-default-features, and a check of the facade without default features;-D warningsacross all CI variants, includingbin/check-windows;cargo run -p checks,cargo doc,cargo vet --lockedandcargo deny.Field results
Before this series was split, a consumer router running MiniUPnPd was tested with
--portmap-probefrom #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 warningsis clean.--portmap-probemapped 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