derp, wgengine: let clients advertise an opaque app name to DERP servers

Add an AppName field to the DERP ClientInfo so DERP servers can
attribute connections to the application making them, primarily for
best effort stats purposes. The value is plumbed per engine instance
rather than via a process global, so a process hosting multiple stacks
can attribute each one's DERP connections separately:
wgengine.Config.DERPAppName flows through magicsock.Options and
derphttp.Client into the naclbox-sealed ClientInfo JSON. Old servers
ignore the unknown field.

There are no callers in the tree yet setting the name.

Updates tailscale/corp#24454

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia7d3e9c2b6f8140e5a9d7c3b2e6f1a8d4c0b5e9f
This commit is contained in:
Brad Fitzpatrick
2026-07-20 07:33:44 -07:00
committed by Brad Fitzpatrick
parent 37e175a032
commit 246c82a658
8 changed files with 161 additions and 1 deletions
+1
View File
@@ -414,6 +414,7 @@ func (c *Conn) derpWriteChanForRegion(regionID int, peer key.NodePublic) chan de
return derpMap.Regions[regionID]
})
dc.HealthTracker = c.health
dc.AppName = c.derpAppName
if c.extraRootCAs != nil {
dc.TLSConfig = &tls.Config{RootCAs: c.extraRootCAs}
}
+6
View File
@@ -170,6 +170,7 @@ type Conn struct {
health *health.Tracker // or nil
extraRootCAs *x509.CertPool // additional trusted root CAs; or nil
controlKnobs *controlknobs.Knobs // or nil
derpAppName string // or empty, see Options.DERPAppName
// ================================================================
// No locking required to access these fields, either because
@@ -489,6 +490,10 @@ type Options struct {
// for TLS connections to DERP servers.
ExtraRootCAs *x509.CertPool
// DERPAppName, if non-empty, is an opaque app name string to
// advertise to DERP servers for stats purposes.
DERPAppName string
// Metrics specifies the metrics registry to record metrics to.
Metrics *usermetric.Registry
@@ -705,6 +710,7 @@ func NewConn(opts Options) (*Conn, error) {
c.netMon = opts.NetMon
c.health = opts.HealthTracker
c.extraRootCAs = opts.ExtraRootCAs
c.derpAppName = opts.DERPAppName
c.getPeerByKey = opts.PeerByKeyFunc
if err := c.rebind(keepCurrentPort); err != nil {
+6
View File
@@ -210,6 +210,11 @@ type Config struct {
// connections (e.g. DERP). Passed through to magicsock.
ExtraRootCAs *x509.CertPool
// DERPAppName, if non-empty, is an opaque app name string to
// advertise to DERP servers for stats purposes. It is passed
// through to magicsock.
DERPAppName string
// ControlKnobs is the set of control plane-provied knobs
// to use.
// If nil, defaults are used.
@@ -425,6 +430,7 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error)
NetMon: e.netMon,
HealthTracker: e.health,
ExtraRootCAs: conf.ExtraRootCAs,
DERPAppName: conf.DERPAppName,
Metrics: conf.Metrics,
ControlKnobs: conf.ControlKnobs,
PeerByKeyFunc: e.PeerByKey,
+90
View File
@@ -4,9 +4,13 @@
package wgengine
import (
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"runtime"
@@ -19,12 +23,15 @@ import (
"go4.org/mem"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/control/controlknobs"
"tailscale.com/derp"
"tailscale.com/derp/derpserver"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/net/dns"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/netaddr"
"tailscale.com/net/netmon"
"tailscale.com/net/stun/stuntest"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
@@ -553,3 +560,86 @@ func TestCloseWaitsForLinkChange(t *testing.T) {
t.Fatal("Close returned with link change work still in flight")
}
}
// TestDERPAppNamePlumbing tests that Config.DERPAppName makes it all
// the way from the engine config to the ClientInfo received by an
// in-process DERP server.
func TestDERPAppNamePlumbing(t *testing.T) {
const appName = "app-name-plumbing-test"
priv := key.NewNode()
infoCh := make(chan derp.ClientInfo, 1)
derpSrv := derpserver.New(key.NewNode(), t.Logf)
derpSrv.ForTest().SetOnClientInfo(func(k key.NodePublic, info derp.ClientInfo) {
if k != priv.Public() {
return
}
select {
case infoCh <- info:
default:
}
})
httpsrv := httptest.NewUnstartedServer(derpserver.Handler(derpSrv))
httpsrv.Config.ErrorLog = logger.StdLogger(t.Logf)
httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
httpsrv.StartTLS()
t.Cleanup(func() {
httpsrv.CloseClientConnections()
httpsrv.Close()
derpSrv.Close()
})
stunAddr, stunCleanup := stuntest.Serve(t)
t.Cleanup(stunCleanup)
derpMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "test",
Nodes: []*tailcfg.DERPNode{{
Name: "t1",
RegionID: 1,
HostName: "test-node.unused",
IPv4: "127.0.0.1",
IPv6: "none",
STUNPort: stunAddr.Port,
DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port,
InsecureForTests: true,
}},
},
},
}
bus := eventbustest.NewBus(t)
noopDNS, err := dns.NewNoopManager()
if err != nil {
t.Fatal(err)
}
e, err := NewUserspaceEngine(t.Logf, Config{
HealthTracker: health.NewTracker(bus),
Metrics: new(usermetric.Registry),
EventBus: bus,
DNS: noopDNS,
DERPAppName: appName,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(e.Close)
if err := e.Reconfig(&wgcfg.Config{PrivateKey: priv}, &router.Config{}, &dns.Config{}); err != nil {
t.Fatalf("Reconfig: %v", err)
}
e.(*userspaceEngine).magicConn.SetDERPMap(derpMap)
select {
case info := <-infoCh:
if info.AppName != appName {
t.Fatalf("ClientInfo.AppName = %q; want %q", info.AppName, appName)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for engine to connect to the test DERP server")
}
}