Repository navigation
feat(exit_node): port-mapper flags and a one-shot --portmap-probe - #645
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 58 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>
--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
force-pushed
the
feat/portmapper-3-runner
branch
from
October 18, 2026 13:07
8fa46d0 to
7cfbe24
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.
…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
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 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-probeanddocs: record the port mapper in the ledger and roadmap.ts_portmappercrate.What it adds
--portmap-proberuns 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:--portmap-waitretries 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 whenTS_DISABLE_PORTMAPPERis set, as Go'sdebug portmapis.--portmap-holdmust 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:
--portmap-advertise-nonpublicrestores Go's behaviour.--portmap-lazy-renewrestores that.Other flags:
--portmap-gateway GW[,SELF]overrides the gateway, validated like a discovered one.--no-portmapperturns the mapper off.Status line: gains the
portmap=summary (Go's NetInfo summary; hereactive-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.mddescribes the runner's defaults;PORTING.mdmapsnet/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);tsr-x4ygopen for UPnP-IGD.Mapping to Go
The closest Go tool is
tailscale debug portmap(feature/debugportmapper), which this repository does not have.--portmap-probecovers 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
--portmap-probe; ranges, reserved ports and a hold not shorter than the lifetime are rejected;TS_DISABLE_PORTMAPPERrefuses the probe; the runner defaults and the flags that revert them.exit_nodeis now an[[example]]withtest = true, socargo testruns these in CI.cargo +1.95.0, all passing:-D warningsacross all CI variants, includingbin/check-windows;cargo run -p checks,cargo doc,cargo vet --lockedandcargo deny;Field results
--portmap-probeagainst 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