WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
11 changed files with 254 additions and 5 deletions
Showing only changes of commit ff1c7ef23c - Show all commits
+1
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+
+1
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+
+1
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+
+1
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
+1
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+
+6
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
+88 -4
View File
@@ -19,6 +19,7 @@ import (
"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
}
+82
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"maps"
"net/netip"
"slices"
"testing"
"time"
@@ -14,6 +15,7 @@ import (
"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 @@ var _ RouteCheckReport = *new(routecheckReport)
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)
}
+27 -1
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())
}
+45
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)
}
+1
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+