feature/featuretags: add LazyWG modular feature

Due to iOS memory limitations in 2020 (see
https://tailscale.com/blog/go-linker, etc) and wireguard-go using
multiple goroutines per peer, commit 16a9cfe2f4ce7d introduced some
convoluted pathsways through Tailscale to look at packets before
they're delivered to wireguard-go and lazily reconfigure wireguard on
the fly before delivering a packet, only telling wireguard about peers
that are active.

We eventually want to remove that code and integrate wireguard-go's
configuration with Tailscale's existing netmap tracking.

To make it easier to find that code later, this makes it modular. It
saves 12 KB (of disk) to turn it off (at the expense of lots of RAM),
but that's not really the point. The point is rather making it obvious
(via the new constants) where this code even is.

Updates #12614

Change-Id: I113b040f3e35f7d861c457eaa710d35f47cee1cb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2025-10-04 14:04:36 -07:00 committed by Brad Fitzpatrick
parent f80c7e7c23
commit cf520a3371
6 changed files with 76 additions and 28 deletions

View File

@ -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_lazywg
package buildfeatures
// HasLazyWG is whether the binary was built with support for modular feature "Lazy WireGuard configuration for memory-constrained devices with large netmaps".
// Specifically, it's whether the binary was NOT built with the "ts_omit_lazywg" build tag.
// It's a const so it can be used for dead code elimination.
const HasLazyWG = false

View File

@ -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_lazywg
package buildfeatures
// HasLazyWG is whether the binary was built with support for modular feature "Lazy WireGuard configuration for memory-constrained devices with large netmaps".
// Specifically, it's whether the binary was NOT built with the "ts_omit_lazywg" build tag.
// It's a const so it can be used for dead code elimination.
const HasLazyWG = true

View File

@ -159,6 +159,7 @@ var Features = map[FeatureTag]FeatureMeta{
"hujsonconf": {Sym: "HuJSONConf", Desc: "HuJSON config file support"}, "hujsonconf": {Sym: "HuJSONConf", Desc: "HuJSON config file support"},
"iptables": {Sym: "IPTables", Desc: "Linux iptables support"}, "iptables": {Sym: "IPTables", Desc: "Linux iptables support"},
"kube": {Sym: "Kube", Desc: "Kubernetes integration"}, "kube": {Sym: "Kube", Desc: "Kubernetes integration"},
"lazywg": {Sym: "LazyWG", Desc: "Lazy WireGuard configuration for memory-constrained devices with large netmaps"},
"linuxdnsfight": {Sym: "LinuxDNSFight", Desc: "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)"}, "linuxdnsfight": {Sym: "LinuxDNSFight", Desc: "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)"},
"listenrawdisco": { "listenrawdisco": {
Sym: "ListenRawDisco", Sym: "ListenRawDisco",

View File

@ -312,7 +312,9 @@ func (t *Wrapper) now() time.Time {
// //
// The map ownership passes to the Wrapper. It must be non-nil. // The map ownership passes to the Wrapper. It must be non-nil.
func (t *Wrapper) SetDestIPActivityFuncs(m map[netip.Addr]func()) { func (t *Wrapper) SetDestIPActivityFuncs(m map[netip.Addr]func()) {
t.destIPActivity.Store(m) if buildfeatures.HasLazyWG {
t.destIPActivity.Store(m)
}
} }
// SetDiscoKey sets the current discovery key. // SetDiscoKey sets the current discovery key.
@ -948,12 +950,14 @@ func (t *Wrapper) Read(buffs [][]byte, sizes []int, offset int) (int, error) {
for _, data := range res.data { for _, data := range res.data {
p.Decode(data[res.dataOffset:]) p.Decode(data[res.dataOffset:])
if m := t.destIPActivity.Load(); m != nil { if buildfeatures.HasLazyWG {
if fn := m[p.Dst.Addr()]; fn != nil { if m := t.destIPActivity.Load(); m != nil {
fn() if fn := m[p.Dst.Addr()]; fn != nil {
fn()
}
} }
} }
if captHook != nil { if buildfeatures.HasCapture && captHook != nil {
captHook(packet.FromLocal, t.now(), p.Buffer(), p.CaptureMeta) captHook(packet.FromLocal, t.now(), p.Buffer(), p.CaptureMeta)
} }
if !t.disableFilter { if !t.disableFilter {
@ -1085,9 +1089,11 @@ func (t *Wrapper) injectedRead(res tunInjectedRead, outBuffs [][]byte, sizes []i
pc.snat(p) pc.snat(p)
invertGSOChecksum(pkt, gso) invertGSOChecksum(pkt, gso)
if m := t.destIPActivity.Load(); m != nil { if buildfeatures.HasLazyWG {
if fn := m[p.Dst.Addr()]; fn != nil { if m := t.destIPActivity.Load(); m != nil {
fn() if fn := m[p.Dst.Addr()]; fn != nil {
fn()
}
} }
} }

View File

@ -468,7 +468,8 @@ type Options struct {
// NoteRecvActivity, if provided, is a func for magicsock to call // NoteRecvActivity, if provided, is a func for magicsock to call
// whenever it receives a packet from a a peer if it's been more // whenever it receives a packet from a a peer if it's been more
// than ~10 seconds since the last one. (10 seconds is somewhat // than ~10 seconds since the last one. (10 seconds is somewhat
// arbitrary; the sole user just doesn't need or want it called on // arbitrary; the sole user, lazy WireGuard configuration,
// just doesn't need or want it called on
// every packet, just every minute or two for WireGuard timeouts, // every packet, just every minute or two for WireGuard timeouts,
// and 10 seconds seems like a good trade-off between often enough // and 10 seconds seems like a good trade-off between often enough
// and not too often.) // and not too often.)

View File

@ -404,19 +404,21 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error)
} }
} }
magicsockOpts := magicsock.Options{ magicsockOpts := magicsock.Options{
EventBus: e.eventBus, EventBus: e.eventBus,
Logf: logf, Logf: logf,
Port: conf.ListenPort, Port: conf.ListenPort,
EndpointsFunc: endpointsFn, EndpointsFunc: endpointsFn,
DERPActiveFunc: e.RequestStatus, DERPActiveFunc: e.RequestStatus,
IdleFunc: e.tundev.IdleDuration, IdleFunc: e.tundev.IdleDuration,
NoteRecvActivity: e.noteRecvActivity, NetMon: e.netMon,
NetMon: e.netMon, HealthTracker: e.health,
HealthTracker: e.health, Metrics: conf.Metrics,
Metrics: conf.Metrics, ControlKnobs: conf.ControlKnobs,
ControlKnobs: conf.ControlKnobs, OnPortUpdate: onPortUpdate,
OnPortUpdate: onPortUpdate, PeerByKeyFunc: e.PeerByKey,
PeerByKeyFunc: e.PeerByKey, }
if buildfeatures.HasLazyWG {
magicsockOpts.NoteRecvActivity = e.noteRecvActivity
} }
var err error var err error
@ -748,15 +750,22 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked(discoChanged map[key.Node
// the past 5 minutes. That's more than WireGuard's key // the past 5 minutes. That's more than WireGuard's key
// rotation time anyway so it's no harm if we remove it // rotation time anyway so it's no harm if we remove it
// later if it's been inactive. // later if it's been inactive.
activeCutoff := e.timeNow().Add(-lazyPeerIdleThreshold) var activeCutoff mono.Time
if buildfeatures.HasLazyWG {
activeCutoff = e.timeNow().Add(-lazyPeerIdleThreshold)
}
// Not all peers can be trimmed from the network map (see // Not all peers can be trimmed from the network map (see
// isTrimmablePeer). For those that are trimmable, keep track of // isTrimmablePeer). For those that are trimmable, keep track of
// their NodeKey and Tailscale IPs. These are the ones we'll need // their NodeKey and Tailscale IPs. These are the ones we'll need
// to install tracking hooks for to watch their send/receive // to install tracking hooks for to watch their send/receive
// activity. // activity.
trackNodes := make([]key.NodePublic, 0, len(full.Peers)) var trackNodes []key.NodePublic
trackIPs := make([]netip.Addr, 0, len(full.Peers)) var trackIPs []netip.Addr
if buildfeatures.HasLazyWG {
trackNodes = make([]key.NodePublic, 0, len(full.Peers))
trackIPs = make([]netip.Addr, 0, len(full.Peers))
}
// Don't re-alloc the map; the Go compiler optimizes map clears as of // Don't re-alloc the map; the Go compiler optimizes map clears as of
// Go 1.11, so we can re-use the existing + allocated map. // Go 1.11, so we can re-use the existing + allocated map.
@ -770,7 +779,7 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked(discoChanged map[key.Node
for i := range full.Peers { for i := range full.Peers {
p := &full.Peers[i] p := &full.Peers[i]
nk := p.PublicKey nk := p.PublicKey
if !e.isTrimmablePeer(p, len(full.Peers)) { if !buildfeatures.HasLazyWG || !e.isTrimmablePeer(p, len(full.Peers)) {
min.Peers = append(min.Peers, *p) min.Peers = append(min.Peers, *p)
if discoChanged[nk] { if discoChanged[nk] {
needRemoveStep = true needRemoveStep = true
@ -803,7 +812,9 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked(discoChanged map[key.Node
return nil return nil
} }
e.updateActivityMapsLocked(trackNodes, trackIPs) if buildfeatures.HasLazyWG {
e.updateActivityMapsLocked(trackNodes, trackIPs)
}
if needRemoveStep { if needRemoveStep {
minner := min minner := min
@ -839,6 +850,9 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked(discoChanged map[key.Node
// //
// e.wgLock must be held. // e.wgLock must be held.
func (e *userspaceEngine) updateActivityMapsLocked(trackNodes []key.NodePublic, trackIPs []netip.Addr) { func (e *userspaceEngine) updateActivityMapsLocked(trackNodes []key.NodePublic, trackIPs []netip.Addr) {
if !buildfeatures.HasLazyWG {
return
}
// Generate the new map of which nodekeys we want to track // Generate the new map of which nodekeys we want to track
// receive times for. // receive times for.
mr := map[key.NodePublic]mono.Time{} // TODO: only recreate this if set of keys changed mr := map[key.NodePublic]mono.Time{} // TODO: only recreate this if set of keys changed
@ -943,7 +957,7 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config,
peerMTUEnable := e.magicConn.ShouldPMTUD() peerMTUEnable := e.magicConn.ShouldPMTUD()
isSubnetRouter := false isSubnetRouter := false
if e.birdClient != nil && nm != nil && nm.SelfNode.Valid() { if buildfeatures.HasBird && e.birdClient != nil && nm != nil && nm.SelfNode.Valid() {
isSubnetRouter = hasOverlap(nm.SelfNode.PrimaryRoutes(), nm.SelfNode.Hostinfo().RoutableIPs()) isSubnetRouter = hasOverlap(nm.SelfNode.PrimaryRoutes(), nm.SelfNode.Hostinfo().RoutableIPs())
e.logf("[v1] Reconfig: hasOverlap(%v, %v) = %v; isSubnetRouter=%v lastIsSubnetRouter=%v", e.logf("[v1] Reconfig: hasOverlap(%v, %v) = %v; isSubnetRouter=%v lastIsSubnetRouter=%v",
nm.SelfNode.PrimaryRoutes(), nm.SelfNode.Hostinfo().RoutableIPs(), nm.SelfNode.PrimaryRoutes(), nm.SelfNode.Hostinfo().RoutableIPs(),