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-14 12:41:11 -07:00
committed by Brad Fitzpatrick
parent e4144230f4
commit f0ce89b715
15 changed files with 318 additions and 245 deletions
+114 -55
View File
@@ -18,6 +18,7 @@ import (
"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 @@ import (
"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 @@ import (
"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 @@ func TestPeerCfg_NAT(t *testing.T) {
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
},
{
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 @@ func TestPeerCfg_NAT(t *testing.T) {
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)
}
}