ipn/ipnlocal,net/routemanager: keep a routemanager.RouteManager updated per node

Give nodeBackend a RouteManager and keep it in sync as routing
inputs change: full netmaps resync the whole peer set (removals plus
no-op-cheap upserts), incremental netmap deltas mirror their peer
upserts and removes into the same mutation batch, and
authReconfigLocked pushes the routing-relevant prefs (exit node,
subnet route acceptance, OneCGNAT) after resolving the exit node's
stable ID to its current numeric node ID.

A selected exit node that doesn't resolve to a current peer (a
nonexistent node, or MDM's "auto:any" placeholder awaiting
resolution) is not the same as no exit node: per the long-standing
ipn.Prefs.ExitNodeID contract, it blackholes internet traffic rather
than letting it escape to the local network. RouteManager's Prefs
gains an ExitNodeSelected bit so its OS route set keeps the default
routes in that case, with no outbound peer to carry them, matching
what routerConfigLocked does today, as pinned by TestRouterConfigExitNodeBlackhole in the previous commit.

All mutations happen with nodeBackend.mu held, satisfying the
RouteManager's serialized Begin/Commit contract.

Nothing consumes its snapshots yet; the wgengine data plane and OS
router wiring come next.

Updates #12542

Change-Id: I677b6b2c9efb8e41b3d27071bd9db73e01640d3b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-07-08 16:06:11 +00:00
committed by Brad Fitzpatrick
parent ce050f1ca1
commit ff1c7ef23c
11 changed files with 254 additions and 5 deletions

View File

@@ -811,6 +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/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,6 +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/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,6 +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/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

@@ -384,6 +384,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/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,6 +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/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

@@ -6126,6 +6126,12 @@ func (b *LocalBackend) authReconfigLocked() {
}
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.NetMon.Get(), b.sys.ControlKnobs(), version.OS())
cn.updateRouteManagerPrefs(routePrefs{
ExitNodeID: prefs.ExitNodeID(),
ExitNodeSelected: prefs.ExitNodeID() != "" || prefs.ExitNodeIP().IsValid(),
RouteAll: flags&netmap.AllowSubnetRoutes != 0,
OneCGNAT: oneCGNATRoute,
})
rcfg := b.routerConfigLocked(cfg, prefs, nm, oneCGNATRoute)
// Add these extra Allowed IPs after router configuration, because the expected

View File

@@ -19,6 +19,7 @@
"tailscale.com/ipn"
"tailscale.com/net/dns"
"tailscale.com/net/routecheck/peernode"
"tailscale.com/net/routemanager"
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
@@ -164,6 +165,13 @@ type nodeBackend struct {
// [nodeBackend.AwaitNodeKeyForTest]. It is populated lazily and remains
// nil in production, where no test installs a waiter.
keyWaitersForTest map[key.NodePublic]chan struct{}
// routeMgr tracks this node's view of which IPs route to which
// peers and publishes lock-free snapshots for the data plane and
// the OS router. It is initialized once and immutable, but its
// Begin/Commit mutations must be serialized, which we do by only
// mutating it with mu held.
routeMgr *routemanager.RouteManager
}
func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *nodeBackend {
@@ -174,6 +182,7 @@ func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *n
ctxCancel: ctxCancel,
eventClient: bus.Client("ipnlocal.nodeBackend"),
readyCh: make(chan struct{}),
routeMgr: routemanager.New(logf),
}
// Default filter blocks everything and logs nothing.
noneFilter := filter.NewAllowNone(logger.Discard, &netipx.IPSet{})
@@ -757,16 +766,81 @@ func (nb *nodeBackend) ExtraDNSByName(hostname string) (_ netip.Addr, ok bool) {
func (nb *nodeBackend) updatePeersLocked() {
nm := nb.netMap
oldIDs := slices.Collect(maps.Keys(nb.peers))
if nm == nil {
nb.peers = nil
return
} else {
mapx.RepopulateNonzero(&nb.peers, func() {
for _, p := range nm.Peers {
nb.peers[p.ID()] = p
}
})
}
mapx.RepopulateNonzero(&nb.peers, func() {
for _, p := range nm.Peers {
nb.peers[p.ID()] = p
// Resync the route manager to the new peer set. Upserts of
// unchanged peers are cheap no-ops; this is a full-netmap event,
// so O(n) work is expected here.
rt := nb.routeMgr.Begin()
for _, id := range oldIDs {
if _, ok := nb.peers[id]; !ok {
rt.RemovePeer(id)
}
}
for _, p := range nb.peers {
rt.UpsertPeer(p)
}
rt.Commit()
}
// updateRouteManagerPrefs pushes the routing-relevant prefs and the
// OneCGNAT decision into the route manager. The exit node arrives as
// a stable ID from prefs and is resolved to the current numeric node
// ID here; it resolves to zero (no exit node) if that peer is not in
// the netmap yet, in which case a later netmap update re-runs this.
// routePrefs is the subset of routing-relevant prefs (and derived
// state) that [nodeBackend.updateRouteManagerPrefs] pushes into the
// RouteManager.
type routePrefs struct {
// ExitNodeID is the stable node ID of the selected exit node, or
// zero if none is selected.
ExitNodeID tailcfg.StableNodeID
// ExitNodeSelected is whether the prefs select any exit node at
// all, even one that doesn't resolve (e.g. an ExitNodeIP that
// never resolved to an ExitNodeID); it mirrors
// routerConfigLocked's blackhole condition.
ExitNodeSelected bool
// RouteAll is whether advertised subnet routes from peers are
// accepted.
RouteAll bool
// OneCGNAT is whether the OS route set should collapse peers'
// CGNAT addresses into the single /10 route.
OneCGNAT bool
}
func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) {
nb.mu.Lock()
defer nb.mu.Unlock()
var exitID tailcfg.NodeID
if !p.ExitNodeID.IsZero() {
// The lookup can miss: the selected exit node may not exist,
// or may be a placeholder like MDM's "auto:any" awaiting
// resolution. ExitNodeSelected then makes the RouteManager
// blackhole internet traffic rather than treat it as "no
// exit node"; see [routemanager.Prefs.ExitNodeSelected].
exitID = nb.nodeByStableID[p.ExitNodeID]
}
rt := nb.routeMgr.Begin()
rt.SetPrefs(routemanager.Prefs{
ExitNodeID: exitID,
ExitNodeSelected: p.ExitNodeSelected,
RouteAll: p.RouteAll,
})
rt.SetTailnetConfig(routemanager.TailnetConfig{OneCGNAT: p.OneCGNAT})
rt.Commit()
}
// setPacketFilter stores the live packet filter rules and parsed
@@ -810,6 +884,14 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
// call (e.g. its endpoints + online status both change)
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
// Mirror peer add/remove mutations into the route manager.
// The Commit is unconditional (even if we panic or return false)
// to stay in sync with the peers map, which is likewise mutated
// in place as the loop runs. And if we return false, we'll just
// get a full netmap soon and reset all our state anyway.
rt := nb.routeMgr.Begin()
defer rt.Commit()
for _, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationUpsert:
@@ -824,6 +906,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
mak.Set(&nb.nodeByWGString, m.Node.Key().WireGuardGoString(), nid)
mak.Set(&nb.nodeByStableID, m.Node.StableID(), nid)
nb.addNodeNameLocked(m.Node.Name(), nid)
rt.UpsertPeer(m.Node)
continue
case netmap.NodeMutationRemove:
nid := m.NodeIDBeingMutated()
@@ -838,6 +921,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
delete(nb.nodeByStableID, old.StableID())
nb.removeNodeNameLocked(old.Name())
delete(nb.peers, nid)
rt.RemovePeer(nid)
}
continue
}

View File

@@ -7,6 +7,7 @@
"context"
"errors"
"maps"
"net/netip"
"slices"
"testing"
"time"
@@ -14,6 +15,7 @@
"tailscale.com/net/routecheck/peernode"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak"
@@ -271,3 +273,83 @@ func TestNodeBackendReachability(t *testing.T) {
func (rp routecheckReport) IsReachable(_ tailcfg.NodeID) peernode.Reachability {
return peernode.Reachability(rp)
}
func TestNodeBackendRouteManager(t *testing.T) {
nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New())
mkPeer := func(id tailcfg.NodeID, stableID tailcfg.StableNodeID, addr4 string, extra ...string) tailcfg.NodeView {
n := &tailcfg.Node{
ID: id,
StableID: stableID,
Key: key.NewNode().Public(),
HomeDERP: 1, // required by the route manager's reachability filter
Addresses: []netip.Prefix{
netip.MustParsePrefix(addr4),
},
}
n.AllowedIPs = append(n.AllowedIPs, n.Addresses...)
for _, s := range extra {
n.AllowedIPs = append(n.AllowedIPs, netip.MustParsePrefix(s))
}
return n.View()
}
wantPeerFor := func(ip string, want tailcfg.NodeView) {
t.Helper()
got, ok := nb.routeMgr.Outbound().Lookup(netip.MustParseAddr(ip))
if !want.Valid() {
if ok {
t.Errorf("Outbound lookup %s = %v; want no match", ip, got)
}
return
}
if !ok || got.Key != want.Key() {
t.Errorf("Outbound lookup %s = %v, %v; want %v", ip, got, ok, want.Key())
}
}
p1 := mkPeer(1, "stable1", "100.64.0.1/32")
p2 := mkPeer(2, "stable2", "100.64.0.2/32", "0.0.0.0/0", "::/0")
// A full netmap populates the route manager.
nb.SetNetMap(&netmap.NetworkMap{Peers: []tailcfg.NodeView{p1, p2}})
wantPeerFor("100.64.0.1", p1)
wantPeerFor("100.64.0.2", p2)
wantPeerFor("8.8.8.8", tailcfg.NodeView{}) // exit node not selected
// Selecting peer 2 as the exit node resolves its stable ID and
// installs its /0 routes.
nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "stable2", ExitNodeSelected: true})
wantPeerFor("8.8.8.8", p2)
// A selected exit node that resolves to no current peer must
// blackhole internet traffic, not fall back to "no exit node":
// the default routes stay in the OS route set with no outbound
// peer to carry them.
nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "no-such-node", ExitNodeSelected: true})
wantPeerFor("8.8.8.8", tailcfg.NodeView{})
if !nb.routeMgr.OSRoutes().Get(netip.MustParsePrefix("0.0.0.0/0")) {
t.Error("unresolved exit node: OSRoutes missing 0.0.0.0/0 blackhole route")
}
nb.updateRouteManagerPrefs(routePrefs{})
wantPeerFor("8.8.8.8", tailcfg.NodeView{})
if nb.routeMgr.OSRoutes().Get(netip.MustParsePrefix("0.0.0.0/0")) {
t.Error("no exit node: OSRoutes unexpectedly contains 0.0.0.0/0")
}
// Incremental deltas: add peer 3, remove peer 1.
p3 := mkPeer(3, "stable3", "100.64.0.3/32")
if !nb.UpdateNetmapDelta([]netmap.NodeMutation{
netmap.NodeMutationUpsert{Node: p3},
netmap.MakeNodeMutationRemove(1),
}) {
t.Fatal("UpdateNetmapDelta not handled")
}
wantPeerFor("100.64.0.3", p3)
wantPeerFor("100.64.0.1", tailcfg.NodeView{})
// A full netmap that drops a peer removes it from the route manager.
nb.SetNetMap(&netmap.NetworkMap{Peers: []tailcfg.NodeView{p2}})
wantPeerFor("100.64.0.3", tailcfg.NodeView{})
wantPeerFor("100.64.0.2", p2)
}

View File

@@ -106,10 +106,22 @@ func (p peerView) hasDataPlaneAttrs() bool {
// Prefs is the subset of ipn.Prefs that affects routing.
type Prefs struct {
// ExitNodeID is the node ID of the peer selected as this
// node's exit node, or zero if no exit node is selected.
// node's exit node, or zero if no exit node is selected or the
// selected exit node does not resolve to a current peer.
// (Callers resolve ipn.Prefs's stable node ID to a NodeID.)
ExitNodeID tailcfg.NodeID
// ExitNodeSelected is whether the prefs select any exit node at
// all, even one that doesn't resolve to a current peer (in which
// case ExitNodeID is zero). When set, the exit routes are always
// in the OS route set: with a resolved exit node they carry its
// traffic, and without one they blackhole internet traffic
// rather than let it escape to the local network, per the
// [tailscale.com/ipn.Prefs.ExitNodeID] docs. MDM's "auto:any"
// placeholder relies on the blackhole while an exit node is
// still being chosen.
ExitNodeSelected bool
// RouteAll is whether advertised subnet routes (non-exit
// routes) from peers are accepted.
RouteAll bool
@@ -899,6 +911,13 @@ func (rm *RouteManager) applyDirty(dirty set.Set[netip.Prefix], res *Result) {
var cgnatDirty []netip.Prefix
for pfx := range dirty {
want, wantOS := rm.desiredFor(pfx)
if rm.prefs.ExitNodeSelected && tsaddr.IsExitRoute(pfx) {
// The exit routes stay in the OS route set as long as an
// exit node is selected, even with no eligible
// contributor, to blackhole rather than leak internet
// traffic. See [Prefs.ExitNodeSelected].
wantOS = true
}
if cur, ok := out.Get(pfx); (want != nil) != ok || (ok && cur != want) {
if want != nil {
@@ -984,6 +1003,13 @@ func (rm *RouteManager) rebuildAll(res *Result) {
for _, pfx := range plain {
osr.Insert(pfx)
}
if rm.prefs.ExitNodeSelected {
// Blackhole (or carry) internet traffic while any exit node
// is selected, resolved or not. See [Prefs.ExitNodeSelected].
for _, pfx := range tsaddr.ExitRoutes() {
osr.Insert(pfx)
}
}
if len(rm.ulaPfxs) > 0 {
osr.Insert(tsaddr.TailscaleULARange())
}

View File

@@ -895,3 +895,48 @@ func TestExtraAllowedIPsPeerLifecycle(t *testing.T) {
wantOutbound(t, rm, "fe80::1234", k1, true)
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48")
}
// Tests that the exit routes stay in the OS route set whenever an
// exit node is selected, even one that resolves to no current peer
// (Prefs.ExitNodeSelected with a zero ExitNodeID), so internet
// traffic is blackholed rather than escaping to the local network.
// This mirrors the long-standing behavior documented on
// ipn.Prefs.ExitNodeID and relied on by MDM's "auto:any" placeholder.
func TestExitNodeBlackhole(t *testing.T) {
rm := New(t.Logf)
exitPeer := peer1()
exitPeer.Routes = tsaddr.ExitRoutes()
commit(rm, func(m *Mutation) { m.upsertPeer(exitPeer) })
// No exit node selected: no default routes, no outbound winner.
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48")
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
// A resolved exit node carries the default routes.
commit(rm, func(m *Mutation) {
m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true})
})
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0")
wantOutbound(t, rm, "8.8.8.8", k1, true)
// An unresolved exit node (nonexistent, or not yet resolved)
// keeps the default routes with no outbound winner: a blackhole.
commit(rm, func(m *Mutation) {
m.SetPrefs(Prefs{ExitNodeID: 0, ExitNodeSelected: true})
})
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0")
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
// Removing the exit-capable peer while still selected keeps the
// blackhole in place via the incremental path.
commit(rm, func(m *Mutation) {
m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true})
})
commit(rm, func(m *Mutation) { m.RemovePeer(1) })
wantOSRoutes(t, rm, "0.0.0.0/0", "::/0")
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
// Deselecting the exit node removes the default routes.
commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) })
wantOSRoutes(t, rm)
}

View File

@@ -206,6 +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/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+