Skip to content

tailcat, internal/buildtags: link the port mapper in native builds - #152

Open
pedramamini wants to merge 1 commit into
tailscale:mainfrom
pedramamini:portmapper
Open

pedramamini wants to merge 1 commit into
tailscale:mainfrom
pedramamini:portmapper

Conversation

@pedramamini

Copy link
Copy Markdown

Updates #143

magicsock creates a port mapper only when feature/portmapper registers one, and nothing in tailcat imports it; release builds also set ts_omit_portmapper. So neither side ever asks its router for a UPnP, NAT-PMP, or PCP mapping, and a peer's only public endpoint is the one STUN learned. (The author of #143 assumed tailcat already relies on UPnP/NAT-PMP/PCP; it doesn't.)

That fails when a client sits behind a NAT that allocates a new public port per destination (common on hotel, office, and carrier networks) and the server sits behind an ordinary home router: the client's endpoint is unpredictable, the server's router drops its packets, and the connection stays on DERP for its whole life. A router mapping accepts packets from any source, which gives such a client an endpoint it can reach directly.

This imports feature/condregister/portmapper from the tailcat package, as tsnet does, and keeps portmapper in releaseKeep, so ts_omit_portmapper drops out of build-tags.txt and .goreleaser.yaml. The wasm build still omits it: a browser has no UDP sockets. TS_DISABLE_PORTMAPPER=1 turns it off at runtime, TS_DISABLE_UPNP=1 only UPnP; the README and CHANGELOG say so. It adds 352 KB (1.9%) to the stripped linux/amd64 binary.

The new test, internal/buildtags/deps_test.go, runs go list -deps on cmd/tailcat with ReleaseTags() and on cmd/tailcat-web with WasmTags(). It checks the binaries rather than the test binary on purpose: test builds link the port mapper on their own through feature/condregister, so a hook check in the package's tests passes even without this change. The new test fails if either the import or the keep-list entry is removed.

Testing:

  • go build ./..., go vet ./... and go test -count=1 -timeout 600s ./... pass (darwin/arm64, go1.26.5). make tidy leaves go.mod, go.sum and flakehashes.json unchanged. Release-tag cross-builds pass for linux amd64/arm64/arm, windows amd64/arm64, darwin, freebsd and openbsd, as do GOOS=js GOARCH=wasm go build ./web and the libtailcat c-shared smoke test.
  • Live, from a hard-NAT client: a GitHub Actions runner ran the stock v0.7.0 client inside a network namespace behind MASQUERADE --random-fully (STUN saw mapped ports 28674, 28674 and 28677 for three destinations) and ran tailcat ping --until-direct --timeout 45s against servers behind a NETGEAR Orbi with UPnP, NAT-PMP and PCP enabled:
    • stock v0.7.0 server: DERP only, no direct path after 45 s
    • this change: direct to the router-mapped port on the second ping, 27 ms (61 ms via DERP)
    • this change, server inside a Docker bridge container: DERP only, as expected, since the container's gateway is docker0 rather than the router
  • Five servers on that LAN have run it since; each holds a lease in the router's mapping table.

The live tests used this commit cherry-picked onto v0.7.0, to match v0.7.0 clients; the PR is against main.

magicsock creates a port mapper only when feature/portmapper registers
one, and nothing in tailcat imported it; release builds also omitted it
with ts_omit_portmapper. So neither side ever asked its router for a
UPnP, NAT-PMP, or PCP mapping, and the only public endpoint a peer
advertised was its STUN-learned one.

That is enough when one side's NAT keeps the same public port for every
destination, but not when a client sits behind a NAT that allocates a
new public port per destination (common on hotel, office, and carrier
networks). The client's endpoint is unpredictable, and a server behind
an ordinary home router drops its packets because they arrive from a
port the server never sent to, so the connection stays on DERP for its
whole life. A router mapping accepts packets from any source, which
gives the server an endpoint such a client can reach directly.

Import feature/condregister/portmapper from the tailcat package, as
tsnet does, and keep portmapper in releaseKeep so ts_omit_portmapper
drops out of build-tags.txt and .goreleaser.yaml. The wasm build still
omits it: a browser has no UDP sockets. TS_DISABLE_PORTMAPPER=1 and
TS_DISABLE_UPNP=1 turn it off at runtime.

The new test runs go list on cmd/tailcat with ReleaseTags instead of
checking the hook in the test binary, which links the port mapper on
its own through feature/condregister.

Updates tailscale#143

Signed-off-by: Pedram Amini <pedram.amini@gmail.com>

This branch has not been deployed

No deployments
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.

1 participant