feature/portmapper: make the portmapper & its debugging tools modular

Starting at a minimal binary and adding one feature back...
    tailscaled tailscale combined (linux/amd64)
     30073135  17451704  31543692 omitting everything
    +  480302 +   10258 +  493896 .. add debugportmapper
    +  475317 +  151943 +  467660 .. add portmapper
    +  500086 +  162873 +  510511 .. add portmapper+debugportmapper

Fixes #17148

Change-Id: I90bd0e9d1bd8cbe64fa2e885e9afef8fb5ee74b1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
pull/17160/head
Brad Fitzpatrick 3 months ago committed by Brad Fitzpatrick
parent 2b0f59cd38
commit 99b3f69126

@ -0,0 +1,84 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debugportmapper
package local
import (
"cmp"
"context"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strconv"
"time"
"tailscale.com/client/tailscale/apitype"
)
// DebugPortmapOpts contains options for the [Client.DebugPortmap] command.
type DebugPortmapOpts struct {
// Duration is how long the mapping should be created for. It defaults
// to 5 seconds if not set.
Duration time.Duration
// Type is the kind of portmap to debug. The empty string instructs the
// portmap client to perform all known types. Other valid options are
// "pmp", "pcp", and "upnp".
Type string
// GatewayAddr specifies the gateway address used during portmapping.
// If set, SelfAddr must also be set. If unset, it will be
// autodetected.
GatewayAddr netip.Addr
// SelfAddr specifies the gateway address used during portmapping. If
// set, GatewayAddr must also be set. If unset, it will be
// autodetected.
SelfAddr netip.Addr
// LogHTTP instructs the debug-portmap endpoint to print all HTTP
// requests and responses made to the logs.
LogHTTP bool
}
// DebugPortmap invokes the debug-portmap endpoint, and returns an
// io.ReadCloser that can be used to read the logs that are printed during this
// process.
//
// opts can be nil; if so, default values will be used.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
opts = &DebugPortmapOpts{}
}
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
vals.Set("type", opts.Type)
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
if opts.GatewayAddr.IsValid() != opts.SelfAddr.IsValid() {
return nil, fmt.Errorf("both GatewayAddr and SelfAddr must be provided if one is")
} else if opts.GatewayAddr.IsValid() {
vals.Set("gateway_and_self", fmt.Sprintf("%s/%s", opts.GatewayAddr, opts.SelfAddr))
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, nil
}

@ -591,70 +591,6 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
return x, nil
}
// DebugPortmapOpts contains options for the [Client.DebugPortmap] command.
type DebugPortmapOpts struct {
// Duration is how long the mapping should be created for. It defaults
// to 5 seconds if not set.
Duration time.Duration
// Type is the kind of portmap to debug. The empty string instructs the
// portmap client to perform all known types. Other valid options are
// "pmp", "pcp", and "upnp".
Type string
// GatewayAddr specifies the gateway address used during portmapping.
// If set, SelfAddr must also be set. If unset, it will be
// autodetected.
GatewayAddr netip.Addr
// SelfAddr specifies the gateway address used during portmapping. If
// set, GatewayAddr must also be set. If unset, it will be
// autodetected.
SelfAddr netip.Addr
// LogHTTP instructs the debug-portmap endpoint to print all HTTP
// requests and responses made to the logs.
LogHTTP bool
}
// DebugPortmap invokes the debug-portmap endpoint, and returns an
// io.ReadCloser that can be used to read the logs that are printed during this
// process.
//
// opts can be nil; if so, default values will be used.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
opts = &DebugPortmapOpts{}
}
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
vals.Set("type", opts.Type)
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
if opts.GatewayAddr.IsValid() != opts.SelfAddr.IsValid() {
return nil, fmt.Errorf("both GatewayAddr and SelfAddr must be provided if one is")
} else if opts.GatewayAddr.IsValid() {
vals.Set("gateway_and_self", fmt.Sprintf("%s/%s", opts.GatewayAddr, opts.SelfAddr))
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, nil
}
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
// The schema (including when keys are re-read) is not a stable interface.
func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) error {

@ -32,11 +32,6 @@ type IPNBusWatcher = local.IPNBusWatcher
// Deprecated: import [tailscale.com/client/local] instead.
type BugReportOpts = local.BugReportOpts
// DebugPortmapOpts is an alias for [tailscale.com/client/local.DebugPortmapOpts].
//
// Deprecated: import [tailscale.com/client/local] instead.
type DebugPortmapOpts = local.DebugPortmapOpts
// PingOpts is an alias for [tailscale.com/client/local.PingOpts].
//
// Deprecated: import [tailscale.com/client/local] instead.

@ -798,7 +798,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+
@ -866,7 +868,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/packet from tailscale.com/net/connstats+
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/ipn/localapi+
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock

@ -22,9 +22,9 @@ import (
var (
cacheDir = flag.String("cachedir", "", "if non-empty, use this directory to store cached size results to speed up subsequent runs. The tool does not consider the git status when deciding whether to use the cache. It's on you to nuke it between runs if the tree changed.")
features = flag.String("features", "", "comma-separated list of features to consider, with or without the ts_omit_ prefix")
features = flag.String("features", "", "comma-separated list of features to list in the table, with or without the ts_omit_ prefix. It may also contain a '+' sign(s) for ANDing features together. If empty, all omittable features are considered one at a time.")
showRemovals = flag.Bool("show-removals", false, "if true, show a table of sizes removing one feature at a time from the full set")
showRemovals = flag.Bool("show-removals", false, "if true, show a table of sizes removing one feature at a time from the full set.")
)
func main() {
@ -43,10 +43,14 @@ func main() {
all = slices.Clone(allOmittable)
} else {
for v := range strings.SplitSeq(*features, ",") {
if !strings.HasPrefix(v, "ts_omit_") {
v = "ts_omit_" + v
var withOmit []string
for v := range strings.SplitSeq(v, "+") {
if !strings.HasPrefix(v, "ts_omit_") {
v = "ts_omit_" + v
}
withOmit = append(withOmit, v)
}
all = append(all, v)
all = append(all, strings.Join(withOmit, "+"))
}
}
@ -70,6 +74,9 @@ func main() {
fmt.Printf("-%8d -%8d -%8d omit-all\n", baseD-minD, baseC-minC, baseBoth-minBoth)
for _, t := range all {
if strings.Contains(t, "+") {
log.Fatalf("TODO: make --show-removals support ANDed features like %q", t)
}
sizeD := measure("tailscaled", t)
sizeC := measure("tailscale", t)
sizeBoth := measure("tailscaled", append([]string{t}, "ts_include_cli")...)
@ -84,17 +91,17 @@ func main() {
fmt.Printf("%9s %9s %9s\n", "tailscaled", "tailscale", "combined (linux/amd64)")
fmt.Printf("%9d %9d %9d omitting everything\n", minD, minC, minBoth)
for _, t := range all {
tags := allExcept(allOmittable, t)
tags := allExcept(allOmittable, strings.Split(t, "+"))
sizeD := measure("tailscaled", tags...)
sizeC := measure("tailscale", tags...)
sizeBoth := measure("tailscaled", append(tags, "ts_include_cli")...)
fmt.Printf("+%8d +%8d +%8d .. add %s\n", max(sizeD-minD, 0), max(sizeC-minC, 0), max(sizeBoth-minBoth, 0), strings.TrimPrefix(t, "ts_omit_"))
fmt.Printf("+%8d +%8d +%8d .. add %s\n", max(sizeD-minD, 0), max(sizeC-minC, 0), max(sizeBoth-minBoth, 0), strings.ReplaceAll(t, "ts_omit_", ""))
}
}
func allExcept(all []string, omit string) []string {
return slices.DeleteFunc(slices.Clone(all), func(s string) bool { return s == omit })
func allExcept(all, omit []string) []string {
return slices.DeleteFunc(slices.Clone(all), func(s string) bool { return slices.Contains(omit, s) })
}
func measure(bin string, tags ...string) int64 {

@ -0,0 +1,79 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !ts_omit_debugportmapper
package cli
import (
"context"
"flag"
"fmt"
"io"
"net/netip"
"os"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/local"
)
func init() {
debugPortmapCmd = mkDebugPortmapCmd
}
func mkDebugPortmapCmd() *ffcli.Command {
return &ffcli.Command{
Name: "portmap",
ShortUsage: "tailscale debug portmap",
Exec: debugPortmap,
ShortHelp: "Run portmap debugging",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("portmap")
fs.DurationVar(&debugPortmapArgs.duration, "duration", 5*time.Second, "timeout for port mapping")
fs.StringVar(&debugPortmapArgs.ty, "type", "", `portmap debug type (one of "", "pmp", "pcp", or "upnp")`)
fs.StringVar(&debugPortmapArgs.gatewayAddr, "gateway-addr", "", `override gateway IP (must also pass --self-addr)`)
fs.StringVar(&debugPortmapArgs.selfAddr, "self-addr", "", `override self IP (must also pass --gateway-addr)`)
fs.BoolVar(&debugPortmapArgs.logHTTP, "log-http", false, `print all HTTP requests and responses to the log`)
return fs
})(),
}
}
var debugPortmapArgs struct {
duration time.Duration
gatewayAddr string
selfAddr string
ty string
logHTTP bool
}
func debugPortmap(ctx context.Context, args []string) error {
opts := &local.DebugPortmapOpts{
Duration: debugPortmapArgs.duration,
Type: debugPortmapArgs.ty,
LogHTTP: debugPortmapArgs.logHTTP,
}
if (debugPortmapArgs.gatewayAddr != "") != (debugPortmapArgs.selfAddr != "") {
return fmt.Errorf("if one of --gateway-addr and --self-addr is provided, the other must be as well")
}
if debugPortmapArgs.gatewayAddr != "" {
var err error
opts.GatewayAddr, err = netip.ParseAddr(debugPortmapArgs.gatewayAddr)
if err != nil {
return fmt.Errorf("invalid --gateway-addr: %w", err)
}
opts.SelfAddr, err = netip.ParseAddr(debugPortmapArgs.selfAddr)
if err != nil {
return fmt.Errorf("invalid --self-addr: %w", err)
}
}
rc, err := localClient.DebugPortmap(ctx, opts)
if err != nil {
return err
}
defer rc.Close()
_, err = io.Copy(os.Stdout, rc)
return err
}

@ -30,7 +30,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/net/http/httpproxy"
"golang.org/x/net/http2"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlhttp"
"tailscale.com/hostinfo"
@ -50,6 +49,7 @@ import (
var (
debugCaptureCmd func() *ffcli.Command // or nil
debugPortmapCmd func() *ffcli.Command // or nil
)
func debugCmd() *ffcli.Command {
@ -319,21 +319,7 @@ func debugCmd() *ffcli.Command {
ShortHelp: "Test a DERP configuration",
},
ccall(debugCaptureCmd),
{
Name: "portmap",
ShortUsage: "tailscale debug portmap",
Exec: debugPortmap,
ShortHelp: "Run portmap debugging",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("portmap")
fs.DurationVar(&debugPortmapArgs.duration, "duration", 5*time.Second, "timeout for port mapping")
fs.StringVar(&debugPortmapArgs.ty, "type", "", `portmap debug type (one of "", "pmp", "pcp", or "upnp")`)
fs.StringVar(&debugPortmapArgs.gatewayAddr, "gateway-addr", "", `override gateway IP (must also pass --self-addr)`)
fs.StringVar(&debugPortmapArgs.selfAddr, "self-addr", "", `override self IP (must also pass --gateway-addr)`)
fs.BoolVar(&debugPortmapArgs.logHTTP, "log-http", false, `print all HTTP requests and responses to the log`)
return fs
})(),
},
ccall(debugPortmapCmd),
{
Name: "peer-endpoint-changes",
ShortUsage: "tailscale debug peer-endpoint-changes <hostname-or-IP>",
@ -1210,44 +1196,6 @@ func runSetExpire(ctx context.Context, args []string) error {
return localClient.DebugSetExpireIn(ctx, setExpireArgs.in)
}
var debugPortmapArgs struct {
duration time.Duration
gatewayAddr string
selfAddr string
ty string
logHTTP bool
}
func debugPortmap(ctx context.Context, args []string) error {
opts := &local.DebugPortmapOpts{
Duration: debugPortmapArgs.duration,
Type: debugPortmapArgs.ty,
LogHTTP: debugPortmapArgs.logHTTP,
}
if (debugPortmapArgs.gatewayAddr != "") != (debugPortmapArgs.selfAddr != "") {
return fmt.Errorf("if one of --gateway-addr and --self-addr is provided, the other must be as well")
}
if debugPortmapArgs.gatewayAddr != "" {
var err error
opts.GatewayAddr, err = netip.ParseAddr(debugPortmapArgs.gatewayAddr)
if err != nil {
return fmt.Errorf("invalid --gateway-addr: %w", err)
}
opts.SelfAddr, err = netip.ParseAddr(debugPortmapArgs.selfAddr)
if err != nil {
return fmt.Errorf("invalid --self-addr: %w", err)
}
}
rc, err := localClient.DebugPortmap(ctx, opts)
if err != nil {
return err
}
defer rc.Close()
_, err = io.Copy(os.Stdout, rc)
return err
}
func runPeerEndpointChanges(ctx context.Context, args []string) error {
st, err := localClient.Status(ctx)
if err != nil {

@ -17,14 +17,23 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/net/netcheck"
"tailscale.com/net/netmon"
"tailscale.com/net/portmapper"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/net/tlsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
// The "netcheck" command also wants the portmapper linked.
//
// TODO: make that subcommand either hit LocalAPI for that info, or use a
// tailscaled subcommand, to avoid making the CLI also link in the portmapper.
// For now (2025-09-15), keep doing what we've done for the past five years and
// keep linking it here.
_ "tailscale.com/feature/condregister/portmapper"
)
var netcheckCmd = &ffcli.Command{
@ -56,14 +65,13 @@ func runNetcheck(ctx context.Context, args []string) error {
return err
}
// Ensure that we close the portmapper after running a netcheck; this
// will release any port mappings created.
pm := portmapper.NewClient(portmapper.Config{
Logf: logf,
NetMon: netMon,
EventBus: bus,
})
defer pm.Close()
var pm portmappertype.Client
if buildfeatures.HasPortMapper {
// Ensure that we close the portmapper after running a netcheck; this
// will release any port mappings created.
pm = portmappertype.HookNewPortMapper.Get()(logf, bus, netMon, nil, nil)
defer pm.Close()
}
c := &netcheck.Client{
NetMon: netMon,
@ -210,6 +218,9 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
}
func portMapping(r *netcheck.Report) string {
if !buildfeatures.HasPortMapper {
return "binary built without portmapper support"
}
if !r.AnyPortMappingChecked() {
return "not checked"
}

@ -96,7 +96,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlhttp/controlhttpcommon from tailscale.com/control/controlhttp
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derpconst from tailscale.com/derp+
tailscale.com/derp/derphttp from tailscale.com/net/netcheck
@ -105,7 +104,10 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web
tailscale.com/feature from tailscale.com/tsweb+
tailscale.com/feature/buildfeatures from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/capture/dissector from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/condregister/portmapper from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
@ -131,7 +133,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/net/netutil from tailscale.com/client/local+
tailscale.com/net/netx from tailscale.com/control/controlhttp+
tailscale.com/net/ping from tailscale.com/net/netcheck
tailscale.com/net/portmapper from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
tailscale.com/net/stun from tailscale.com/net/netcheck
L tailscale.com/net/tcpinfo from tailscale.com/derp
@ -175,7 +178,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/eventbus from tailscale.com/net/portmapper+
tailscale.com/util/eventbus from tailscale.com/client/local+
tailscale.com/util/groupmember from tailscale.com/client/web
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+
@ -351,7 +354,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
encoding/xml from github.com/tailscale/goupnp+
encoding/xml from github.com/godbus/dbus/v5/introspect+
errors from archive/tar+
expvar from tailscale.com/derp+
flag from github.com/peterbourgon/ff/v3+

@ -272,10 +272,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/feature/wakeonlan+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/capture from tailscale.com/feature/condregister
tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled
tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister
tailscale.com/feature/debugportmapper from tailscale.com/feature/condregister
tailscale.com/feature/drive from tailscale.com/feature/condregister
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
@ -338,7 +341,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/packet from tailscale.com/net/connstats+
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/ipn/localapi+
tailscale.com/net/portmapper from tailscale.com/feature/portmapper+
tailscale.com/net/portmapper/portmappertype from tailscale.com/feature/portmapper+
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+

@ -90,3 +90,21 @@ func TestOmitTailnetLock(t *testing.T) {
},
}.Check(t)
}
func TestOmitPortmapper(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_portmapper,ts_include_cli,ts_omit_debugportmapper",
OnDep: func(dep string) {
if dep == "tailscale.com/net/portmapper" {
t.Errorf("unexpected dep with ts_omit_portmapper: %q", dep)
return
}
if strings.Contains(dep, "goupnp") || strings.Contains(dep, "/soap") ||
strings.Contains(dep, "internetgateway2") {
t.Errorf("unexpected dep with ts_omit_portmapper: %q", dep)
}
},
}.Check(t)
}

@ -239,7 +239,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+
@ -295,7 +297,8 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/net/packet from tailscale.com/ipn/ipnlocal+
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/ipn/localapi+
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_debugportmapper
package buildfeatures
// HasDebugPortMapper is whether the binary was built with support for modular feature "portmapper debug support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_debugportmapper" build tag.
// It's a const so it can be used for dead code elimination.
const HasDebugPortMapper = false

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_debugportmapper
package buildfeatures
// HasDebugPortMapper is whether the binary was built with support for modular feature "portmapper debug support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_debugportmapper" build tag.
// It's a const so it can be used for dead code elimination.
const HasDebugPortMapper = true

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_portmapper
package buildfeatures
// HasPortMapper is whether the binary was built with support for modular feature "NAT-PMP/PCP/UPnP port mapping support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_portmapper" build tag.
// It's a const so it can be used for dead code elimination.
const HasPortMapper = false

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_portmapper
package buildfeatures
// HasPortMapper is whether the binary was built with support for modular feature "NAT-PMP/PCP/UPnP port mapping support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_portmapper" build tag.
// It's a const so it can be used for dead code elimination.
const HasPortMapper = true

@ -5,3 +5,10 @@
// by build tags. It is one central package that callers can empty import
// to ensure all conditional features are registered.
package condregister
// Portmapper is special in that the CLI also needs to link it in,
// so it's pulled out into its own package, rather than using a maybe_*.go
// file in condregister.
import (
_ "tailscale.com/feature/condregister/portmapper"
)

@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debugportmapper
package condregister
import _ "tailscale.com/feature/debugportmapper"

@ -0,0 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package portmapper registers support for portmapper
// if it's not disabled via the ts_omit_portmapper build tag.
package portmapper

@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_portmapper
package portmapper
import _ "tailscale.com/feature/portmapper"

@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package debugportmapper registers support for debugging Tailscale's
// portmapping support.
package debugportmapper
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"tailscale.com/ipn/localapi"
"tailscale.com/net/netmon"
"tailscale.com/net/portmapper"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
)
func init() {
localapi.Register("debug-portmap", serveDebugPortmap)
}
func serveDebugPortmap(h *localapi.Handler, w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/plain")
dur, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gwSelf := r.FormValue("gateway_and_self")
trueFunc := func() bool { return true }
// Update portmapper debug flags
debugKnobs := &portmapper.DebugKnobs{VerboseLogs: true}
switch r.FormValue("type") {
case "":
case "pmp":
debugKnobs.DisablePCPFunc = trueFunc
debugKnobs.DisableUPnPFunc = trueFunc
case "pcp":
debugKnobs.DisablePMPFunc = trueFunc
debugKnobs.DisableUPnPFunc = trueFunc
case "upnp":
debugKnobs.DisablePCPFunc = trueFunc
debugKnobs.DisablePMPFunc = trueFunc
default:
http.Error(w, "unknown portmap debug type", http.StatusBadRequest)
return
}
if k := h.LocalBackend().ControlKnobs(); k != nil {
if k.DisableUPnP.Load() {
debugKnobs.DisableUPnPFunc = trueFunc
}
}
if defBool(r.FormValue("log_http"), false) {
debugKnobs.LogHTTP = true
}
var (
logLock sync.Mutex
handlerDone bool
)
logf := func(format string, args ...any) {
if !strings.HasSuffix(format, "\n") {
format = format + "\n"
}
logLock.Lock()
defer logLock.Unlock()
// The portmapper can call this log function after the HTTP
// handler returns, which is not allowed and can cause a panic.
// If this happens, ignore the log lines since this typically
// occurs due to a client disconnect.
if handlerDone {
return
}
// Write and flush each line to the client so that output is streamed
fmt.Fprintf(w, format, args...)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
defer func() {
logLock.Lock()
handlerDone = true
logLock.Unlock()
}()
ctx, cancel := context.WithTimeout(r.Context(), dur)
defer cancel()
done := make(chan bool, 1)
var c *portmapper.Client
c = portmapper.NewClient(portmapper.Config{
Logf: logger.WithPrefix(logf, "portmapper: "),
NetMon: h.LocalBackend().NetMon(),
DebugKnobs: debugKnobs,
EventBus: h.LocalBackend().EventBus(),
OnChange: func() {
logf("portmapping changed.")
logf("have mapping: %v", c.HaveMapping())
if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("cb: mapping: %v", ext)
select {
case done <- true:
default:
}
return
}
logf("cb: no mapping")
},
})
defer c.Close()
bus := eventbus.New()
defer bus.Close()
netMon, err := netmon.New(bus, logger.WithPrefix(logf, "monitor: "))
if err != nil {
logf("error creating monitor: %v", err)
return
}
gatewayAndSelfIP := func() (gw, self netip.Addr, ok bool) {
if a, b, ok := strings.Cut(gwSelf, "/"); ok {
gw = netip.MustParseAddr(a)
self = netip.MustParseAddr(b)
return gw, self, true
}
return netMon.GatewayAndSelfIP()
}
c.SetGatewayLookupFunc(gatewayAndSelfIP)
gw, selfIP, ok := gatewayAndSelfIP()
if !ok {
logf("no gateway or self IP; %v", netMon.InterfaceState())
return
}
logf("gw=%v; self=%v", gw, selfIP)
uc, err := net.ListenPacket("udp", "0.0.0.0:0")
if err != nil {
return
}
defer uc.Close()
c.SetLocalPort(uint16(uc.LocalAddr().(*net.UDPAddr).Port))
res, err := c.Probe(ctx)
if err != nil {
logf("error in Probe: %v", err)
return
}
logf("Probe: %+v", res)
if !res.PCP && !res.PMP && !res.UPnP {
logf("no portmapping services available")
return
}
if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("mapping: %v", ext)
} else {
logf("no mapping")
}
select {
case <-done:
case <-ctx.Done():
if r.Context().Err() == nil {
logf("serveDebugPortmap: context done: %v", ctx.Err())
} else {
h.Logf("serveDebugPortmap: context done: %v", ctx.Err())
}
}
}
func defBool(a string, def bool) bool {
if a == "" {
return def
}
v, err := strconv.ParseBool(a)
if err != nil {
return def
}
return v
}

@ -48,9 +48,11 @@ var Features = map[FeatureTag]FeatureMeta{
"cli": {"CLI", "embed the CLI into the tailscaled binary"},
"completion": {"Completion", "CLI shell completion"},
"debugeventbus": {"DebugEventBus", "eventbus debug support"},
"debugportmapper": {"DebugPortMapper", "portmapper debug support"},
"desktop_sessions": {"DesktopSessions", "Desktop sessions support"},
"drive": {"Drive", "Tailscale Drive (file server) support"},
"kube": {"Kube", "Kubernetes integration"},
"portmapper": {"PortMapper", "NAT-PMP/PCP/UPnP port mapping support"},
"relayserver": {"RelayServer", "Relay server"},
"serve": {"Serve", "Serve and Funnel support"},
"ssh": {"SSH", "Tailscale SSH support"},

@ -0,0 +1,38 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package portmapper registers support for NAT-PMP, PCP, and UPnP port
// mapping protocols to help get direction connections through NATs.
package portmapper
import (
"tailscale.com/net/netmon"
"tailscale.com/net/portmapper"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
)
func init() {
portmappertype.HookNewPortMapper.Set(newPortMapper)
}
func newPortMapper(
logf logger.Logf,
bus *eventbus.Bus,
netMon *netmon.Monitor,
disableUPnPOrNil func() bool,
onlyTCP443OrNil func() bool) portmappertype.Client {
pm := portmapper.NewClient(portmapper.Config{
EventBus: bus,
Logf: logf,
NetMon: netMon,
DebugKnobs: &portmapper.DebugKnobs{
DisableAll: onlyTCP443OrNil,
DisableUPnPFunc: disableUPnPOrNil,
},
})
pm.SetGatewayLookupFunc(netMon.GatewayAndSelfIP)
return pm
}

@ -6780,6 +6780,11 @@ func (b *LocalBackend) ControlKnobs() *controlknobs.Knobs {
return b.sys.ControlKnobs()
}
// EventBus returns the node's event bus.
func (b *LocalBackend) EventBus() *eventbus.Bus {
return b.sys.Bus.Get()
}
// MagicConn returns the backend's *magicsock.Conn.
func (b *LocalBackend) MagicConn() *magicsock.Conn {
return b.sys.MagicSock.Get()

@ -34,6 +34,7 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
_ "tailscale.com/feature/condregister/portmapper"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"

@ -35,9 +35,7 @@ import (
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/logtail"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
"tailscale.com/net/portmapper"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/dnstype"
@ -90,7 +88,6 @@ var handler = map[string]LocalAPIHandler{
"debug-packet-filter-matches": (*Handler).serveDebugPacketFilterMatches,
"debug-packet-filter-rules": (*Handler).serveDebugPacketFilterRules,
"debug-peer-endpoint-changes": (*Handler).serveDebugPeerEndpointChanges,
"debug-portmap": (*Handler).serveDebugPortmap,
"derpmap": (*Handler).serveDERPMap,
"dev-set-state-store": (*Handler).serveDevSetStateStore,
"dial": (*Handler).serveDial,
@ -762,166 +759,6 @@ func (h *Handler) serveDebugPacketFilterMatches(w http.ResponseWriter, r *http.R
enc.Encode(nm.PacketFilter)
}
func (h *Handler) serveDebugPortmap(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/plain")
dur, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gwSelf := r.FormValue("gateway_and_self")
// Update portmapper debug flags
debugKnobs := &portmapper.DebugKnobs{VerboseLogs: true}
switch r.FormValue("type") {
case "":
case "pmp":
debugKnobs.DisablePCP = true
debugKnobs.DisableUPnP = true
case "pcp":
debugKnobs.DisablePMP = true
debugKnobs.DisableUPnP = true
case "upnp":
debugKnobs.DisablePCP = true
debugKnobs.DisablePMP = true
default:
http.Error(w, "unknown portmap debug type", http.StatusBadRequest)
return
}
if defBool(r.FormValue("log_http"), false) {
debugKnobs.LogHTTP = true
}
var (
logLock sync.Mutex
handlerDone bool
)
logf := func(format string, args ...any) {
if !strings.HasSuffix(format, "\n") {
format = format + "\n"
}
logLock.Lock()
defer logLock.Unlock()
// The portmapper can call this log function after the HTTP
// handler returns, which is not allowed and can cause a panic.
// If this happens, ignore the log lines since this typically
// occurs due to a client disconnect.
if handlerDone {
return
}
// Write and flush each line to the client so that output is streamed
fmt.Fprintf(w, format, args...)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
defer func() {
logLock.Lock()
handlerDone = true
logLock.Unlock()
}()
ctx, cancel := context.WithTimeout(r.Context(), dur)
defer cancel()
done := make(chan bool, 1)
var c *portmapper.Client
c = portmapper.NewClient(portmapper.Config{
Logf: logger.WithPrefix(logf, "portmapper: "),
NetMon: h.b.NetMon(),
DebugKnobs: debugKnobs,
ControlKnobs: h.b.ControlKnobs(),
EventBus: h.eventBus,
OnChange: func() {
logf("portmapping changed.")
logf("have mapping: %v", c.HaveMapping())
if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("cb: mapping: %v", ext)
select {
case done <- true:
default:
}
return
}
logf("cb: no mapping")
},
})
defer c.Close()
bus := eventbus.New()
defer bus.Close()
netMon, err := netmon.New(bus, logger.WithPrefix(logf, "monitor: "))
if err != nil {
logf("error creating monitor: %v", err)
return
}
gatewayAndSelfIP := func() (gw, self netip.Addr, ok bool) {
if a, b, ok := strings.Cut(gwSelf, "/"); ok {
gw = netip.MustParseAddr(a)
self = netip.MustParseAddr(b)
return gw, self, true
}
return netMon.GatewayAndSelfIP()
}
c.SetGatewayLookupFunc(gatewayAndSelfIP)
gw, selfIP, ok := gatewayAndSelfIP()
if !ok {
logf("no gateway or self IP; %v", netMon.InterfaceState())
return
}
logf("gw=%v; self=%v", gw, selfIP)
uc, err := net.ListenPacket("udp", "0.0.0.0:0")
if err != nil {
return
}
defer uc.Close()
c.SetLocalPort(uint16(uc.LocalAddr().(*net.UDPAddr).Port))
res, err := c.Probe(ctx)
if err != nil {
logf("error in Probe: %v", err)
return
}
logf("Probe: %+v", res)
if !res.PCP && !res.PMP && !res.UPnP {
logf("no portmapping services available")
return
}
if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("mapping: %v", ext)
} else {
logf("no mapping")
}
select {
case <-done:
case <-ctx.Done():
if r.Context().Err() == nil {
logf("serveDebugPortmap: context done: %v", ctx.Err())
} else {
h.logf("serveDebugPortmap: context done: %v", ctx.Err())
}
}
}
// EventError provides the JSON encoding of internal errors from event processing.
type EventError struct {
Error string

@ -33,7 +33,7 @@ import (
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/ping"
"tailscale.com/net/portmapper"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/net/sockstats"
"tailscale.com/net/stun"
"tailscale.com/syncs"
@ -215,7 +215,7 @@ type Client struct {
// PortMapper, if non-nil, is used for portmap queries.
// If nil, portmap discovery is not done.
PortMapper *portmapper.Client // lazily initialized on first use
PortMapper portmappertype.Client
// UseDNSCache controls whether this client should use a
// *dnscache.Resolver to resolve DERP hostnames, when no IP address is
@ -730,7 +730,7 @@ func (rs *reportState) probePortMapServices() {
res, err := rs.c.PortMapper.Probe(context.Background())
if err != nil {
if !errors.Is(err, portmapper.ErrGatewayRange) {
if !errors.Is(err, portmappertype.ErrGatewayRange) {
// "skipping portmap; gateway range likely lacks support"
// is not very useful, and too spammy on cloud systems.
// If there are other errors, we want to log those.

@ -14,7 +14,6 @@ import (
"sync/atomic"
"testing"
"tailscale.com/control/controlknobs"
"tailscale.com/net/netaddr"
"tailscale.com/net/netmon"
"tailscale.com/syncs"
@ -273,10 +272,9 @@ func newTestClient(t *testing.T, igd *TestIGD, bus *eventbus.Bus) *Client {
}
var c *Client
c = NewClient(Config{
Logf: tstest.WhileTestRunningLogger(t),
NetMon: netmon.NewStatic(),
ControlKnobs: new(controlknobs.Knobs),
EventBus: bus,
Logf: tstest.WhileTestRunningLogger(t),
NetMon: netmon.NewStatic(),
EventBus: bus,
OnChange: func() { // TODO(creachadair): Remove.
t.Logf("port map changed")
t.Logf("have mapping: %v", c.HaveMapping())

@ -8,7 +8,6 @@ package portmapper
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
@ -20,12 +19,12 @@ import (
"time"
"go4.org/mem"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/net/netaddr"
"tailscale.com/net/neterror"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/net/sockstats"
"tailscale.com/syncs"
"tailscale.com/types/logger"
@ -34,6 +33,13 @@ import (
"tailscale.com/util/eventbus"
)
var (
ErrNoPortMappingServices = portmappertype.ErrNoPortMappingServices
ErrGatewayRange = portmappertype.ErrGatewayRange
ErrGatewayIPv6 = portmappertype.ErrGatewayIPv6
ErrPortMappingDisabled = portmappertype.ErrPortMappingDisabled
)
var disablePortMapperEnv = envknob.RegisterBool("TS_DISABLE_PORTMAPPER")
// DebugKnobs contains debug configuration that can be provided when creating a
@ -49,15 +55,33 @@ type DebugKnobs struct {
LogHTTP bool
// Disable* disables a specific service from mapping.
DisableUPnP bool
DisablePMP bool
DisablePCP bool
// If the funcs are nil or return false, the service is not disabled.
// Use the corresponding accessor methods without the "Func" suffix
// to check whether a service is disabled.
DisableUPnPFunc func() bool
DisablePMPFunc func() bool
DisablePCPFunc func() bool
// DisableAll, if non-nil, is a func that reports whether all port
// mapping attempts should be disabled.
DisableAll func() bool
}
// DisableUPnP reports whether UPnP is disabled.
func (k *DebugKnobs) DisableUPnP() bool {
return k != nil && k.DisableUPnPFunc != nil && k.DisableUPnPFunc()
}
// DisablePMP reports whether NAT-PMP is disabled.
func (k *DebugKnobs) DisablePMP() bool {
return k != nil && k.DisablePMPFunc != nil && k.DisablePMPFunc()
}
// DisablePCP reports whether PCP is disabled.
func (k *DebugKnobs) DisablePCP() bool {
return k != nil && k.DisablePCPFunc != nil && k.DisablePCPFunc()
}
func (k *DebugKnobs) disableAll() bool {
if disablePortMapperEnv() {
return true
@ -88,11 +112,10 @@ type Client struct {
// The following two fields must both be non-nil.
// Both are immutable after construction.
pubClient *eventbus.Client
updates *eventbus.Publisher[Mapping]
updates *eventbus.Publisher[portmappertype.Mapping]
logf logger.Logf
netMon *netmon.Monitor // optional; nil means interfaces will be looked up on-demand
controlKnobs *controlknobs.Knobs
ipAndGateway func() (gw, ip netip.Addr, ok bool)
onChange func() // or nil
debug DebugKnobs
@ -130,6 +153,8 @@ type Client struct {
mapping mapping // non-nil if we have a mapping
}
var _ portmappertype.Client = (*Client)(nil)
func (c *Client) vlogf(format string, args ...any) {
if c.debug.VerboseLogs {
c.logf(format, args...)
@ -159,7 +184,6 @@ type mapping interface {
MappingDebug() string
}
// HaveMapping reports whether we have a current valid mapping.
func (c *Client) HaveMapping() bool {
c.mu.Lock()
defer c.mu.Unlock()
@ -223,10 +247,6 @@ type Config struct {
// debugging. If nil, a sensible set of defaults will be used.
DebugKnobs *DebugKnobs
// ControlKnobs, if non-nil, specifies knobs from the control plane that
// might disable port mapping.
ControlKnobs *controlknobs.Knobs
// OnChange is called to run in a new goroutine whenever the port mapping
// status has changed. If nil, no callback is issued.
OnChange func()
@ -246,10 +266,9 @@ func NewClient(c Config) *Client {
netMon: c.NetMon,
ipAndGateway: netmon.LikelyHomeRouterIP, // TODO(bradfitz): move this to method on netMon
onChange: c.OnChange,
controlKnobs: c.ControlKnobs,
}
ret.pubClient = c.EventBus.Client("portmapper")
ret.updates = eventbus.Publish[Mapping](ret.pubClient)
ret.updates = eventbus.Publish[portmappertype.Mapping](ret.pubClient)
if ret.logf == nil {
ret.logf = logger.Discard
}
@ -448,13 +467,6 @@ func IsNoMappingError(err error) bool {
return ok
}
var (
ErrNoPortMappingServices = errors.New("no port mapping services were found")
ErrGatewayRange = errors.New("skipping portmap; gateway range likely lacks support")
ErrGatewayIPv6 = errors.New("skipping portmap; no IPv6 support for portmapping")
ErrPortMappingDisabled = errors.New("port mapping is disabled")
)
// GetCachedMappingOrStartCreatingOne quickly returns with our current cached portmapping, if any.
// If there's not one, it starts up a background goroutine to create one.
// If the background goroutine ends up creating one, the onChange hook registered with the
@ -512,7 +524,7 @@ func (c *Client) createMapping() {
// the control flow to eliminate that possibility. Meanwhile, this
// mitigates a panic downstream, cf. #16662.
}
c.updates.Publish(Mapping{
c.updates.Publish(portmappertype.Mapping{
External: mapping.External(),
Type: mapping.MappingType(),
GoodUntil: mapping.GoodUntil(),
@ -524,15 +536,6 @@ func (c *Client) createMapping() {
}
}
// Mapping is an event recording the allocation of a port mapping.
type Mapping struct {
External netip.AddrPort
Type string
GoodUntil time.Time
// TODO(creachadair): Record whether we reused an existing mapping?
}
// wildcardIP is used when the previous external IP is not known for PCP port mapping.
var wildcardIP = netip.MustParseAddr("0.0.0.0")
@ -545,7 +548,7 @@ func (c *Client) createOrGetMapping(ctx context.Context) (mapping mapping, exter
if c.debug.disableAll() {
return nil, netip.AddrPort{}, NoMappingError{ErrPortMappingDisabled}
}
if c.debug.DisableUPnP && c.debug.DisablePCP && c.debug.DisablePMP {
if c.debug.DisableUPnP() && c.debug.DisablePCP() && c.debug.DisablePMP() {
return nil, netip.AddrPort{}, NoMappingError{ErrNoPortMappingServices}
}
gw, myIP, ok := c.gatewayAndSelfIP()
@ -624,7 +627,7 @@ func (c *Client) createOrGetMapping(ctx context.Context) (mapping mapping, exter
prevPort = m.External().Port()
}
if c.debug.DisablePCP && c.debug.DisablePMP {
if c.debug.DisablePCP() && c.debug.DisablePMP() {
c.mu.Unlock()
if external, ok := c.getUPnPPortMapping(ctx, gw, internalAddr, prevPort); ok {
return nil, external, nil
@ -675,7 +678,7 @@ func (c *Client) createOrGetMapping(ctx context.Context) (mapping mapping, exter
pxpAddr := netip.AddrPortFrom(gw, c.pxpPort())
preferPCP := !c.debug.DisablePCP && (c.debug.DisablePMP || (!haveRecentPMP && haveRecentPCP))
preferPCP := !c.debug.DisablePCP() && (c.debug.DisablePMP() || (!haveRecentPMP && haveRecentPCP))
// Create a mapping, defaulting to PMP unless only PCP was seen recently.
if preferPCP {
@ -860,19 +863,13 @@ func parsePMPResponse(pkt []byte) (res pmpResponse, ok bool) {
return res, true
}
type ProbeResult struct {
PCP bool
PMP bool
UPnP bool
}
// Probe returns a summary of which port mapping services are
// available on the network.
//
// If a probe has run recently and there haven't been any network changes since,
// the returned result might be server from the Client's cache, without
// sending any network traffic.
func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
func (c *Client) Probe(ctx context.Context) (res portmappertype.ProbeResult, err error) {
if c.debug.disableAll() {
return res, ErrPortMappingDisabled
}
@ -907,19 +904,19 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
// https://github.com/tailscale/tailscale/issues/1001
if c.sawPMPRecently() {
res.PMP = true
} else if !c.debug.DisablePMP {
} else if !c.debug.DisablePMP() {
metricPMPSent.Add(1)
uc.WriteToUDPAddrPort(pmpReqExternalAddrPacket, pxpAddr)
}
if c.sawPCPRecently() {
res.PCP = true
} else if !c.debug.DisablePCP {
} else if !c.debug.DisablePCP() {
metricPCPSent.Add(1)
uc.WriteToUDPAddrPort(pcpAnnounceRequest(myIP), pxpAddr)
}
if c.sawUPnPRecently() {
res.UPnP = true
} else if !c.debug.DisableUPnP {
} else if !c.debug.DisableUPnP() {
// Strictly speaking, you discover UPnP services by sending an
// SSDP query (which uPnPPacket is) to udp/1900 on the SSDP
// multicast address, and then get a flood of responses back

@ -11,7 +11,7 @@ import (
"testing"
"time"
"tailscale.com/control/controlknobs"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/util/eventbus/eventbustest"
)
@ -19,7 +19,7 @@ func TestCreateOrGetMapping(t *testing.T) {
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
t.Skip("skipping test without HIT_NETWORK=1")
}
c := NewClient(Config{Logf: t.Logf, ControlKnobs: new(controlknobs.Knobs)})
c := NewClient(Config{Logf: t.Logf})
defer c.Close()
c.SetLocalPort(1234)
for i := range 2 {
@ -35,7 +35,7 @@ func TestClientProbe(t *testing.T) {
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
t.Skip("skipping test without HIT_NETWORK=1")
}
c := NewClient(Config{Logf: t.Logf, ControlKnobs: new(controlknobs.Knobs)})
c := NewClient(Config{Logf: t.Logf})
defer c.Close()
for i := range 3 {
if i > 0 {
@ -50,7 +50,7 @@ func TestClientProbeThenMap(t *testing.T) {
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
t.Skip("skipping test without HIT_NETWORK=1")
}
c := NewClient(Config{Logf: t.Logf, ControlKnobs: new(controlknobs.Knobs)})
c := NewClient(Config{Logf: t.Logf})
defer c.Close()
c.debug.VerboseLogs = true
c.SetLocalPort(1234)
@ -150,7 +150,7 @@ func TestUpdateEvent(t *testing.T) {
t.Fatalf("Probe failed: %v", err)
}
c.GetCachedMappingOrStartCreatingOne()
if err := eventbustest.Expect(tw, eventbustest.Type[Mapping]()); err != nil {
if err := eventbustest.Expect(tw, eventbustest.Type[portmappertype.Mapping]()); err != nil {
t.Error(err.Error())
}
}

@ -0,0 +1,88 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package portmappertype defines the net/portmapper interface, which may or may not be
// linked into the binary.
package portmappertype
import (
"context"
"errors"
"net/netip"
"time"
"tailscale.com/feature"
"tailscale.com/net/netmon"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
)
// HookNewPortMapper is a hook to install the portmapper creation function.
// It must be set by an init function when buildfeatures.HasPortmapper is true.
var HookNewPortMapper feature.Hook[func(logf logger.Logf,
bus *eventbus.Bus,
netMon *netmon.Monitor,
disableUPnPOrNil,
onlyTCP443OrNil func() bool) Client]
var (
ErrNoPortMappingServices = errors.New("no port mapping services were found")
ErrGatewayRange = errors.New("skipping portmap; gateway range likely lacks support")
ErrGatewayIPv6 = errors.New("skipping portmap; no IPv6 support for portmapping")
ErrPortMappingDisabled = errors.New("port mapping is disabled")
)
// ProbeResult is the result of a portmapper probe, saying
// which port mapping protocols were discovered.
type ProbeResult struct {
PCP bool
PMP bool
UPnP bool
}
// Client is the interface implemented by a portmapper client.
type Client interface {
// Probe returns a summary of which port mapping services are available on
// the network.
//
// If a probe has run recently and there haven't been any network changes
// since, the returned result might be server from the Client's cache,
// without sending any network traffic.
Probe(context.Context) (ProbeResult, error)
// HaveMapping reports whether we have a current valid mapping.
HaveMapping() bool
// SetGatewayLookupFunc set the func that returns the machine's default
// gateway IP, and the primary IP address for that gateway. It must be
// called before the client is used. If not called,
// interfaces.LikelyHomeRouterIP is used.
SetGatewayLookupFunc(f func() (gw, myIP netip.Addr, ok bool))
// NoteNetworkDown should be called when the network has transitioned to a down state.
// It's too late to release port mappings at this point (the user might've just turned off
// their wifi), but we can make sure we invalidate mappings for later when the network
// comes back.
NoteNetworkDown()
// GetCachedMappingOrStartCreatingOne quickly returns with our current cached portmapping, if any.
// If there's not one, it starts up a background goroutine to create one.
// If the background goroutine ends up creating one, the onChange hook registered with the
// NewClient constructor (if any) will fire.
GetCachedMappingOrStartCreatingOne() (external netip.AddrPort, ok bool)
// SetLocalPort updates the local port number to which we want to port
// map UDP traffic
SetLocalPort(localPort uint16)
Close() error
}
// Mapping is an event recording the allocation of a port mapping.
type Mapping struct {
External netip.AddrPort
Type string
GoodUntil time.Time
// TODO(creachadair): Record whether we reused an existing mapping?
}

@ -209,7 +209,7 @@ func addAnyPortMapping(
// The meta is the most recently parsed UDP discovery packet response
// from the Internet Gateway Device.
func getUPnPRootDevice(ctx context.Context, logf logger.Logf, debug DebugKnobs, gw netip.Addr, meta uPnPDiscoResponse) (rootDev *goupnp.RootDevice, loc *url.URL, err error) {
if debug.DisableUPnP {
if debug.DisableUPnP() {
return nil, nil, nil
}
@ -434,7 +434,7 @@ func (c *Client) getUPnPPortMapping(
internal netip.AddrPort,
prevPort uint16,
) (external netip.AddrPort, ok bool) {
if disableUPnpEnv() || c.debug.DisableUPnP || (c.controlKnobs != nil && c.controlKnobs.DisableUPnP.Load()) {
if disableUPnpEnv() || c.debug.DisableUPnP() {
return netip.AddrPort{}, false
}

@ -18,6 +18,7 @@ import (
"sync/atomic"
"testing"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/tstest"
)
@ -1039,7 +1040,7 @@ func (u *upnpServer) handleControl(w http.ResponseWriter, r *http.Request, handl
}
}
func mustProbeUPnP(tb testing.TB, ctx context.Context, c *Client) ProbeResult {
func mustProbeUPnP(tb testing.TB, ctx context.Context, c *Client) portmappertype.ProbeResult {
tb.Helper()
res, err := c.Probe(ctx)
if err != nil {

@ -235,7 +235,9 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+
@ -291,7 +293,8 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/net/packet from tailscale.com/ipn/ipnlocal+
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/ipn/localapi+
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock

@ -29,6 +29,7 @@ import (
"tailscale.com/client/local"
"tailscale.com/control/controlclient"
"tailscale.com/envknob"
_ "tailscale.com/feature/condregister/portmapper"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"

@ -33,6 +33,7 @@ import (
"tailscale.com/control/controlknobs"
"tailscale.com/disco"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnstate"
@ -44,7 +45,7 @@ import (
"tailscale.com/net/netns"
"tailscale.com/net/packet"
"tailscale.com/net/ping"
"tailscale.com/net/portmapper"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/net/sockopts"
"tailscale.com/net/sockstats"
"tailscale.com/net/stun"
@ -177,7 +178,7 @@ type Conn struct {
// These [eventbus.Subscriber] fields are solely accessed by
// consumeEventbusTopics once initialized.
pmSub *eventbus.Subscriber[portmapper.Mapping]
pmSub *eventbus.Subscriber[portmappertype.Mapping]
filterSub *eventbus.Subscriber[FilterUpdate]
nodeViewsSub *eventbus.Subscriber[NodeViewsUpdate]
nodeMutsSub *eventbus.Subscriber[NodeMutationsUpdate]
@ -207,7 +208,8 @@ type Conn struct {
// portMapper is the NAT-PMP/PCP/UPnP prober/client, for requesting
// port mappings from NAT devices.
portMapper *portmapper.Client
// If nil, the portmapper is disabled.
portMapper portmappertype.Client
// derpRecvCh is used by receiveDERP to read DERP messages.
// It must have buffer size > 0; see issue 3736.
@ -731,7 +733,7 @@ func NewConn(opts Options) (*Conn, error) {
// Subscribe calls must return before NewConn otherwise published
// events can be missed.
c.pmSub = eventbus.Subscribe[portmapper.Mapping](c.eventClient)
c.pmSub = eventbus.Subscribe[portmappertype.Mapping](c.eventClient)
c.filterSub = eventbus.Subscribe[FilterUpdate](c.eventClient)
c.nodeViewsSub = eventbus.Subscribe[NodeViewsUpdate](c.eventClient)
c.nodeMutsSub = eventbus.Subscribe[NodeMutationsUpdate](c.eventClient)
@ -747,19 +749,21 @@ func NewConn(opts Options) (*Conn, error) {
// Don't log the same log messages possibly every few seconds in our
// portmapper.
portmapperLogf := logger.WithPrefix(c.logf, "portmapper: ")
portmapperLogf = netmon.LinkChangeLogLimiter(c.connCtx, portmapperLogf, opts.NetMon)
portMapOpts := &portmapper.DebugKnobs{
DisableAll: func() bool { return opts.DisablePortMapper || c.onlyTCP443.Load() },
}
c.portMapper = portmapper.NewClient(portmapper.Config{
EventBus: c.eventBus,
Logf: portmapperLogf,
NetMon: opts.NetMon,
DebugKnobs: portMapOpts,
ControlKnobs: opts.ControlKnobs,
})
c.portMapper.SetGatewayLookupFunc(opts.NetMon.GatewayAndSelfIP)
if buildfeatures.HasPortMapper && !opts.DisablePortMapper {
portmapperLogf := logger.WithPrefix(c.logf, "portmapper: ")
portmapperLogf = netmon.LinkChangeLogLimiter(c.connCtx, portmapperLogf, opts.NetMon)
var disableUPnP func() bool
if c.controlKnobs != nil {
disableUPnP = c.controlKnobs.DisableUPnP.Load
}
newPortMapper, ok := portmappertype.HookNewPortMapper.GetOk()
if ok {
c.portMapper = newPortMapper(portmapperLogf, opts.EventBus, opts.NetMon, disableUPnP, c.onlyTCP443.Load)
} else if !testenv.InTest() {
panic("unexpected: HookNewPortMapper not set")
}
}
c.netMon = opts.NetMon
c.health = opts.HealthTracker
c.onPortUpdate = opts.OnPortUpdate
@ -1081,7 +1085,9 @@ func (c *Conn) updateNetInfo(ctx context.Context) (*netcheck.Report, error) {
UPnP: report.UPnP,
PMP: report.PMP,
PCP: report.PCP,
HavePortMap: c.portMapper.HaveMapping(),
}
if c.portMapper != nil {
ni.HavePortMap = c.portMapper.HaveMapping()
}
for rid, d := range report.RegionV4Latency {
ni.DERPLatency[fmt.Sprintf("%d-v4", rid)] = d.Seconds()
@ -1248,7 +1254,7 @@ func (c *Conn) DiscoPublicKey() key.DiscoPublic {
func (c *Conn) determineEndpoints(ctx context.Context) ([]tailcfg.Endpoint, error) {
var havePortmap bool
var portmapExt netip.AddrPort
if runtime.GOOS != "js" {
if runtime.GOOS != "js" && c.portMapper != nil {
portmapExt, havePortmap = c.portMapper.GetCachedMappingOrStartCreatingOne()
}
@ -1288,7 +1294,7 @@ func (c *Conn) determineEndpoints(ctx context.Context) ([]tailcfg.Endpoint, erro
}
// If we didn't have a portmap earlier, maybe it's done by now.
if !havePortmap {
if !havePortmap && c.portMapper != nil {
portmapExt, havePortmap = c.portMapper.GetCachedMappingOrStartCreatingOne()
}
if havePortmap {
@ -2662,7 +2668,9 @@ func (c *Conn) SetNetworkUp(up bool) {
if up {
c.startDerpHomeConnectLocked()
} else {
c.portMapper.NoteNetworkDown()
if c.portMapper != nil {
c.portMapper.NoteNetworkDown()
}
c.closeAllDerpLocked("network-down")
}
}
@ -3324,7 +3332,9 @@ func (c *Conn) Close() error {
c.derpCleanupTimer.Stop()
}
c.stopPeriodicReSTUNTimerLocked()
c.portMapper.Close()
if c.portMapper != nil {
c.portMapper.Close()
}
c.peerMap.forEachEndpoint(func(ep *endpoint) {
ep.stopAndReset()
@ -3577,7 +3587,9 @@ func (c *Conn) rebind(curPortFate currentPortFate) error {
if err := c.bindSocket(&c.pconn4, "udp4", curPortFate); err != nil {
return fmt.Errorf("magicsock: Rebind IPv4 failed: %w", err)
}
c.portMapper.SetLocalPort(c.LocalPort())
if c.portMapper != nil {
c.portMapper.SetLocalPort(c.LocalPort())
}
c.UpdatePMTUD()
return nil
}

Loading…
Cancel
Save