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>
This commit is contained in:
Brad Fitzpatrick
2025-09-15 19:50:21 -07:00
committed by Brad Fitzpatrick
parent 2b0f59cd38
commit 99b3f69126
36 changed files with 757 additions and 398 deletions
@@ -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
+7
View File
@@ -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"
+6
View File
@@ -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"
+204
View File
@@ -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
}
+2
View File
@@ -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"},
+38
View File
@@ -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
}