From 3e75c20221d2db41fc57cf07a1f91a8d821565cb Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 20 Oct 2026 20:05:40 -0500 Subject: [PATCH] tailcat, internal/buildtags: link the port mapper in native builds 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 #143 Signed-off-by: Pedram Amini --- .goreleaser.yaml | 2 +- CHANGELOG.md | 11 +++++++ README.md | 16 ++++++--- build-tags.txt | 2 +- internal/buildtags/buildtags.go | 10 ++++-- internal/buildtags/buildtags_test.go | 4 +-- internal/buildtags/deps_test.go | 49 ++++++++++++++++++++++++++++ portmapper.go | 20 ++++++++++++ 8 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 internal/buildtags/deps_test.go create mode 100644 portmapper.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bbf2cc537..1414fef96 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,7 +14,7 @@ builds: # internal/buildtags.ReleaseTags, which a test enforces. # Regenerate with: go run ./internal/buildtags/printtags flags: - - -tags=ts_omit_ace,ts_omit_acme,ts_omit_advertiseexitnode,ts_omit_advertiseroutes,ts_omit_appconnectors,ts_omit_aws,ts_omit_bird,ts_omit_cachenetmap,ts_omit_captiveportal,ts_omit_capture,ts_omit_cliconndiag,ts_omit_clientmetrics,ts_omit_clientupdate,ts_omit_cloud,ts_omit_colorable,ts_omit_completion,ts_omit_completion_scripts,ts_omit_conn25,ts_omit_connreject,ts_omit_debug,ts_omit_debugeventbus,ts_omit_debugportmapper,ts_omit_desktop_sessions,ts_omit_dns,ts_omit_dnsresolvecache,ts_omit_doctor,ts_omit_drive,ts_omit_favorites,ts_omit_flashappliance,ts_omit_health,ts_omit_hujsonconf,ts_omit_identityfederation,ts_omit_ipnbus,ts_omit_iptables,ts_omit_kube,ts_omit_linkspeed,ts_omit_linuxdnsfight,ts_omit_listenrawdisco,ts_omit_logtail,ts_omit_netlog,ts_omit_networkmanager,ts_omit_oauthkey,ts_omit_osrouter,ts_omit_outboundproxy,ts_omit_peerapiclient,ts_omit_peerapiserver,ts_omit_portlist,ts_omit_portmapper,ts_omit_posture,ts_omit_qrcodes,ts_omit_relayserver,ts_omit_remoteconfig,ts_omit_resolved,ts_omit_routecheck,ts_omit_runtimemetrics,ts_omit_sdnotify,ts_omit_serve,ts_omit_serviceclientprefs,ts_omit_synology,ts_omit_syslog,ts_omit_syspolicy,ts_omit_systray,ts_omit_taildrop,ts_omit_tailnetlock,ts_omit_tap,ts_omit_tpm,ts_omit_tundevstats,ts_omit_unixsocketidentity,ts_omit_useexitnode,ts_omit_useproxy,ts_omit_usermetrics,ts_omit_useroutes,ts_omit_wakeonlan,ts_omit_webbrowser,ts_omit_webclient + - -tags=ts_omit_ace,ts_omit_acme,ts_omit_advertiseexitnode,ts_omit_advertiseroutes,ts_omit_appconnectors,ts_omit_aws,ts_omit_bird,ts_omit_cachenetmap,ts_omit_captiveportal,ts_omit_capture,ts_omit_cliconndiag,ts_omit_clientmetrics,ts_omit_clientupdate,ts_omit_cloud,ts_omit_colorable,ts_omit_completion,ts_omit_completion_scripts,ts_omit_conn25,ts_omit_connreject,ts_omit_debug,ts_omit_debugeventbus,ts_omit_debugportmapper,ts_omit_desktop_sessions,ts_omit_dns,ts_omit_dnsresolvecache,ts_omit_doctor,ts_omit_drive,ts_omit_favorites,ts_omit_flashappliance,ts_omit_health,ts_omit_hujsonconf,ts_omit_identityfederation,ts_omit_ipnbus,ts_omit_iptables,ts_omit_kube,ts_omit_linkspeed,ts_omit_linuxdnsfight,ts_omit_listenrawdisco,ts_omit_logtail,ts_omit_netlog,ts_omit_networkmanager,ts_omit_oauthkey,ts_omit_osrouter,ts_omit_outboundproxy,ts_omit_peerapiclient,ts_omit_peerapiserver,ts_omit_portlist,ts_omit_posture,ts_omit_qrcodes,ts_omit_relayserver,ts_omit_remoteconfig,ts_omit_resolved,ts_omit_routecheck,ts_omit_runtimemetrics,ts_omit_sdnotify,ts_omit_serve,ts_omit_serviceclientprefs,ts_omit_synology,ts_omit_syslog,ts_omit_syspolicy,ts_omit_systray,ts_omit_taildrop,ts_omit_tailnetlock,ts_omit_tap,ts_omit_tpm,ts_omit_tundevstats,ts_omit_unixsocketidentity,ts_omit_useexitnode,ts_omit_useproxy,ts_omit_usermetrics,ts_omit_useroutes,ts_omit_wakeonlan,ts_omit_webbrowser,ts_omit_webclient goos: - linux - windows diff --git a/CHANGELOG.md b/CHANGELOG.md index b87d718e3..c40d8ba4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Native builds now include Tailscale's port mapper: each side asks + its local router for a public UDP port mapping over UPnP IGD, + NAT-PMP, or PCP, and advertises that endpoint alongside its STUN and + local ones. A peer behind a NAT that allocates a new public port per + destination (common on hotel, office, and carrier networks) can now + get a direct path to a peer behind an ordinary home router, where it + previously stayed relayed over DERP for the whole connection. Set + `TS_DISABLE_PORTMAPPER=1` to turn it off, or `TS_DISABLE_UPNP=1` to + turn off only UPnP. The wasm build is unchanged. It adds about 350 KB + (1.9%) to the stripped linux/amd64 binary. + ([#143](https://github.com/tailscale/tailcat/issues/143)) - **Breaking Go API change:** `Server.AllowedClients` and `Server.AddAllowedClient` are replaced by the `Server.AllowClient` hook, a `func(key.NodePublic) bool` asked about each client as it diff --git a/README.md b/README.md index 3082f93ef..ceabc0d0c 100644 --- a/README.md +++ b/README.md @@ -731,7 +731,9 @@ without the control plane. implementation for encrypting all tunnel traffic. It doesn't use a kernel TUN/TAP device (nor does it configure any networking routes or DNS settings), so `root` isn't required. - **magicsock** -- Tailscale's transport layer that multiplexes traffic over direct UDP and DERP relays. It handles STUN-based endpoint - discovery and UDP hole-punching for NAT traversal. + discovery and UDP hole-punching for NAT traversal, and asks the + local router for a public port mapping (UPnP IGD, NAT-PMP, or PCP) + when the router offers one. - **Netstack** (gVisor) -- a userspace TCP/IP stack that terminates TCP connections inside the process. This is what lets Tailcat accept inbound connections and dial outbound ones without any OS @@ -767,9 +769,15 @@ without the control plane. encrypted traffic can flow. 5. **NAT traversal.** In parallel, each side advertises its UDP - endpoints (public IP:port learned via STUN, plus local interface - addresses) to the other in disco call-me-maybe messages over DERP, - re-advertising whenever they change. Both sides then run Tailscale's + endpoints (public IP:port learned via STUN, a port mapped on the + local router via UPnP IGD, NAT-PMP, or PCP when the router allows + it, plus local interface addresses) to the other in disco + call-me-maybe messages over DERP, re-advertising whenever they + change. A mapped port accepts packets from any source, so it gives + a direct path to a peer behind a NAT that allocates a new public + port per destination, where STUN endpoints alone fail. Set + `TS_DISABLE_PORTMAPPER=1` to skip port mapping, or + `TS_DISABLE_UPNP=1` to skip only UPnP. Both sides then run Tailscale's disco protocol and attempt UDP hole-punching. If successful, traffic upgrades from the DERP relay to a direct peer-to-peer path. If hole-punching fails, DERP continues as a diff --git a/build-tags.txt b/build-tags.txt index 67ede1108..7516d6c0b 100644 --- a/build-tags.txt +++ b/build-tags.txt @@ -1 +1 @@ -ts_omit_ace,ts_omit_acme,ts_omit_advertiseexitnode,ts_omit_advertiseroutes,ts_omit_appconnectors,ts_omit_aws,ts_omit_bird,ts_omit_cachenetmap,ts_omit_captiveportal,ts_omit_capture,ts_omit_cliconndiag,ts_omit_clientmetrics,ts_omit_clientupdate,ts_omit_cloud,ts_omit_colorable,ts_omit_completion,ts_omit_completion_scripts,ts_omit_conn25,ts_omit_connreject,ts_omit_debug,ts_omit_debugeventbus,ts_omit_debugportmapper,ts_omit_desktop_sessions,ts_omit_dns,ts_omit_dnsresolvecache,ts_omit_doctor,ts_omit_drive,ts_omit_favorites,ts_omit_flashappliance,ts_omit_health,ts_omit_hujsonconf,ts_omit_identityfederation,ts_omit_ipnbus,ts_omit_iptables,ts_omit_kube,ts_omit_linkspeed,ts_omit_linuxdnsfight,ts_omit_listenrawdisco,ts_omit_logtail,ts_omit_netlog,ts_omit_networkmanager,ts_omit_oauthkey,ts_omit_osrouter,ts_omit_outboundproxy,ts_omit_peerapiclient,ts_omit_peerapiserver,ts_omit_portlist,ts_omit_portmapper,ts_omit_posture,ts_omit_qrcodes,ts_omit_relayserver,ts_omit_remoteconfig,ts_omit_resolved,ts_omit_routecheck,ts_omit_runtimemetrics,ts_omit_sdnotify,ts_omit_serve,ts_omit_serviceclientprefs,ts_omit_synology,ts_omit_syslog,ts_omit_syspolicy,ts_omit_systray,ts_omit_taildrop,ts_omit_tailnetlock,ts_omit_tap,ts_omit_tpm,ts_omit_tundevstats,ts_omit_unixsocketidentity,ts_omit_useexitnode,ts_omit_useproxy,ts_omit_usermetrics,ts_omit_useroutes,ts_omit_wakeonlan,ts_omit_webbrowser,ts_omit_webclient +ts_omit_ace,ts_omit_acme,ts_omit_advertiseexitnode,ts_omit_advertiseroutes,ts_omit_appconnectors,ts_omit_aws,ts_omit_bird,ts_omit_cachenetmap,ts_omit_captiveportal,ts_omit_capture,ts_omit_cliconndiag,ts_omit_clientmetrics,ts_omit_clientupdate,ts_omit_cloud,ts_omit_colorable,ts_omit_completion,ts_omit_completion_scripts,ts_omit_conn25,ts_omit_connreject,ts_omit_debug,ts_omit_debugeventbus,ts_omit_debugportmapper,ts_omit_desktop_sessions,ts_omit_dns,ts_omit_dnsresolvecache,ts_omit_doctor,ts_omit_drive,ts_omit_favorites,ts_omit_flashappliance,ts_omit_health,ts_omit_hujsonconf,ts_omit_identityfederation,ts_omit_ipnbus,ts_omit_iptables,ts_omit_kube,ts_omit_linkspeed,ts_omit_linuxdnsfight,ts_omit_listenrawdisco,ts_omit_logtail,ts_omit_netlog,ts_omit_networkmanager,ts_omit_oauthkey,ts_omit_osrouter,ts_omit_outboundproxy,ts_omit_peerapiclient,ts_omit_peerapiserver,ts_omit_portlist,ts_omit_posture,ts_omit_qrcodes,ts_omit_relayserver,ts_omit_remoteconfig,ts_omit_resolved,ts_omit_routecheck,ts_omit_runtimemetrics,ts_omit_sdnotify,ts_omit_serve,ts_omit_serviceclientprefs,ts_omit_synology,ts_omit_syslog,ts_omit_syspolicy,ts_omit_systray,ts_omit_taildrop,ts_omit_tailnetlock,ts_omit_tap,ts_omit_tpm,ts_omit_tundevstats,ts_omit_unixsocketidentity,ts_omit_useexitnode,ts_omit_useproxy,ts_omit_usermetrics,ts_omit_useroutes,ts_omit_wakeonlan,ts_omit_webbrowser,ts_omit_webclient diff --git a/internal/buildtags/buildtags.go b/internal/buildtags/buildtags.go index 0c9c7b8f5..153bc026b 100644 --- a/internal/buildtags/buildtags.go +++ b/internal/buildtags/buildtags.go @@ -54,8 +54,13 @@ var wasmKeep = []featuretags.FeatureTag{ // without them magicsock is DERP-only, and direct paths between peers // are the whole point of tailcat. The wasm build omits both: a browser // has no UDP sockets, so all of its traffic is relayed over DERP -// regardless. Note that featuretags.Requires pulls in ssh's c2n and -// dbus dependencies too. +// regardless. Native builds also keep portmapper (UPnP IGD, NAT-PMP, +// and PCP), so a peer behind a home router asks it for a public port +// mapping and advertises that endpoint; it is what lets a peer behind a +// NAT that allocates a new public port per destination get a direct +// path to it instead of staying on DERP. The wasm build omits it along +// with udptransport. Note that featuretags.Requires pulls in ssh's c2n +// and dbus dependencies too. var releaseKeep = []featuretags.FeatureTag{ "netstack", "ssh", @@ -63,6 +68,7 @@ var releaseKeep = []featuretags.FeatureTag{ "bakedroots", "androidbin", "nattraversal", + "portmapper", } // WasmTags returns the comma-joined -tags value for the wasm build, diff --git a/internal/buildtags/buildtags_test.go b/internal/buildtags/buildtags_test.go index c38db8562..dafc41736 100644 --- a/internal/buildtags/buildtags_test.go +++ b/internal/buildtags/buildtags_test.go @@ -42,14 +42,14 @@ func TestTags(t *testing.T) { t.Errorf("ReleaseTags contains %q; see the comment in buildtags.go", notWant) } } - for _, notWant := range []string{"ts_omit_netstack", "ts_omit_ssh", "ts_omit_gro", "ts_omit_c2n", "ts_omit_dbus", "ts_omit_udptransport", "ts_omit_nattraversal"} { + for _, notWant := range []string{"ts_omit_netstack", "ts_omit_ssh", "ts_omit_gro", "ts_omit_c2n", "ts_omit_dbus", "ts_omit_udptransport", "ts_omit_nattraversal", "ts_omit_portmapper"} { if release[notWant] { t.Errorf("ReleaseTags contains %q; that feature must stay linked in release builds", notWant) } } wasm := tagSet(t, WasmTags()) - for _, want := range []string{"ts_omit_ssh", "ts_omit_gro", "ts_omit_udptransport", "ts_omit_nattraversal"} { + for _, want := range []string{"ts_omit_ssh", "ts_omit_gro", "ts_omit_udptransport", "ts_omit_nattraversal", "ts_omit_portmapper"} { if !wasm[want] { t.Errorf("WasmTags missing %q", want) } diff --git a/internal/buildtags/deps_test.go b/internal/buildtags/deps_test.go new file mode 100644 index 000000000..485d4c583 --- /dev/null +++ b/internal/buildtags/deps_test.go @@ -0,0 +1,49 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package buildtags + +import ( + "os" + "os/exec" + "slices" + "strings" + "testing" +) + +// listDeps returns the import paths that pkg links when built for +// goos/goarch with the given tags, as reported by go list. +func listDeps(t *testing.T, goos, goarch, tags, pkg string) []string { + t.Helper() + cmd := exec.Command("go", "list", "-deps", "-tags", tags, pkg) + cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch) + out, err := cmd.Output() + if err != nil { + var stderr []byte + if ee, ok := err.(*exec.ExitError); ok { + stderr = ee.Stderr + } + t.Fatalf("go list -deps %s: %v\n%s", pkg, err, stderr) + } + return strings.Fields(string(out)) +} + +// TestReleaseLinksPortMapper checks the dependency graph of the binaries +// themselves, not the test binary: tests link the port mapper on their +// own through tailscale.com/feature/condregister, so only go list shows +// whether a release build of cmd/tailcat has it. magicsock silently runs +// without a port mapper when nothing registers one. +func TestReleaseLinksPortMapper(t *testing.T) { + if testing.Short() { + t.Skip("runs go list") + } + const portmapper = "tailscale.com/feature/portmapper" + native := listDeps(t, "linux", "amd64", ReleaseTags(), "github.com/tailscale/tailcat/cmd/tailcat") + if !slices.Contains(native, portmapper) { + t.Errorf("release build of cmd/tailcat does not link %s", portmapper) + } + wasm := listDeps(t, "js", "wasm", WasmTags(), "github.com/tailscale/tailcat/cmd/tailcat-web") + if slices.Contains(wasm, portmapper) { + t.Errorf("wasm build of cmd/tailcat-web links %s; a browser has no UDP sockets to map", portmapper) + } +} diff --git a/portmapper.go b/portmapper.go new file mode 100644 index 000000000..c70dbf71d --- /dev/null +++ b/portmapper.go @@ -0,0 +1,20 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package tailcat + +// The port mapper asks the local router for a public UDP port mapping +// (UPnP IGD, NAT-PMP, or PCP) and adds it to the endpoints magicsock +// advertises. Without it, a peer behind a NAT that allocates a new +// public port per destination (a "hard" NAT, common on hotel, office, +// and carrier networks) can reach a peer behind an ordinary home +// router only over DERP: neither side's STUN-learned endpoint is +// reachable from the other, so hole punching fails. With a mapping, +// the home side has an endpoint that accepts packets from any source, +// and the path goes direct. +// +// magicsock only creates a port mapper when this package registers +// one; it doesn't need to be linked by any other path. The import is +// a no-op under the ts_omit_portmapper build tag, which the wasm build +// sets: a browser has no UDP sockets, so it has nothing to map. +import _ "tailscale.com/feature/condregister/portmapper"