net/tstun,wgengine,ipn/ipnlocal: make tstun's peerConfigTable use RouteManager table

Previously tstun.Wrapper.SetWGConfig walked wgcfg.Config.Peers on every
netmap to rebuild its own IP-to-peer table for masquerade NAT rewrites
and jailed-peer classification. Now the tun layer instead consumes the
route manager's shared immutable outbound snapshot directly, via a new
Engine.SetPeerRoutes method: LocalBackend pushes the snapshot (plus this
node's native Tailscale addresses) after every route manager commit that
can change it, and per-packet lookups read the interned PeerRoute
attributes from that table.

When no current peer is jailed or masqueraded, LocalBackend installs a
nil table (gated on RouteManager.HasDataPlaneAttrs), preserving the
per-packet nil-check fast path. The exitNodeRequiresMasq machinery is
deleted: its purpose was populating the table with all peers so that
more-specific entries shadow an exit node's /0, and the always-full
route manager table gives that shadowing inherently.

This is the last step before removing the Peers field from wgcfg.Config.

Updates #12542

Change-Id: Ifce09ca929a3f2511303ca1d6efdd583739494ce
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2026-07-09 14:28:53 +00:00 committed by Brad Fitzpatrick
parent e4144230f4
commit f0ce89b715
15 changed files with 318 additions and 245 deletions

View File

@ -811,7 +811,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+

View File

@ -109,7 +109,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/net/netcheck+

View File

@ -126,7 +126,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/net/netcheck+

View File

@ -385,7 +385,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routecheck from tailscale.com/feature/routecheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled

View File

@ -210,7 +210,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+

View File

@ -31,6 +31,7 @@
"sync/atomic"
"time"
"github.com/gaissmai/bart"
"go4.org/mem"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
@ -62,6 +63,7 @@
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/net/packet"
"tailscale.com/net/routemanager"
"tailscale.com/net/traffic"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
@ -2516,6 +2518,7 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
for k := range deltaRes.ChangedAllowedIPs {
b.e.SyncDevicePeer(k)
}
b.setDataPlanePeerRoutes()
// Reset the WireGuard session for peers whose disco key changed in
// a way that indicates a restart, flushing their dead session keys;
@ -2530,11 +2533,9 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
// and its lazy peer creation reads live state via
// [wgengine.Engine.SetPeerConfigFunc]. What still rides
// authReconfig is everything else derived from the full peer set:
// OS routes (router.Config), the quad-100 resolver's MagicDNS
// hosts map (dnsConfigForNetmap), and tstun's per-peer config
// (masquerade addresses and jailed peers, via SetWGConfig). Once
// those become delta-aware too, this can be gated on the route
// manager's OS-routes changes and the tstun-relevant fields
// OS routes (router.Config) and the quad-100 resolver's MagicDNS
// hosts map (dnsConfigForNetmap). Once those become delta-aware
// too, this can be gated on the route manager's OS-routes changes
// instead of firing on every peer change.
needsAuthReconfig = needsAuthReconfig || peersUpsertedOrRemoved
if needsAuthReconfig {
@ -6183,6 +6184,13 @@ func (b *LocalBackend) authReconfigLocked() {
cfg.Peers[i].AllowedIPs = extras.AppendTo(cfg.Peers[i].AllowedIPs)
}
}
// The prefs and extras commits above can both change the outbound
// table (such as installing the selected exit node's /0 routes),
// so refresh the data plane's view of it. This must stay after
// updateRouteManagerExtras, so the pushed snapshot includes its
// commit, and before the Reconfig below, so packets flowing under
// the new config never see a stale peer table.
b.setDataPlanePeerRoutes()
err = b.e.Reconfig(cfg, rcfg, dcfg)
if err == wgengine.ErrNoChanges {
@ -6196,6 +6204,29 @@ func (b *LocalBackend) authReconfigLocked() {
}
}
// setDataPlanePeerRoutes pushes the route manager's outbound table and
// this node's native Tailscale addresses into the engine's tun-layer
// data plane, which uses them for per-packet NAT rewrites and
// jailed-filter selection. It must be called after every route manager
// commit that can change the outbound table or the set of peers with
// data-plane attributes: netmap updates (full or delta), prefs changes,
// and extra-allowed-IP changes.
//
// When no current peer is jailed or masqueraded, it installs nil, which
// keeps the data plane's per-packet fast path to a nil check.
func (b *LocalBackend) setDataPlanePeerRoutes() {
nb := b.currentNode()
var native4, native6 netip.Addr
var routes *bart.Table[*routemanager.PeerRoute]
if nb.routeMgr.HasDataPlaneAttrs() {
routes = nb.routeMgr.Outbound()
if nm := nb.NetMap(); nm != nil {
native4, native6 = tsaddr.FirstTailscaleAddrs(nm.GetAddresses().All())
}
}
b.e.SetPeerRoutes(native4, native6, routes)
}
// shouldUseOneCGNATRoute reports whether we should prefer to make one big
// CGNAT /10 route rather than a /32 per peer.
//
@ -7312,6 +7343,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
login = cmp.Or(profileFromView(nm.UserProfiles[nm.User()]).LoginName, "<missing-profile>")
}
discoChanged := b.currentNode().SetNetMap(nm)
b.setDataPlanePeerRoutes()
if ms, ok := b.sys.MagicSock.GetOK(); ok {
if nm != nil {
if nm.Cached {

View File

@ -17,6 +17,7 @@
"time"
qt "github.com/frankban/quicktest"
"github.com/gaissmai/bart"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@ -29,6 +30,7 @@
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/packet"
"tailscale.com/net/routemanager"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
@ -1961,6 +1963,9 @@ func (e *mockEngine) SetJailedFilter(f *filter.Filter) {
e.mu.Unlock()
}
func (e *mockEngine) SetPeerRoutes(native4, native6 netip.Addr, routes *bart.Table[*routemanager.PeerRoute]) {
}
func (e *mockEngine) SetStatusCallback(cb wgengine.StatusCallback) {
e.mu.Lock()
e.statusCb = cb

View File

@ -7,6 +7,7 @@
import (
"encoding/binary"
"errors"
"iter"
"net/netip"
"slices"
"sync"
@ -317,3 +318,24 @@ func MapVia(siteID uint32, v4 netip.Prefix) (via netip.Prefix, err error) {
copy(a[12:], ip4a[:])
return netip.PrefixFrom(netip.AddrFrom16(a), v4.Bits()+64+32), nil
}
// FirstTailscaleAddrs returns the first Tailscale IPv4 address and
// the first Tailscale IPv6 address among the addresses of addrs'
// prefixes, if any. The addrs sequence is typically a node's own
// address list, either a slice (via [slices.All]) or a view (via
// [views.Slice.All]).
func FirstTailscaleAddrs(addrs iter.Seq2[int, netip.Prefix]) (a4, a6 netip.Addr) {
for _, pfx := range addrs {
a := pfx.Addr()
switch {
case a.Is4() && !a4.IsValid() && IsTailscaleIP(a):
a4 = a
case a.Is6() && !a6.IsValid() && IsTailscaleIP(a):
a6 = a
}
if a4.IsValid() && a6.IsValid() {
break
}
}
return a4, a6
}

View File

@ -5,6 +5,7 @@
import (
"net/netip"
"slices"
"testing"
"github.com/google/go-cmp/cmp"
@ -290,3 +291,27 @@ func TestIsTailscaleIP(t *testing.T) {
}
}
}
func TestFirstTailscaleAddrs(t *testing.T) {
pfx := func(s string) netip.Prefix { return netip.MustParsePrefix(s) }
addr := func(s string) netip.Addr { return netip.MustParseAddr(s) }
tests := []struct {
name string
in []netip.Prefix
want4 netip.Addr
want6 netip.Addr
}{
{"empty", nil, netip.Addr{}, netip.Addr{}},
{"both", []netip.Prefix{pfx("100.64.0.1/32"), pfx("fd7a:115c:a1e0::1/128")}, addr("100.64.0.1"), addr("fd7a:115c:a1e0::1")},
{"first-of-each", []netip.Prefix{pfx("100.64.0.1/32"), pfx("100.64.0.2/32"), pfx("fd7a:115c:a1e0::1/128"), pfx("fd7a:115c:a1e0::2/128")}, addr("100.64.0.1"), addr("fd7a:115c:a1e0::1")},
{"non-tailscale-skipped", []netip.Prefix{pfx("192.168.1.1/32"), pfx("2001:db8::1/128"), pfx("100.64.0.9/32")}, addr("100.64.0.9"), netip.Addr{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got4, got6 := FirstTailscaleAddrs(slices.All(tt.in))
if got4 != tt.want4 || got6 != tt.want6 {
t.Errorf("FirstTailscaleAddrs = %v, %v; want %v, %v", got4, got6, tt.want4, tt.want6)
}
})
}
}

View File

@ -9,7 +9,6 @@
"io"
"net/netip"
"os"
"reflect"
"runtime"
"slices"
"strings"
@ -28,6 +27,7 @@
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/packet"
"tailscale.com/net/packet/checksum"
"tailscale.com/net/routemanager"
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/tstime/mono"
@ -565,31 +565,9 @@ func (pc *peerConfigTable) dnat(p *packet.Parsed) {
}
}
// findV4 returns the first Tailscale IPv4 address in addrs.
func findV4(addrs []netip.Prefix) netip.Addr {
for _, ap := range addrs {
a := ap.Addr()
if a.Is4() && tsaddr.IsTailscaleIP(a) {
return a
}
}
return netip.Addr{}
}
// findV6 returns the first Tailscale IPv6 address in addrs.
func findV6(addrs []netip.Prefix) netip.Addr {
for _, ap := range addrs {
a := ap.Addr()
if a.Is6() && tsaddr.IsTailscaleIP(a) {
return a
}
}
return netip.Addr{}
}
// peerConfigTable contains configuration for individual peers and related
// information necessary to perform peer-specific operations. It should be
// treated as immutable.
// peerConfigTable contains the per-peer route attributes and related
// information necessary to perform peer-specific operations. It should
// be treated as immutable.
//
// The nil value is a valid configuration.
type peerConfigTable struct {
@ -602,57 +580,17 @@ type peerConfigTable struct {
// inbound packet is IPv6.
nativeAddr4, nativeAddr6 netip.Addr
// byIP contains configuration for each peer, indexed by a peer's IP
// address(es).
byIP bart.Table[*peerConfig]
// masqAddrCounts is a count of peers by MasqueradeAsIP.
// TODO? for logging
masqAddrCounts map[netip.Addr]int
}
// peerConfig is the configuration for a single peer.
type peerConfig struct {
// dstMasqAddr{4,6} are the addresses that should be used as the
// source address when masquerading packets to this peer (i.e.
// SNAT). If an address is not valid, the packet should not be
// masqueraded for that address family.
dstMasqAddr4 netip.Addr
dstMasqAddr6 netip.Addr
// jailed is whether this peer is "jailed" (i.e. is restricted from being
// able to initiate connections to this node). This is the case for shared
// nodes.
jailed bool
// byIP maps a peer's IP addresses and routed prefixes to the
// peer's route attributes. It is a shared immutable snapshot
// from [routemanager.RouteManager.Outbound].
byIP *bart.Table[*routemanager.PeerRoute]
}
func (c *peerConfigTable) String() string {
if c == nil {
return "peerConfigTable(nil)"
}
var b strings.Builder
b.WriteString("peerConfigTable{")
fmt.Fprintf(&b, "nativeAddr4: %v, ", c.nativeAddr4)
fmt.Fprintf(&b, "nativeAddr6: %v, ", c.nativeAddr6)
// TODO: figure out how to iterate/debug/print c.byIP
b.WriteString("}")
return b.String()
}
func (c *peerConfig) String() string {
if c == nil {
return "peerConfig(nil)"
}
var b strings.Builder
b.WriteString("peerConfig{")
fmt.Fprintf(&b, "dstMasqAddr4: %v, ", c.dstMasqAddr4)
fmt.Fprintf(&b, "dstMasqAddr6: %v, ", c.dstMasqAddr6)
fmt.Fprintf(&b, "jailed: %v}", c.jailed)
return b.String()
return fmt.Sprintf("peerConfigTable{nativeAddr4: %v, nativeAddr6: %v}", c.nativeAddr4, c.nativeAddr6)
}
// mapDstIP returns the destination IP to use for a packet to dst.
@ -675,10 +613,10 @@ func (pc *peerConfigTable) mapDstIP(src, oldDst netip.Addr) netip.Addr {
return oldDst
}
if oldDst.Is4() && pc.nativeAddr4.IsValid() && c.dstMasqAddr4 == oldDst {
if oldDst.Is4() && pc.nativeAddr4.IsValid() && c.MasqAddr4 == oldDst {
return pc.nativeAddr4
}
if oldDst.Is6() && pc.nativeAddr6.IsValid() && c.dstMasqAddr6 == oldDst {
if oldDst.Is6() && pc.nativeAddr6.IsValid() && c.MasqAddr6 == oldDst {
return pc.nativeAddr6
}
return oldDst
@ -708,105 +646,17 @@ func (pc *peerConfigTable) selectSrcIP(oldSrc, dst netip.Addr) netip.Addr {
// Perform SNAT based on the address family and whether we have a valid
// addr.
if oldSrc.Is4() && c.dstMasqAddr4.IsValid() {
return c.dstMasqAddr4
if oldSrc.Is4() && c.MasqAddr4.IsValid() {
return c.MasqAddr4
}
if oldSrc.Is6() && c.dstMasqAddr6.IsValid() {
return c.dstMasqAddr6
if oldSrc.Is6() && c.MasqAddr6.IsValid() {
return c.MasqAddr6
}
// No SNAT; use old src
return oldSrc
}
// peerConfigTableFromWGConfig generates a peerConfigTable from nm. If NAT is
// not required, and no additional configuration is present, it returns nil.
func peerConfigTableFromWGConfig(wcfg *wgcfg.Config) *peerConfigTable {
if wcfg == nil {
return nil
}
nativeAddr4 := findV4(wcfg.Addresses)
nativeAddr6 := findV6(wcfg.Addresses)
if !nativeAddr4.IsValid() && !nativeAddr6.IsValid() {
return nil
}
ret := &peerConfigTable{
nativeAddr4: nativeAddr4,
nativeAddr6: nativeAddr6,
masqAddrCounts: make(map[netip.Addr]int),
}
// When using an exit node that requires masquerading, we need to
// fill out the routing table with all peers not just the ones that
// require masquerading.
exitNodeRequiresMasq := false // true if using an exit node and it requires masquerading
for _, p := range wcfg.Peers {
isExitNode := slices.Contains(p.AllowedIPs, tsaddr.AllIPv4()) || slices.Contains(p.AllowedIPs, tsaddr.AllIPv6())
if isExitNode {
hasMasqAddr := false ||
(p.V4MasqAddr != nil && p.V4MasqAddr.IsValid()) ||
(p.V6MasqAddr != nil && p.V6MasqAddr.IsValid())
if hasMasqAddr {
exitNodeRequiresMasq = true
}
break
}
}
byIPSize := 0
for i := range wcfg.Peers {
p := &wcfg.Peers[i]
// Build a routing table that configures DNAT (i.e. changing
// the V4MasqAddr/V6MasqAddr for a given peer to the current
// peer's v4/v6 IP).
var addrToUse4, addrToUse6 netip.Addr
if p.V4MasqAddr != nil && p.V4MasqAddr.IsValid() {
addrToUse4 = *p.V4MasqAddr
ret.masqAddrCounts[addrToUse4]++
}
if p.V6MasqAddr != nil && p.V6MasqAddr.IsValid() {
addrToUse6 = *p.V6MasqAddr
ret.masqAddrCounts[addrToUse6]++
}
// If the exit node requires masquerading, set the masquerade
// addresses to our native addresses.
if exitNodeRequiresMasq {
if !addrToUse4.IsValid() && nativeAddr4.IsValid() {
addrToUse4 = nativeAddr4
}
if !addrToUse6.IsValid() && nativeAddr6.IsValid() {
addrToUse6 = nativeAddr6
}
}
if !addrToUse4.IsValid() && !addrToUse6.IsValid() && !p.IsJailed {
// NAT not required for this peer.
continue
}
// Use the same peer configuration for each address of the peer.
pc := &peerConfig{
dstMasqAddr4: addrToUse4,
dstMasqAddr6: addrToUse6,
jailed: p.IsJailed,
}
// Insert an entry into our routing table for each allowed IP.
for _, ip := range p.AllowedIPs {
ret.byIP.Insert(ip, pc)
byIPSize++
}
}
if byIPSize == 0 && len(ret.masqAddrCounts) == 0 {
return nil
}
return ret
}
func (pc *peerConfigTable) inboundPacketIsJailed(p *packet.Parsed) bool {
if pc == nil {
return false
@ -815,7 +665,7 @@ func (pc *peerConfigTable) inboundPacketIsJailed(p *packet.Parsed) bool {
if !ok {
return false
}
return c.jailed
return c.Jailed
}
func (pc *peerConfigTable) outboundPacketIsJailed(p *packet.Parsed) bool {
@ -826,7 +676,7 @@ func (pc *peerConfigTable) outboundPacketIsJailed(p *packet.Parsed) bool {
if !ok {
return false
}
return c.jailed
return c.Jailed
}
// SetIPer is the interface expected to be implemented by the TAP implementation
@ -836,16 +686,53 @@ type SetIPer interface {
SetIP(ipV4, ipV6 netip.Addr) error
}
// SetWGConfig is called when a new NetworkMap is received.
// SetWGConfig is called when a new NetworkMap is received. Its only
// remaining job is updating the TAP device's IP addresses; the
// per-peer route attributes arrive via [Wrapper.SetPeerRoutes].
func (t *Wrapper) SetWGConfig(wcfg *wgcfg.Config) {
if t.isTAP {
if sip, ok := t.tdev.(SetIPer); ok {
sip.SetIP(findV4(wcfg.Addresses), findV6(wcfg.Addresses))
sip.SetIP(tsaddr.FirstTailscaleAddrs(slices.All(wcfg.Addresses)))
}
}
cfg := peerConfigTableFromWGConfig(wcfg)
}
// SetPeerRoutes is called whenever this node's Tailscale addresses or
// the route manager's outbound table change. native4 and native6 are
// this node's own Tailscale addresses, and routes maps each peer's
// addresses and routed prefixes to its route attributes.
//
// A nil routes table disables all per-packet peer processing (NAT
// rewrites and jailed-filter selection); callers pass nil when no
// current peer has any such attributes, which keeps the common
// per-packet path to a nil check.
//
// Unchanged values are a cheap no-op, so callers can call it
// unconditionally whenever the inputs might have changed: the routes
// table is an immutable snapshot whose pointer identity means its
// contents are unchanged.
func (t *Wrapper) SetPeerRoutes(native4, native6 netip.Addr, routes *bart.Table[*routemanager.PeerRoute]) {
var cfg *peerConfigTable
if routes != nil && (native4.IsValid() || native6.IsValid()) {
if old := t.peerConfig.Load(); old != nil &&
old.byIP == routes && old.nativeAddr4 == native4 && old.nativeAddr6 == native6 {
return
}
cfg = &peerConfigTable{
nativeAddr4: native4,
nativeAddr6: native6,
byIP: routes,
}
} else if t.peerConfig.Load() == nil {
// Uninstalling (cfg stays nil) over an already-nil config;
// skip the Swap and its transition logging below.
return
}
old := t.peerConfig.Swap(cfg)
if !reflect.DeepEqual(old, cfg) {
// Log only on nil-ness or native address transitions; the routes
// table changes with every routing update and is too chatty to log.
if (old == nil) != (cfg == nil) ||
(old != nil && (old.nativeAddr4 != cfg.nativeAddr4 || old.nativeAddr6 != cfg.nativeAddr6)) {
t.logf("peer config: %v", cfg)
}
}

View File

@ -18,6 +18,7 @@
"unicode"
"unsafe"
"github.com/gaissmai/bart"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/tailscale/wireguard-go/tun/tuntest"
@ -29,6 +30,7 @@
"tailscale.com/net/netaddr"
"tailscale.com/net/packet"
"tailscale.com/net/packet/checksum"
"tailscale.com/net/routemanager"
"tailscale.com/tstest"
"tailscale.com/tstime/mono"
"tailscale.com/types/ipproto"
@ -42,7 +44,6 @@
"tailscale.com/util/usermetric"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/netstack/gro"
"tailscale.com/wgengine/wgcfg"
)
func udp4(src, dst string, sport, dport uint16) []byte {
@ -710,22 +711,43 @@ func TestFilterDiscoLoop(t *testing.T) {
}
// TODO(andrew-d): refactor this test to no longer use addrFam, after #11945
// removed it in peerConfigFromWGConfig
func TestPeerCfg_NAT(t *testing.T) {
node := func(ip, masqIP netip.Addr, otherAllowedIPs ...netip.Prefix) wgcfg.Peer {
p := wgcfg.Peer{
PublicKey: key.NewNode().Public(),
AllowedIPs: []netip.Prefix{
netip.PrefixFrom(ip, ip.BitLen()),
},
type testPeer struct {
pr *routemanager.PeerRoute
pfxs []netip.Prefix
}
node := func(ip, masqIP netip.Addr, otherAllowedIPs ...netip.Prefix) testPeer {
pr := &routemanager.PeerRoute{Key: key.NewNode().Public()}
switch {
case masqIP.Is4():
pr.MasqAddr4 = masqIP
case masqIP.Is6():
pr.MasqAddr6 = masqIP
}
if masqIP.Is4() {
p.V4MasqAddr = new(masqIP)
return testPeer{
pr: pr,
pfxs: append([]netip.Prefix{netip.PrefixFrom(ip, ip.BitLen())}, otherAllowedIPs...),
}
}
// peerCfgTable builds the peerConfigTable under test from peers,
// as SetPeerRoutes would from the route manager's outbound table.
// A nil peers means no table, matching an uninstalled config.
peerCfgTable := func(selfNativeIP netip.Addr, peers []testPeer) *peerConfigTable {
if peers == nil {
return nil
}
pcfg := &peerConfigTable{byIP: &bart.Table[*routemanager.PeerRoute]{}}
if selfNativeIP.Is4() {
pcfg.nativeAddr4 = selfNativeIP
} else {
p.V6MasqAddr = new(masqIP)
pcfg.nativeAddr6 = selfNativeIP
}
p.AllowedIPs = append(p.AllowedIPs, otherAllowedIPs...)
return p
for _, p := range peers {
for _, pfx := range p.pfxs {
pcfg.byIP.Insert(pfx, p.pr)
}
}
return pcfg
}
test := func(addrFam ipproto.Version) {
var (
@ -734,7 +756,6 @@ func TestPeerCfg_NAT(t *testing.T) {
selfNativeIP = netip.MustParseAddr("100.64.0.1")
selfEIP1 = netip.MustParseAddr("100.64.1.1")
selfEIP2 = netip.MustParseAddr("100.64.1.2")
selfAddrs = []netip.Prefix{netip.PrefixFrom(selfNativeIP, selfNativeIP.BitLen())}
peer1IP = netip.MustParseAddr("100.64.0.2")
peer2IP = netip.MustParseAddr("100.64.0.3")
@ -749,7 +770,6 @@ func TestPeerCfg_NAT(t *testing.T) {
selfNativeIP = netip.MustParseAddr("fd7a:115c:a1e0::a")
selfEIP1 = netip.MustParseAddr("fd7a:115c:a1e0::1a")
selfEIP2 = netip.MustParseAddr("fd7a:115c:a1e0::1b")
selfAddrs = []netip.Prefix{netip.PrefixFrom(selfNativeIP, selfNativeIP.BitLen())}
peer1IP = netip.MustParseAddr("fd7a:115c:a1e0::b")
peer2IP = netip.MustParseAddr("fd7a:115c:a1e0::c")
@ -769,13 +789,13 @@ type dnatTest struct {
tests := []struct {
name string
wcfg *wgcfg.Config
peers []testPeer // nil means no config at all
snatMap map[netip.Addr]netip.Addr // dst -> src
dnat []dnatTest
}{
{
name: "no-cfg",
wcfg: nil,
name: "no-cfg",
peers: nil,
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfNativeIP,
peer2IP: selfNativeIP,
@ -789,12 +809,9 @@ type dnatTest struct {
},
{
name: "single-peer-requires-nat",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, noIP),
node(peer2IP, selfEIP2),
},
peers: []testPeer{
node(peer1IP, noIP),
node(peer2IP, selfEIP2),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfNativeIP,
@ -810,12 +827,9 @@ type dnatTest struct {
},
{
name: "multiple-peers-require-nat",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2),
},
peers: []testPeer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfEIP1,
@ -831,12 +845,9 @@ type dnatTest struct {
},
{
name: "multiple-peers-require-nat-with-subnet",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2, subnet),
},
peers: []testPeer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2, subnet),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfEIP1,
@ -852,12 +863,9 @@ type dnatTest struct {
},
{
name: "multiple-peers-require-nat-with-default-route",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2, exitRoute),
},
peers: []testPeer{
node(peer1IP, selfEIP1),
node(peer2IP, selfEIP2, exitRoute),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfEIP1,
@ -873,12 +881,9 @@ type dnatTest struct {
},
{
name: "no-nat",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, noIP),
node(peer2IP, noIP),
},
peers: []testPeer{
node(peer1IP, noIP),
node(peer2IP, noIP),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfNativeIP,
@ -894,12 +899,9 @@ type dnatTest struct {
},
{
name: "exit-node-require-nat-peer-doesnt",
wcfg: &wgcfg.Config{
Addresses: selfAddrs,
Peers: []wgcfg.Peer{
node(peer1IP, noIP),
node(peer2IP, selfEIP2, exitRoute),
},
peers: []testPeer{
node(peer1IP, noIP),
node(peer2IP, selfEIP2, exitRoute),
},
snatMap: map[netip.Addr]netip.Addr{
peer1IP: selfNativeIP,
@ -916,7 +918,7 @@ type dnatTest struct {
for _, tc := range tests {
t.Run(fmt.Sprintf("%v/%v", addrFam, tc.name), func(t *testing.T) {
pcfg := peerConfigTableFromWGConfig(tc.wcfg)
pcfg := peerCfgTable(selfNativeIP, tc.peers)
for peer, want := range tc.snatMap {
if got := pcfg.selectSrcIP(selfNativeIP, peer); got != want {
t.Errorf("selectSrcIP[%v]: got %v; want %v", peer, got, want)
@ -1154,3 +1156,60 @@ func TestInjectedReadCallsAppConnectorHook(t *testing.T) {
t.Errorf("packet mismatch\nwant:\t% x\ngot:\t% x", wantPkt, gotPkt)
}
}
// Tests SetPeerRoutes's fast path: unchanged inputs (including the
// nil-table case) must not replace the stored config, relying on the
// routes table's immutable-snapshot pointer identity, so callers can
// call it unconditionally on every routing update.
func TestSetPeerRoutesFastPath(t *testing.T) {
bus := eventbustest.NewBus(t)
_, tun := newFakeTUN(t.Logf, bus, false)
defer tun.Close()
// Installing nil over nil is a no-op.
tun.SetPeerRoutes(netip.Addr{}, netip.Addr{}, nil)
if got := tun.peerConfig.Load(); got != nil {
t.Fatalf("peerConfig after nil install = %v; want nil", got)
}
native4 := netip.MustParseAddr("100.64.0.1")
native6 := netip.MustParseAddr("fd7a:115c:a1e0::1")
routes := &bart.Table[*routemanager.PeerRoute]{}
routes.Insert(netip.MustParsePrefix("100.64.0.2/32"), &routemanager.PeerRoute{Jailed: true})
tun.SetPeerRoutes(native4, native6, routes)
installed := tun.peerConfig.Load()
if installed == nil {
t.Fatal("peerConfig = nil; want non-nil")
}
// Same inputs: the stored config must be untouched.
tun.SetPeerRoutes(native4, native6, routes)
if got := tun.peerConfig.Load(); got != installed {
t.Errorf("peerConfig replaced on unchanged inputs")
}
// A new snapshot pointer installs a new config.
routes2 := routes.InsertPersist(netip.MustParsePrefix("100.64.0.3/32"), &routemanager.PeerRoute{Jailed: true})
tun.SetPeerRoutes(native4, native6, routes2)
second := tun.peerConfig.Load()
if second == installed {
t.Error("peerConfig not replaced on changed routes table")
}
// Changed native address installs a new config too.
tun.SetPeerRoutes(netip.MustParseAddr("100.64.0.9"), native6, routes2)
if got := tun.peerConfig.Load(); got == second {
t.Error("peerConfig not replaced on changed native address")
}
// Dropping back to nil works and is then a stable no-op.
tun.SetPeerRoutes(netip.Addr{}, netip.Addr{}, nil)
if got := tun.peerConfig.Load(); got != nil {
t.Fatalf("peerConfig after uninstall = %v; want nil", got)
}
tun.SetPeerRoutes(netip.Addr{}, netip.Addr{}, nil)
if got := tun.peerConfig.Load(); got != nil {
t.Fatalf("peerConfig after second uninstall = %v; want nil", got)
}
}

View File

@ -206,7 +206,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+

View File

@ -54,8 +54,10 @@
"tailscale.com/net/netmon"
"tailscale.com/net/packet"
"tailscale.com/net/ping"
"tailscale.com/net/routemanager"
"tailscale.com/net/stun"
"tailscale.com/net/stun/stuntest"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tstun"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
@ -244,9 +246,31 @@ func newMagicStackWithKey(t testing.TB, logf logger.Logf, ln nettype.PacketListe
}
}
func (s *magicStack) Reconfig(cfg *wgcfg.Config) error {
// Reconfig applies cfg to the stack's device and tun layer. peers,
// if non-nil, are the tailcfg nodes that cfg was derived from; the
// tun layer's per-peer data-plane attributes (masquerade addresses,
// jailed classification) are derived from them with a real
// [routemanager.RouteManager], exactly as LocalBackend does in
// production, so this helper cannot drift from the production
// derivation. Tests whose hand-built configs carry no such
// attributes may pass nil.
func (s *magicStack) Reconfig(cfg *wgcfg.Config, peers []tailcfg.NodeView) error {
s.tsTun.SetWGConfig(cfg)
if peers != nil {
rm := routemanager.New(nil)
mut := rm.Begin()
// Mirror the netmap.AllowSubnetRoutes flag the tests pass to
// nmcfg.WGCfg.
mut.SetPrefs(routemanager.Prefs{RouteAll: true})
for _, n := range peers {
mut.UpsertPeer(n)
}
mut.Commit()
native4, native6 := tsaddr.FirstTailscaleAddrs(slices.All(cfg.Addresses))
s.tsTun.SetPeerRoutes(native4, native6, rm.Outbound())
}
// In production, LocalBackend installs a PeerByIPPacketFunc via
// Engine.SetPeerByIPPacketFunc. Tests that bypass LocalBackend need
// to install one here for outbound packet routing.
@ -373,7 +397,7 @@ func meshStacks(logf logger.Logf, mutateNetmap func(idx int, nm *netmap.NetworkM
// blow up. Shouldn't happen anyway.
panic(fmt.Sprintf("failed to construct wgcfg from netmap: %v", err))
}
if err := m.Reconfig(wg); err != nil {
if err := m.Reconfig(wg, nm.Peers); err != nil {
if ctx.Err() != nil || errors.Is(err, errConnClosed) {
// shutdown race, don't care.
return
@ -1199,10 +1223,10 @@ func testTwoDevicePing(t *testing.T, d *devices) {
},
}
if err := m1.Reconfig(m1cfg); err != nil {
if err := m1.Reconfig(m1cfg, nil); err != nil {
t.Fatal(err)
}
if err := m2.Reconfig(m2cfg); err != nil {
if err := m2.Reconfig(m2cfg, nil); err != nil {
t.Fatal(err)
}
@ -1326,7 +1350,7 @@ func testTwoDevicePing(t *testing.T, d *devices) {
t.Run("no-op-dev1-reconfig", func(t *testing.T) {
setT(t)
defer setT(outerT)
if err := m1.Reconfig(m1cfg); err != nil {
if err := m1.Reconfig(m1cfg, nil); err != nil {
t.Fatal(err)
}
ping1(t)
@ -2525,7 +2549,7 @@ func TestIsWireGuardOnlyPeer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
m.Reconfig(cfg)
m.Reconfig(cfg, nm.Peers)
pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr())
m.tun.Outbound <- pbuf
@ -2586,7 +2610,7 @@ func TestIsWireGuardOnlyPeerWithMasquerade(t *testing.T) {
if err != nil {
t.Fatal(err)
}
m.Reconfig(cfg)
m.Reconfig(cfg, nm.Peers)
pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr())
m.tun.Outbound <- pbuf
@ -2627,7 +2651,7 @@ func applyNetworkMap(t *testing.T, m *magicStack, nm *netmap.NetworkMap) {
t.Fatal(err)
}
// Apply the wireguard config to the tailscale internal wireguard device.
if err := m.Reconfig(cfg); err != nil {
if err := m.Reconfig(cfg, nm.Peers); err != nil {
t.Fatal(err)
}
}

View File

@ -18,6 +18,7 @@
"sync/atomic"
"time"
"github.com/gaissmai/bart"
"github.com/tailscale/wireguard-go/device"
"github.com/tailscale/wireguard-go/tun"
"go4.org/mem"
@ -33,6 +34,7 @@
"tailscale.com/net/ipset"
"tailscale.com/net/netmon"
"tailscale.com/net/packet"
"tailscale.com/net/routemanager"
"tailscale.com/net/sockstats"
"tailscale.com/net/tsdial"
"tailscale.com/net/tstun"
@ -1006,6 +1008,10 @@ func (e *userspaceEngine) SetJailedFilter(filt *filter.Filter) {
e.tundev.SetJailedFilter(filt)
}
func (e *userspaceEngine) SetPeerRoutes(native4, native6 netip.Addr, routes *bart.Table[*routemanager.PeerRoute]) {
e.tundev.SetPeerRoutes(native4, native6, routes)
}
func (e *userspaceEngine) SetStatusCallback(cb StatusCallback) {
e.mu.Lock()
defer e.mu.Unlock()

View File

@ -9,9 +9,11 @@
"net/netip"
"time"
"github.com/gaissmai/bart"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns"
"tailscale.com/net/packet"
"tailscale.com/net/routemanager"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
@ -125,6 +127,17 @@ type Engine interface {
// SetJailedFilter updates the packet filter for jailed nodes.
SetJailedFilter(*filter.Filter)
// SetPeerRoutes updates the per-peer route attributes used by the
// tun-layer data plane for per-packet NAT rewrites and
// jailed-filter selection. native4 and native6 are this node's own
// Tailscale addresses, and routes maps each peer's addresses and
// routed prefixes to its attributes; it is a shared immutable
// snapshot from [routemanager.RouteManager.Outbound].
//
// A nil routes table disables all per-packet peer processing;
// callers pass nil when no current peer has any such attributes.
SetPeerRoutes(native4, native6 netip.Addr, routes *bart.Table[*routemanager.PeerRoute])
// SetStatusCallback sets the function to call when the
// WireGuard status changes.
SetStatusCallback(StatusCallback)