diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 4da89afa6..82dda280f 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -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+ diff --git a/cmd/tailscaled/depaware-min.txt b/cmd/tailscaled/depaware-min.txt index f9a87a608..ab85bdd9b 100644 --- a/cmd/tailscaled/depaware-min.txt +++ b/cmd/tailscaled/depaware-min.txt @@ -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+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index e5dce3277..1f39aea86 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -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+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 4874769b4..da6262032 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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 diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 33f885d4a..07da57287 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -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+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 798e43499..843f60336 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index b57be59b1..964b6402d 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -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 } diff --git a/ipn/ipnlocal/node_backend_test.go b/ipn/ipnlocal/node_backend_test.go index 657aadb14..6ca34c4d9 100644 --- a/ipn/ipnlocal/node_backend_test.go +++ b/ipn/ipnlocal/node_backend_test.go @@ -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) +} diff --git a/net/routemanager/routemanager.go b/net/routemanager/routemanager.go index e18ecb459..e750fea8f 100644 --- a/net/routemanager/routemanager.go +++ b/net/routemanager/routemanager.go @@ -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()) } diff --git a/net/routemanager/routemanager_test.go b/net/routemanager/routemanager_test.go index fececdd6c..108b42870 100644 --- a/net/routemanager/routemanager_test.go +++ b/net/routemanager/routemanager_test.go @@ -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) +} diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 7162bb616..2b1f8a27f 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -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+