diff --git a/net/routemanager/fortest.go b/net/routemanager/fortest.go new file mode 100644 index 000000000..079c78ff6 --- /dev/null +++ b/net/routemanager/fortest.go @@ -0,0 +1,23 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package routemanager + +import "tailscale.com/util/testenv" + +// forTest is an unexported type to hide the test-only methods on +// [RouteManager] from godoc. +type forTest struct{ rm *RouteManager } + +// ForTest returns a handle to test-only methods on rm. The resulting +// type is unexported to make it very obvious in godoc that this is +// not stable API. This method panics if called outside of tests, +// which also centralizes all must-be-in-tests validation. +func (rm *RouteManager) ForTest() forTest { + testenv.AssertInTest() + return forTest{rm} +} + +// PeerCount returns the number of peers currently tracked. Callers +// must serialize it with mutations like any other write-path access. +func (f forTest) PeerCount() int { return len(f.rm.peers) } diff --git a/net/routemanager/routemanager.go b/net/routemanager/routemanager.go new file mode 100644 index 000000000..e18ecb459 --- /dev/null +++ b/net/routemanager/routemanager.go @@ -0,0 +1,1016 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package routemanager tracks which peers own which IP prefixes and +// incrementally derives the routing data structures used by the rest +// of the system: a table mapping destination IP to the outbound peer, +// and the set of routes to program into the operating system's +// routing table. +// +// Updates are transactional: callers open a [Mutation] with +// [RouteManager.Begin], stage operations, and call [Mutation.Commit] +// to publish new snapshots. The published snapshots are immutable +// bart tables that share memory with their predecessors, so readers +// (notably the wireguard-go data plane) can hold and read them +// without locks. At most one [Mutation] may be active at a time, +// and the caller is responsible for synchronizing them. +package routemanager + +import ( + "net/netip" + "slices" + "sync" + "sync/atomic" + + "github.com/gaissmai/bart" + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/types/logger" + "tailscale.com/types/views" + "tailscale.com/util/mak" + "tailscale.com/util/set" +) + +// peerView is the subset of a peer's netmap state that affects +// routing. It is intentionally much narrower than tailcfg.NodeView so +// the RouteManager can be driven and tested without full netmap +// nodes; UpsertPeer converts from tailcfg.NodeView. +type peerView struct { + // ID is the peer's node ID. It is the peer's identity within + // the RouteManager. + ID tailcfg.NodeID + + // Key is the peer's WireGuard public key. It is what gets + // published into the data-path tables, so per-packet readers + // need no NodeID-to-key translation. + Key key.NodePublic + + // Jailed is whether the peer is restricted from initiating + // connections to this node, in which case the data plane + // applies its more restrictive jailed packet filter to the + // peer's traffic. + Jailed bool + + // MasqAddr4 and MasqAddr6 are the addresses the peer knows + // this node as, per address family, if they differ from this + // node's native addresses. The data plane masquerades (NATs) + // traffic to and from the peer accordingly. An invalid address + // means no masquerading for that family. + MasqAddr4, MasqAddr6 netip.Addr + + // SelfAddrs are the peer's own addresses (its CGNAT IPv4 /32, + // if any, and its Tailscale ULA IPv6 /128) plus any other + // single Tailscale IPs it routes for, such as VIP service + // addresses. They are routable regardless of Prefs.RouteAll. + SelfAddrs []netip.Prefix + + // Routes are the peer's advertised routes: subnet routes and, + // for exit-node candidates, the 0.0.0.0/0 and ::/0 exit routes. + Routes []netip.Prefix +} + +// PeerRoute is the payload of the outbound table: the attributes the +// data plane needs when handling a packet to or from one of the +// peer's prefixes. All prefixes contributed by a peer share a single +// interned *PeerRoute, and a new one is allocated whenever the +// attributes change, so published snapshots stay immutable and +// pointer identity doubles as a change check. +type PeerRoute struct { + // Key is the peer's WireGuard public key. + Key key.NodePublic + + // Jailed, MasqAddr4, and MasqAddr6 mirror the fields of the + // same names in peerView. + Jailed bool + MasqAddr4, MasqAddr6 netip.Addr +} + +// routeAttrs returns the peer's attributes as published in the +// outbound table. +func (p peerView) routeAttrs() PeerRoute { + return PeerRoute{ + Key: p.Key, + Jailed: p.Jailed, + MasqAddr4: p.MasqAddr4, + MasqAddr6: p.MasqAddr6, + } +} + +// hasDataPlaneAttrs reports whether the peer has any attributes that +// require per-packet attention from the tun-layer data plane. +func (p peerView) hasDataPlaneAttrs() bool { + return p.Jailed || p.MasqAddr4.IsValid() || p.MasqAddr6.IsValid() +} + +// 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. + // (Callers resolve ipn.Prefs's stable node ID to a NodeID.) + ExitNodeID tailcfg.NodeID + + // RouteAll is whether advertised subnet routes (non-exit + // routes) from peers are accepted. + RouteAll bool +} + +// TailnetConfig is tailnet-global and environment-derived +// configuration that affects routing. +type TailnetConfig struct { + // DisableIPv4 is whether the tailnet has disabled IPv4 self + // addresses. If set, peers' IPv4 self addresses are ignored. + DisableIPv4 bool + + // OneCGNAT is whether the OS route set should collapse peers' + // CGNAT IPv4 self addresses into the single CGNAT /10 route + // rather than per-peer /32s. The decision is made by the + // caller (it depends on platform and interface state); the + // RouteManager just applies it. + OneCGNAT bool +} + +// cgnatThreshold is the number of distinct CGNAT self-address routes +// above which the OS route set collapses them into the single CGNAT +// /10, even without TailnetConfig.OneCGNAT. +const cgnatThreshold = 10_000 + +// contribKind is a bit vector that describes how a peer contributes a +// prefix: as one of its own addresses, as an advertised route, or +// both. +type contribKind uint8 + +const ( + kindSelf contribKind = 1 << iota + kindRoute + + // kindExtra marks a prefix from the extra allowed IPs set via + // [Mutation.SetExtraAllowedIPs]. Extra prefixes appear in the + // outbound table and in [RouteManager.PeerAllowedIPs], but + // never in the OS route set. + kindExtra +) + +// scoreKey identifies a per-(node, prefix) score. +type scoreKey struct { + id tailcfg.NodeID + pfx netip.Prefix +} + +// RouteManager tracks peers' addresses and advertised routes and +// derives the routing snapshots. +// +// The snapshot accessors (Outbound, OSRoutes) may be called +// concurrently from any goroutine. Mutations must be serialized by +// the caller: Begin, then stage operations, then Commit (or Discard), +// before the next Begin. Commit panics if it detects overlapping +// mutations. +type RouteManager struct { + logf logger.Logf + txGen atomic.Uint64 + + outbound atomic.Pointer[bart.Table[*PeerRoute]] + osRoutes atomic.Pointer[bart.Lite] + + // attrPeers counts the current peers with data-plane attributes + // (jailed or masquerade addresses). It backs + // [RouteManager.HasDataPlaneAttrs]. + attrPeers atomic.Int64 + + // mu guards the working state below. Commit holds it while + // applying staged operations and PeerAllowedIPs holds it while + // reading, so reads cannot race a concurrent Commit. Mutations + // are additionally serialized by callers (see Begin). + mu sync.Mutex + + peers map[tailcfg.NodeID]peerView + routes map[tailcfg.NodeID]*PeerRoute + byPrefix map[netip.Prefix]map[tailcfg.NodeID]contribKind + scores map[scoreKey]int + extras map[tailcfg.NodeID][]netip.Prefix + prefs Prefs + cfg TailnetConfig + + // cgnatPfxs and ulaPfxs are the prefixes currently eligible + // for the OS route set that are single CGNAT IPv4 addresses or + // single Tailscale ULA IPv6 addresses, respectively. They feed + // the coarse-route decisions. + cgnatPfxs set.Set[netip.Prefix] + ulaPfxs set.Set[netip.Prefix] + + // coarseCGNAT is whether the OS route set currently contains + // the single CGNAT /10 instead of per-peer /32s. + coarseCGNAT bool +} + +// New returns a new RouteManager with empty snapshots. +func New(logf logger.Logf) *RouteManager { + if logf == nil { + logf = logger.Discard + } + rm := &RouteManager{ + logf: logf, + peers: make(map[tailcfg.NodeID]peerView), + routes: make(map[tailcfg.NodeID]*PeerRoute), + byPrefix: make(map[netip.Prefix]map[tailcfg.NodeID]contribKind), + scores: make(map[scoreKey]int), + cgnatPfxs: make(set.Set[netip.Prefix]), + ulaPfxs: make(set.Set[netip.Prefix]), + } + rm.outbound.Store(&bart.Table[*PeerRoute]{}) + rm.osRoutes.Store(&bart.Lite{}) + return rm +} + +// Outbound returns the current destination-IP-to-peer table. The +// returned table and the PeerRoutes it points to are immutable; +// callers must not modify them. +func (rm *RouteManager) Outbound() *bart.Table[*PeerRoute] { + return rm.outbound.Load() +} + +// OSRoutes returns the current set of prefixes to program into the +// OS routing table. The returned table is immutable; callers must not +// modify it. +func (rm *RouteManager) OSRoutes() *bart.Lite { + return rm.osRoutes.Load() +} + +// HasDataPlaneAttrs reports whether any current peer has data-plane +// attributes (jailed or masquerade addresses), that is, whether the +// tun-layer data plane needs the outbound table for per-packet NAT +// rewrites and jailed-filter selection. When it reports false, the +// data plane can skip those per-packet lookups entirely. +func (rm *RouteManager) HasDataPlaneAttrs() bool { + return rm.attrPeers.Load() > 0 +} + +// PeerAllowedIPs returns the prefixes from which the given peer is +// currently allowed to originate traffic: its self addresses, its +// advertised subnet routes when Prefs.RouteAll is set, the exit +// routes when it is the selected exit node, and its extra allowed +// IPs (see [Mutation.SetExtraAllowedIPs]). It returns ok=false if +// the peer is unknown or currently contributes no prefixes; such a +// peer should not exist in the WireGuard device at all. +// +// The result is sorted, so identical routing state yields identical +// slices (letting callers cheaply detect no-op updates). +// +// Unlike the snapshot accessors, PeerAllowedIPs reads the working +// state. An internal mutex makes it safe to call concurrently with +// Commit. +func (rm *RouteManager) PeerAllowedIPs(id tailcfg.NodeID) (pfxs []netip.Prefix, ok bool) { + rm.mu.Lock() + defer rm.mu.Unlock() + return rm.peerAllowedIPsLocked(id) +} + +// peerAllowedIPsLocked implements [RouteManager.PeerAllowedIPs]. +// rm.mu must be held. +func (rm *RouteManager) peerAllowedIPsLocked(id tailcfg.NodeID) (pfxs []netip.Prefix, ok bool) { + p, ok := rm.peers[id] + if !ok { + return nil, false + } + for pfx, kind := range rm.contribs(p) { + if rm.eligible(id, pfx, kind) { + pfxs = append(pfxs, pfx) + } + } + if len(pfxs) == 0 { + return nil, false + } + tsaddr.SortPrefixes(pfxs) + return pfxs, true +} + +// Result describes what a Commit changed. +type Result struct { + // PeersUpserted is the number of UpsertPeer operations applied. + PeersUpserted int + // PeersRemoved is the number of RemovePeer operations that + // removed a known peer. + PeersRemoved int + // ScoresChanged is the number of SetScore operations that + // changed a stored score. + ScoresChanged int + // ExtrasChanged is whether the commit changed the extra + // allowed IPs. + ExtrasChanged bool + // PrefsChanged is whether the commit changed the prefs. + PrefsChanged bool + // TailnetCfgChanged is whether the commit changed the tailnet config. + TailnetCfgChanged bool + + // OutboundChanged and OSRoutesChanged report whether each + // output snapshot actually changed contents. + OutboundChanged bool + OSRoutesChanged bool + + // Outbound and OSRoutes are the fresh snapshot pointers, or + // nil for any that didn't change. + Outbound *bart.Table[*PeerRoute] + OSRoutes *bart.Lite + + // AllowedIPs maps the public key of each peer whose allowed + // source prefixes changed in this commit to its new sorted + // prefix list, as [RouteManager.PeerAllowedIPs] would now + // return it. A nil value means the peer no longer has any + // allowed prefixes, because it was removed or now contributes + // nothing. When a peer's key changes, the old key maps to nil + // and the new key to the peer's prefixes. The map is nil when + // no peer's allowed prefixes changed. + AllowedIPs map[key.NodePublic][]netip.Prefix +} + +type opKind uint8 + +const ( + opUpsert opKind = iota + opRemove + opPrefs + opConfig + opScore + opExtras +) + +type stagedOp struct { + kind opKind + peer peerView // for opUpsert + id tailcfg.NodeID // for opRemove, opScore + pfx netip.Prefix // for opScore + score int // for opScore + prefs Prefs // for opPrefs + cfg TailnetConfig // for opConfig + extras map[tailcfg.NodeID][]netip.Prefix // for opExtras +} + +// Mutation is an open transaction against a RouteManager. Operations +// are staged in order and applied atomically by Commit. A Mutation +// must not be used after Commit or Discard. +type Mutation struct { + rm *RouteManager + gen uint64 + done bool + ops []stagedOp +} + +// Begin starts a mutation. Callers must serialize Begin/Commit pairs; +// overlapping mutations cause Commit to panic. +func (rm *RouteManager) Begin() *Mutation { + return &Mutation{rm: rm, gen: rm.txGen.Load()} +} + +func (m *Mutation) checkOpen() { + if m.done { + panic("routemanager: use of finished Mutation") + } +} + +// UpsertPeer stages an add or update of a peer. The peer's prefixes +// come solely from n.AllowedIPs: entries that are single Tailscale +// IPs (the peer's own addresses, or VIP service addresses it hosts) +// or that appear in n.Addresses count as self addresses and are +// always routable; the rest are treated as its advertised routes +// (subnet routes and, for exit-node candidates, the /0 exit routes). +func (m *Mutation) UpsertPeer(n tailcfg.NodeView) { + m.upsertPeer(peerViewOf(n)) +} + +// peerViewOf reduces a tailcfg.NodeView to the routing-relevant +// peerView. +// +// It mirrors the peer and prefix filtering in nmcfg.WGCfg: peers we +// cannot communicate with (expired, or predating both DERP and disco) +// contribute no prefixes. They remain tracked by ID and key so that a +// later update can make them routable again. AllowedIPs is the sole +// source of prefixes; an address absent from AllowedIPs is not +// routable. The self-vs-route split mirrors nmcfg's cidrIsSubnet: +// single Tailscale IPs are never subnets, so a VIP service address +// hosted by the peer lands in SelfAddrs and stays routable without +// Prefs.RouteAll. +func peerViewOf(n tailcfg.NodeView) peerView { + pv := peerView{ + ID: n.ID(), + Key: n.Key(), + Jailed: n.IsJailed(), + MasqAddr4: n.SelfNodeV4MasqAddrForThisPeer().Get(), + MasqAddr6: n.SelfNodeV6MasqAddrForThisPeer().Get(), + } + if n.Expired() { + return pv + } + if n.DiscoKey().IsZero() && n.HomeDERP() == 0 && !n.IsWireGuardOnly() { + return pv + } + for _, aip := range n.AllowedIPs().All() { + isSelf := aip.IsSingleIP() && tsaddr.IsTailscaleIP(aip.Addr()) || + views.SliceContains(n.Addresses(), aip) + if isSelf { + pv.SelfAddrs = append(pv.SelfAddrs, aip) + } else { + pv.Routes = append(pv.Routes, aip) + } + } + return pv +} + +// upsertPeer stages an add or update of a peer from an +// already-reduced view. +func (m *Mutation) upsertPeer(p peerView) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opUpsert, peer: p}) +} + +// RemovePeer stages the removal of a peer. +func (m *Mutation) RemovePeer(id tailcfg.NodeID) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opRemove, id: id}) +} + +// SetPrefs stages a prefs update. +func (m *Mutation) SetPrefs(p Prefs) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opPrefs, prefs: p}) +} + +// SetTailnetConfig stages a tailnet config update. +func (m *Mutation) SetTailnetConfig(c TailnetConfig) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opConfig, cfg: c}) +} + +// SetScore stages a score update for the given peer and prefix. +// Scores are used to pick the outbound peer when multiple peers +// advertise the same prefix: highest score wins, with ties broken in +// a consistent manner. The default score is zero. Scores for a peer +// are dropped when the peer is removed. +// +// The plan is for feature/routecheck to do the route probing +// (reachability, latency, whatnot) and tweak the scores in some way, +// with details TBD. And as of 2026-07-10, the server never sends two +// nodes with the same AllowedIP CIDR anyway. But that will be +// changing and this code is preparing for that, letting the client +// make the decision about which of multiple peer candidates to use +// for a given route. +func (m *Mutation) SetScore(id tailcfg.NodeID, pfx netip.Prefix, score int) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opScore, id: id, pfx: normalizePrefix(pfx), score: score}) +} + +// SetExtraAllowedIPs stages a wholesale replacement of the extra +// allowed IPs: additional prefixes, keyed by node ID, that each peer +// may originate traffic from and that outbound traffic to should be +// sent to that peer. Extra prefixes appear in the outbound table and +// in [RouteManager.PeerAllowedIPs], but never in the OS route set. +// (In Tailscale they carry the conn25 extension's Transit IPs, which +// must reach WireGuard but not the OS routing table.) +// +// An entry for an unknown node ID is retained and takes effect if a +// peer with that ID is later upserted. Entries are dropped only by a +// later SetExtraAllowedIPs that omits them, not by peer removal. +// +// The caller must not mutate extras or its values after the call, and +// each prefix list must be in a stable order across calls so that +// unchanged entries are detected as no-ops. +func (m *Mutation) SetExtraAllowedIPs(extras map[tailcfg.NodeID][]netip.Prefix) { + m.checkOpen() + m.ops = append(m.ops, stagedOp{kind: opExtras, extras: extras}) +} + +// Discard abandons the mutation without applying any staged +// operations. +func (m *Mutation) Discard() { + m.checkOpen() + m.done = true + m.ops = nil +} + +// Commit applies the staged operations, publishes any changed +// snapshots, and reports what changed. +// +// Commit panics if this Mutation overlapped another Begin/Commit, +// which indicates a caller bug: callers are required to serialize +// mutations. +func (m *Mutation) Commit() Result { + m.checkOpen() + m.done = true + rm := m.rm + if !rm.txGen.CompareAndSwap(m.gen, m.gen+1) { + panic("routemanager: concurrent Begin/Commit detected: caller must serialize mutations") + } + rm.mu.Lock() + defer rm.mu.Unlock() + + if len(m.ops) == 0 { + // Nothing was staged, so nothing can have changed; return + // before allocating any of the diff-tracking state below. + return Result{} + } + + var res Result + dirty := make(set.Set[netip.Prefix]) + fullRebuild := false + + // before records each affected peer's key and allowed prefixes + // as of the start of the commit, so Result.AllowedIPs can be + // computed by comparison afterwards. Each peer is snapshotted + // the first time an op touches it (or on the first prefs or + // config change, which can affect every peer), which is always + // before any of its state has been mutated. + type peerBefore struct { + key key.NodePublic + pfxs []netip.Prefix // nil if the peer had no allowed prefixes + existed bool + } + var before map[tailcfg.NodeID]peerBefore + snapshot := func(id tailcfg.NodeID) { + if _, ok := before[id]; ok { + return + } + var pb peerBefore + if p, ok := rm.peers[id]; ok { + pb.existed = true + pb.key = p.Key + pb.pfxs, _ = rm.peerAllowedIPsLocked(id) + } + mak.Set(&before, id, pb) + } + // TODO(bradfitz): snapshotting all peers on any prefs or tailnet + // config change is a temporary lazy hack that makes those commits + // O(all peers). Each such change only affects a knowable subset: + // an exit node change affects only the old and new exit node, and + // a RouteAll change affects only the peers currently contributing + // non-self routes, which we could track incrementally. We + // shouldn't need O(all peers) work here except once at startup. + snapshotAll := func() { + for id := range rm.peers { + snapshot(id) + } + } + + for _, op := range m.ops { + switch op.kind { + case opUpsert: + snapshot(op.peer.ID) + rm.applyUpsert(op.peer, dirty) + res.PeersUpserted++ + case opRemove: + snapshot(op.id) + if rm.applyRemove(op.id, dirty) { + res.PeersRemoved++ + } + case opPrefs: + if rm.prefs != op.prefs { + snapshotAll() + rm.prefs = op.prefs + res.PrefsChanged = true + fullRebuild = true + } + case opConfig: + if rm.cfg != op.cfg { + snapshotAll() + rm.cfg = op.cfg + res.TailnetCfgChanged = true + fullRebuild = true + } + case opScore: + sk := scoreKey{op.id, op.pfx} + old, had := rm.scores[sk] + if op.score == 0 { + if had { + delete(rm.scores, sk) + res.ScoresChanged++ + dirty.Add(op.pfx) + } + } else if !had || old != op.score { + rm.scores[sk] = op.score + res.ScoresChanged++ + dirty.Add(op.pfx) + } + case opExtras: + for id := range rm.extras { + snapshot(id) + } + for id := range op.extras { + snapshot(id) + } + if rm.applyExtras(op.extras, dirty) { + res.ExtrasChanged = true + } + } + } + + if fullRebuild { + rm.rebuildAll(&res) + } else if len(dirty) > 0 { + rm.applyDirty(dirty, &res) + } + + // Diff each snapshotted peer's allowed prefixes against the + // final working state to populate Result.AllowedIPs. Both + // sides are sorted, so slices.Equal detects no-ops. + for id, was := range before { + now, _ := rm.peerAllowedIPsLocked(id) + p, exists := rm.peers[id] + switch { + case !exists: + if was.pfxs != nil { + mak.Set(&res.AllowedIPs, was.key, nil) // nil signals deletion; see Result.AllowedIPs + } + case was.existed && was.key != p.Key: + if was.pfxs != nil { + mak.Set(&res.AllowedIPs, was.key, nil) // nil signals deletion; see Result.AllowedIPs + } + if now != nil { + mak.Set(&res.AllowedIPs, p.Key, now) + } + default: + if !slices.Equal(was.pfxs, now) { + mak.Set(&res.AllowedIPs, p.Key, now) + } + } + } + return res +} + +// normalizePrefix unmaps a 4-in-6 prefix address and masks off any +// non-address bits. +func normalizePrefix(p netip.Prefix) netip.Prefix { + return netip.PrefixFrom(p.Addr().Unmap(), p.Bits()).Masked() +} + +// contribs returns the normalized per-prefix contributions of p, +// skipping (with a log message) any prefix that arrives with +// non-address bits set, mirroring the defensive check in +// ipnlocal.peerRoutes. It includes the peer's extra allowed IPs, +// except for peers that contribute no addresses or routes of their +// own (expired or otherwise non-communicable peers, which mirrors +// nmcfg.WGCfg dropping such peers entirely). +func (rm *RouteManager) contribs(p peerView) map[netip.Prefix]contribKind { + c := make(map[netip.Prefix]contribKind, len(p.SelfAddrs)+len(p.Routes)) + add := func(pfx netip.Prefix, kind contribKind) { + pfx = netip.PrefixFrom(pfx.Addr().Unmap(), pfx.Bits()) + if mm := pfx.Masked(); pfx != mm { + rm.logf("routemanager: prefix %s from %s has non-address bits set; expected %s; skipping", pfx, p.Key.ShortString(), mm) + return + } + c[pfx] |= kind + } + for _, pfx := range p.SelfAddrs { + add(pfx, kindSelf) + } + for _, pfx := range p.Routes { + add(pfx, kindRoute) + } + if len(c) > 0 { + for _, pfx := range rm.extras[p.ID] { + add(pfx, kindExtra) + } + } + return c +} + +// applyExtras replaces the extra allowed IPs with newExtras, updating +// working state and dirty for every peer whose extras changed. It +// reports whether anything changed. +func (rm *RouteManager) applyExtras(newExtras map[tailcfg.NodeID][]netip.Prefix, dirty set.Set[netip.Prefix]) (changed bool) { + apply := func(id tailcfg.NodeID, pfxs []netip.Prefix) { + if slices.Equal(rm.extras[id], pfxs) { + return + } + changed = true + p, exists := rm.peers[id] + if !exists { + rm.updateExtras(id, pfxs) + return + } + oldC := rm.contribs(p) + rm.updateExtras(id, pfxs) + newC := rm.contribs(p) + for pfx, kind := range oldC { + if newC[pfx] != kind { + rm.dropContrib(id, pfx) + dirty.Add(pfx) + } + } + for pfx, kind := range newC { + if oldC[pfx] != kind { + rm.addContrib(id, pfx, kind) + dirty.Add(pfx) + } + } + } + for id := range rm.extras { + if _, ok := newExtras[id]; !ok { + apply(id, nil) + } + } + for id, pfxs := range newExtras { + apply(id, pfxs) + } + return changed +} + +// updateExtras stores or deletes the extras entry for id. +func (rm *RouteManager) updateExtras(id tailcfg.NodeID, pfxs []netip.Prefix) { + if len(pfxs) == 0 { + delete(rm.extras, id) + } else { + mak.Set(&rm.extras, id, pfxs) + } +} + +// applyUpsert updates working state for an upserted peer, adding any +// affected prefixes to dirty. +func (rm *RouteManager) applyUpsert(p peerView, dirty set.Set[netip.Prefix]) { + newC := rm.contribs(p) + old, had := rm.peers[p.ID] + attrsChanged := had && old.routeAttrs() != p.routeAttrs() + if !had || attrsChanged { + // Intern a fresh PeerRoute rather than mutating the old + // one, which published snapshots may still reference. + rm.routes[p.ID] = new(p.routeAttrs()) + } + if had && old.hasDataPlaneAttrs() { + rm.attrPeers.Add(-1) + } + if p.hasDataPlaneAttrs() { + rm.attrPeers.Add(1) + } + if had { + oldC := rm.contribs(old) + for pfx, kind := range oldC { + if newC[pfx] != kind { + rm.dropContrib(p.ID, pfx) + dirty.Add(pfx) + } else if attrsChanged { + dirty.Add(pfx) + } + } + for pfx, kind := range newC { + if oldC[pfx] != kind { + rm.addContrib(p.ID, pfx, kind) + dirty.Add(pfx) + } + } + } else { + for pfx, kind := range newC { + rm.addContrib(p.ID, pfx, kind) + dirty.Add(pfx) + } + } + rm.peers[p.ID] = p +} + +// applyRemove updates working state for a removed peer, adding its +// prefixes to dirty. It reports whether the peer was known. +func (rm *RouteManager) applyRemove(id tailcfg.NodeID, dirty set.Set[netip.Prefix]) bool { + old, had := rm.peers[id] + if !had { + return false + } + for pfx := range rm.contribs(old) { + rm.dropContrib(id, pfx) + dirty.Add(pfx) + } + delete(rm.peers, id) + delete(rm.routes, id) + if old.hasDataPlaneAttrs() { + rm.attrPeers.Add(-1) + } + for sk := range rm.scores { + if sk.id == id { + delete(rm.scores, sk) + } + } + return true +} + +func (rm *RouteManager) addContrib(id tailcfg.NodeID, pfx netip.Prefix, kind contribKind) { + nodes := rm.byPrefix[pfx] + if nodes == nil { + nodes = make(map[tailcfg.NodeID]contribKind) + rm.byPrefix[pfx] = nodes + } + nodes[id] = kind +} + +func (rm *RouteManager) dropContrib(id tailcfg.NodeID, pfx netip.Prefix) { + nodes := rm.byPrefix[pfx] + delete(nodes, id) + if len(nodes) == 0 { + delete(rm.byPrefix, pfx) + } +} + +// eligible reports whether id's contribution of pfx (of the given +// kind) should be reflected in the outbound table and in the peer's +// allowed source prefixes, per current prefs and tailnet config. +// Extra allowed IPs are always eligible here, but are excluded from +// the OS route set by [RouteManager.desiredFor]. +func (rm *RouteManager) eligible(id tailcfg.NodeID, pfx netip.Prefix, kind contribKind) bool { + if kind&kindSelf != 0 { + if !(pfx.Addr().Is4() && rm.cfg.DisableIPv4) { + return true + } + } + if kind&kindRoute != 0 { + if tsaddr.IsExitRoute(pfx) { + return rm.prefs.ExitNodeID != 0 && id == rm.prefs.ExitNodeID + } + return rm.prefs.RouteAll + } + return kind&kindExtra != 0 +} + +// desiredFor computes the desired output state for pfx from the +// current working state: the outbound winner (nil if the prefix has +// no outbound winner) and whether the prefix belongs in the OS route +// set. +func (rm *RouteManager) desiredFor(pfx netip.Prefix) (out *PeerRoute, os bool) { + var bestID tailcfg.NodeID + var bestScore int + for id, kind := range rm.byPrefix[pfx] { + if !rm.eligible(id, pfx, kind) { + continue + } + if rm.eligible(id, pfx, kind&^kindExtra) { + os = true + } + sc := rm.scores[scoreKey{id, pfx}] + if out == nil || sc > bestScore || (sc == bestScore && id < bestID) { + bestID, bestScore = id, sc + out = rm.routes[id] + } + } + return out, os +} + +// osClass classifies a prefix for the OS route set. +type osClass uint8 + +const ( + osPlain osClass = iota // installed as-is when eligible + osCGNAT // single CGNAT IPv4 addr; subject to /10 coarsening + osULA // single Tailscale ULA IPv6 addr; always coarsened +) + +func classify(pfx netip.Prefix) osClass { + if !pfx.IsSingleIP() { + return osPlain + } + if pfx.Addr().Is4() && tsaddr.CGNATRange().Contains(pfx.Addr()) { + return osCGNAT + } + if pfx.Addr().Is6() && tsaddr.TailscaleULARange().Contains(pfx.Addr()) { + return osULA + } + return osPlain +} + +func (rm *RouteManager) cgnatThreshold() int { + if rm.cfg.OneCGNAT { + return 1 + } + return cgnatThreshold +} + +// tableSet returns a table derived from t, in which pfx's presence +// matches want, reporting whether the result table differs from t. +func tableSet(t *bart.Lite, pfx netip.Prefix, want bool) (*bart.Lite, bool) { + has := t.Get(pfx) + if has == want { + return t, false + } + if want { + return t.InsertPersist(pfx), true + } + return t.DeletePersist(pfx), true +} + +// applyDirty incrementally updates the output snapshots for the dirty +// prefixes and publishes any that changed. +func (rm *RouteManager) applyDirty(dirty set.Set[netip.Prefix], res *Result) { + out := rm.outbound.Load() + osr := rm.osRoutes.Load() + var outChanged, osChanged bool + + var cgnatDirty []netip.Prefix + for pfx := range dirty { + want, wantOS := rm.desiredFor(pfx) + + if cur, ok := out.Get(pfx); (want != nil) != ok || (ok && cur != want) { + if want != nil { + out = out.InsertPersist(pfx, want) + } else { + out = out.DeletePersist(pfx) + } + outChanged = true + } + + switch classify(pfx) { + case osULA: + if wantOS { + rm.ulaPfxs.Add(pfx) + } else { + rm.ulaPfxs.Delete(pfx) + } + case osCGNAT: + if wantOS { + rm.cgnatPfxs.Add(pfx) + } else { + rm.cgnatPfxs.Delete(pfx) + } + cgnatDirty = append(cgnatDirty, pfx) + case osPlain: + var ch bool + osr, ch = tableSet(osr, pfx, wantOS) + osChanged = osChanged || ch + } + } + + var ch bool + osr, ch = tableSet(osr, tsaddr.TailscaleULARange(), len(rm.ulaPfxs) > 0) + osChanged = osChanged || ch + + wantCoarse := len(rm.cgnatPfxs) > rm.cgnatThreshold() + if wantCoarse != rm.coarseCGNAT { + rm.coarseCGNAT = wantCoarse + for pfx := range rm.cgnatPfxs { + osr, ch = tableSet(osr, pfx, !wantCoarse) + osChanged = osChanged || ch + } + osr, ch = tableSet(osr, tsaddr.CGNATRange(), wantCoarse) + osChanged = osChanged || ch + } + for _, pfx := range cgnatDirty { + osr, ch = tableSet(osr, pfx, !rm.coarseCGNAT && rm.cgnatPfxs.Contains(pfx)) + osChanged = osChanged || ch + } + + rm.publish(out, outChanged, osr, osChanged, res) +} + +// rebuildAll recomputes the output snapshots from scratch and +// publishes those that changed. It runs on prefs or tailnet config +// changes, where O(number of prefixes) work is acceptable. +func (rm *RouteManager) rebuildAll(res *Result) { + out := &bart.Table[*PeerRoute]{} + osr := &bart.Lite{} + clear(rm.cgnatPfxs) + clear(rm.ulaPfxs) + + var plain []netip.Prefix + for pfx := range rm.byPrefix { + want, wantOS := rm.desiredFor(pfx) + if want == nil { + continue + } + out.Insert(pfx, want) + if !wantOS { + continue + } + switch classify(pfx) { + case osULA: + rm.ulaPfxs.Add(pfx) + case osCGNAT: + rm.cgnatPfxs.Add(pfx) + case osPlain: + plain = append(plain, pfx) + } + } + + for _, pfx := range plain { + osr.Insert(pfx) + } + if len(rm.ulaPfxs) > 0 { + osr.Insert(tsaddr.TailscaleULARange()) + } + rm.coarseCGNAT = len(rm.cgnatPfxs) > rm.cgnatThreshold() + if rm.coarseCGNAT { + osr.Insert(tsaddr.CGNATRange()) + } else { + for pfx := range rm.cgnatPfxs { + osr.Insert(pfx) + } + } + + rm.publish(out, !out.Equal(rm.outbound.Load()), + osr, !osr.Equal(rm.osRoutes.Load()), res) +} + +// publish stores the changed snapshots and records them in res. +func (rm *RouteManager) publish(out *bart.Table[*PeerRoute], outChanged bool, + osr *bart.Lite, osChanged bool, res *Result) { + if outChanged { + rm.outbound.Store(out) + res.OutboundChanged = true + res.Outbound = out + } + if osChanged { + rm.osRoutes.Store(osr) + res.OSRoutesChanged = true + res.OSRoutes = osr + } +} diff --git a/net/routemanager/routemanager_test.go b/net/routemanager/routemanager_test.go new file mode 100644 index 000000000..fececdd6c --- /dev/null +++ b/net/routemanager/routemanager_test.go @@ -0,0 +1,897 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package routemanager + +import ( + "net/netip" + "reflect" + "slices" + "testing" + + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/util/set" +) + +var ( + k1 = key.NewNode().Public() + k2 = key.NewNode().Public() + k3 = key.NewNode().Public() +) + +func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) } +func addr(s string) netip.Addr { return netip.MustParseAddr(s) } + +// peer1 is a basic peer with self addresses only. +func peer1() peerView { + return peerView{ + ID: 1, + Key: k1, + SelfAddrs: []netip.Prefix{ + pfx("100.64.0.1/32"), + pfx("fd7a:115c:a1e0::1/128"), + }, + } +} + +func peer2() peerView { + return peerView{ + ID: 2, + Key: k2, + SelfAddrs: []netip.Prefix{ + pfx("100.64.0.2/32"), + pfx("fd7a:115c:a1e0::2/128"), + }, + } +} + +// commit applies fn within a single mutation and returns the Result. +func commit(rm *RouteManager, fn func(*Mutation)) Result { + m := rm.Begin() + fn(m) + return m.Commit() +} + +func wantOutbound(t *testing.T, rm *RouteManager, ip string, want key.NodePublic, wantOK bool) { + t.Helper() + got, ok := rm.Outbound().Lookup(addr(ip)) + if ok != wantOK || (ok && got.Key != want) { + var gotKey key.NodePublic + if got != nil { + gotKey = got.Key + } + t.Errorf("Outbound lookup %s = %v, %v; want %v, %v", ip, gotKey.ShortString(), ok, want.ShortString(), wantOK) + } +} + +func wantOSRoutes(t *testing.T, rm *RouteManager, want ...string) { + t.Helper() + wantSet := make(set.Set[netip.Prefix]) + for _, s := range want { + wantSet.Add(pfx(s)) + } + got := make(set.Set[netip.Prefix]) + for p := range rm.OSRoutes().All() { + got.Add(p) + } + if !got.Equal(wantSet) { + t.Errorf("OSRoutes = %v; want %v", got.Slice(), wantSet.Slice()) + } +} + +func TestSelfAddrs(t *testing.T) { + rm := New(t.Logf) + res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + + if res.PeersUpserted != 1 { + t.Errorf("PeersUpserted = %d; want 1", res.PeersUpserted) + } + if !res.OutboundChanged || !res.OSRoutesChanged { + t.Errorf("changed flags = %v/%v; want all true", res.OutboundChanged, res.OSRoutesChanged) + } + if res.Outbound == nil || res.OSRoutes == nil { + t.Error("Result snapshot pointers should be non-nil when changed") + } + + wantOutbound(t, rm, "100.64.0.1", k1, true) + wantOutbound(t, rm, "fd7a:115c:a1e0::1", k1, true) + wantOutbound(t, rm, "100.64.0.2", key.NodePublic{}, false) + // Individual CGNAT /32 (below threshold) plus the coarse ULA range. + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") +} + +func TestRemovePeer(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + res := commit(rm, func(m *Mutation) { m.RemovePeer(1) }) + + if res.PeersRemoved != 1 { + t.Errorf("PeersRemoved = %d; want 1", res.PeersRemoved) + } + wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) + wantOSRoutes(t, rm) + if rm.ForTest().PeerCount() != 0 { + t.Errorf("PeerCount = %d; want 0", rm.ForTest().PeerCount()) + } + + // Removing an unknown peer is a no-op. + res = commit(rm, func(m *Mutation) { m.RemovePeer(42) }) + if res.PeersRemoved != 0 || res.OutboundChanged { + t.Errorf("remove of unknown peer: %+v", res) + } +} + +func TestExitNodeGating(t *testing.T) { + rm := New(t.Logf) + exitPeer := peer1() + exitPeer.Routes = tsaddr.ExitRoutes() + commit(rm, func(m *Mutation) { m.upsertPeer(exitPeer) }) + + // Not selected: no /0 entries anywhere. + wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") + + // Select it as exit node. + res := commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{ExitNodeID: 1}) }) + if !res.PrefsChanged || !res.OutboundChanged || !res.OSRoutesChanged { + t.Errorf("select exit node: %+v", res) + } + wantOutbound(t, rm, "8.8.8.8", k1, true) + wantOutbound(t, rm, "2001:db8::1", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0") + + // Deselect: /0s vanish. + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) }) + wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") + + // Selecting a peer that doesn't advertise /0s adds nothing. + commit(rm, func(m *Mutation) { + m.upsertPeer(peer2()) + m.SetPrefs(Prefs{ExitNodeID: 2}) + }) + wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) +} + +func TestRouteAll(t *testing.T) { + rm := New(t.Logf) + p := peer1() + p.Routes = []netip.Prefix{pfx("10.0.0.0/24")} + commit(rm, func(m *Mutation) { m.upsertPeer(p) }) + + // Subnet routes ignored until RouteAll. + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") + + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantOutbound(t, rm, "10.0.0.5", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "10.0.0.0/24") + + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) }) + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) +} + +func TestSharedSubnetRoute(t *testing.T) { + rm := New(t.Logf) + route := pfx("10.0.0.0/24") + pa, pb := peer1(), peer2() + pa.Routes = []netip.Prefix{route} + pb.Routes = []netip.Prefix{route} + commit(rm, func(m *Mutation) { + m.upsertPeer(pa) + m.upsertPeer(pb) + m.SetPrefs(Prefs{RouteAll: true}) + }) + + // Tie on score: lowest NodeID wins outbound. + wantOutbound(t, rm, "10.0.0.5", k1, true) + + // Raise peer 2's score: outbound flips, OS routes unchanged. + res := commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) + if res.ScoresChanged != 1 || !res.OutboundChanged || res.OSRoutesChanged { + t.Errorf("score change: %+v", res) + } + wantOutbound(t, rm, "10.0.0.5", k2, true) + + // Setting the same score again is a no-op. + res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) + if res.ScoresChanged != 0 || res.OutboundChanged { + t.Errorf("same score: %+v", res) + } + + // Resetting the score to zero restores the NodeID tie-break. + res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 0) }) + if res.ScoresChanged != 1 || !res.OutboundChanged { + t.Errorf("score reset: %+v", res) + } + wantOutbound(t, rm, "10.0.0.5", k1, true) + + // Resetting an already-absent score is a no-op. + res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 0) }) + if res.ScoresChanged != 0 { + t.Errorf("absent score reset: %+v", res) + } + + // Restore peer 2 as winner for the removal test below. + commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) + wantOutbound(t, rm, "10.0.0.5", k2, true) + + // Removing the winner falls back to the other advertiser. + commit(rm, func(m *Mutation) { m.RemovePeer(2) }) + wantOutbound(t, rm, "10.0.0.5", k1, true) + + // Peer 2's score was dropped on removal: re-adding it ties again + // and peer 1 wins by lower NodeID. + commit(rm, func(m *Mutation) { m.upsertPeer(pb) }) + wantOutbound(t, rm, "10.0.0.5", k1, true) +} + +func TestOneCGNAT(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { + m.SetTailnetConfig(TailnetConfig{OneCGNAT: true}) + m.upsertPeer(peer1()) + }) + // One CGNAT addr: not above threshold (1 > 1 is false), so + // still individual, mirroring ipnlocal.peerRoutes. + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") + + // Second CGNAT addr crosses the threshold: collapse to /10. + res := commit(rm, func(m *Mutation) { m.upsertPeer(peer2()) }) + if !res.OSRoutesChanged { + t.Errorf("crossing threshold: %+v", res) + } + wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") + + // Hot-path tables stay exact regardless of OS coarsening. + wantOutbound(t, rm, "100.64.0.2", k2, true) + wantOutbound(t, rm, "100.64.0.3", key.NodePublic{}, false) + + // Dropping back below the threshold un-coarsens. + commit(rm, func(m *Mutation) { m.RemovePeer(2) }) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") +} + +// TestFullRebuildWhileCoarse exercises the full-rebuild path (a prefs +// change) while CGNAT coarsening is already active. +func TestFullRebuildWhileCoarse(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { + m.SetTailnetConfig(TailnetConfig{OneCGNAT: true}) + m.upsertPeer(peer1()) + m.upsertPeer(peer2()) + }) + wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") + + res := commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + if !res.PrefsChanged || res.OSRoutesChanged { + t.Errorf("prefs change while coarse: %+v", res) + } + wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") + wantOutbound(t, rm, "100.64.0.2", k2, true) +} + +// TestSingleIPPlainRoute checks that a single-IP route outside the +// CGNAT and ULA ranges is programmed individually, not coarsened. +func TestSingleIPPlainRoute(t *testing.T) { + rm := New(t.Logf) + p := peer1() + p.Routes = []netip.Prefix{pfx("192.0.2.7/32")} + commit(rm, func(m *Mutation) { + m.upsertPeer(p) + m.SetPrefs(Prefs{RouteAll: true}) + }) + wantOutbound(t, rm, "192.0.2.7", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "192.0.2.7/32") +} + +func TestDisableIPv4(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { + m.SetTailnetConfig(TailnetConfig{DisableIPv4: true}) + m.upsertPeer(peer1()) + }) + wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) + wantOutbound(t, rm, "fd7a:115c:a1e0::1", k1, true) + wantOSRoutes(t, rm, "fd7a:115c:a1e0::/48") +} + +func TestKeyRotation(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + + rotated := peer1() + rotated.Key = k3 + res := commit(rm, func(m *Mutation) { m.upsertPeer(rotated) }) + if !res.OutboundChanged || res.OSRoutesChanged { + t.Errorf("key rotation: %+v", res) + } + wantOutbound(t, rm, "100.64.0.1", k3, true) +} + +// TestPeerRouteAttrs checks that the data-plane attributes (jailed, +// masquerade addresses) are published in the outbound table, that +// changing only an attribute republishes the peer's prefixes, and +// that previously published snapshots keep the old attributes. +func TestPeerRouteAttrs(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + + pr, ok := rm.Outbound().Lookup(addr("100.64.0.1")) + if !ok || pr.Jailed || pr.MasqAddr4.IsValid() || pr.MasqAddr6.IsValid() { + t.Fatalf("initial attrs = %+v, %v; want unjailed, no masq", pr, ok) + } + if rm.HasDataPlaneAttrs() { + t.Error("HasDataPlaneAttrs = true with no jailed or masqueraded peers") + } + oldOut := rm.Outbound() + + jailed := peer1() + jailed.Jailed = true + jailed.MasqAddr4 = addr("100.99.0.1") + res := commit(rm, func(m *Mutation) { m.upsertPeer(jailed) }) + if !res.OutboundChanged || res.OSRoutesChanged { + t.Errorf("attr change: %+v", res) + } + if res.AllowedIPs != nil { + t.Errorf("attr change reported AllowedIPs %v; attrs must not affect allowed prefixes", res.AllowedIPs) + } + pr, ok = rm.Outbound().Lookup(addr("100.64.0.1")) + if !ok || !pr.Jailed || pr.MasqAddr4 != addr("100.99.0.1") || pr.Key != k1 { + t.Fatalf("updated attrs = %+v, %v", pr, ok) + } + if !rm.HasDataPlaneAttrs() { + t.Error("HasDataPlaneAttrs = false with a jailed peer") + } + if pr, ok := oldOut.Lookup(addr("100.64.0.1")); !ok || pr.Jailed { + t.Errorf("old snapshot attrs = %+v, %v; want original unjailed entry", pr, ok) + } + + // An identical re-upsert is a no-op. + res = commit(rm, func(m *Mutation) { m.upsertPeer(jailed) }) + if res.OutboundChanged { + t.Errorf("identical attrs re-upsert changed outbound: %+v", res) + } + if !rm.HasDataPlaneAttrs() { + t.Error("HasDataPlaneAttrs = false after identical re-upsert of a jailed peer") + } + + // Removing the peer drops its data-plane attributes. + commit(rm, func(m *Mutation) { m.RemovePeer(1) }) + if rm.HasDataPlaneAttrs() { + t.Error("HasDataPlaneAttrs = true after removing the only jailed peer") + } +} + +func TestNoopCommit(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + + res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + if res.OutboundChanged || res.OSRoutesChanged { + t.Errorf("identical re-upsert changed tables: %+v", res) + } + if res.Outbound != nil || res.OSRoutes != nil { + t.Error("Result snapshot pointers should be nil when unchanged") + } + if res.PeersUpserted != 1 { + t.Errorf("PeersUpserted = %d; want 1", res.PeersUpserted) + } + + // Same prefs again: no rebuild reported. + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + if res.PrefsChanged { + t.Errorf("identical prefs reported changed: %+v", res) + } + + // An empty mutation commits fine. + res = commit(rm, func(m *Mutation) {}) + if !reflect.DeepEqual(res, Result{}) { + t.Errorf("empty commit: %+v", res) + } +} + +func TestUnmaskedPrefixSkipped(t *testing.T) { + rm := New(t.Logf) + p := peer1() + p.Routes = []netip.Prefix{netip.PrefixFrom(addr("10.0.0.5"), 24)} // non-address bits set + commit(rm, func(m *Mutation) { + m.upsertPeer(p) + m.SetPrefs(Prefs{RouteAll: true}) + }) + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) +} + +func TestSnapshotImmutability(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + oldOut := rm.Outbound() + + commit(rm, func(m *Mutation) { m.RemovePeer(1) }) + + // The old snapshot still sees the removed peer. + if _, ok := oldOut.Lookup(addr("100.64.0.1")); !ok { + t.Error("old outbound snapshot lost entry after later commit") + } + // And the new one doesn't. + wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) +} + +func TestDiscard(t *testing.T) { + rm := New(t.Logf) + m := rm.Begin() + m.upsertPeer(peer1()) + m.Discard() + if rm.ForTest().PeerCount() != 0 { + t.Error("discarded mutation was applied") + } + // A new mutation works after a discard. + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + if rm.ForTest().PeerCount() != 1 { + t.Error("commit after discard failed") + } +} + +func TestConcurrentMutationPanics(t *testing.T) { + rm := New(t.Logf) + m1 := rm.Begin() + m2 := rm.Begin() + m1.upsertPeer(peer1()) + m1.Commit() + + defer func() { + if recover() == nil { + t.Error("overlapping Commit did not panic") + } + }() + m2.Commit() +} + +func TestFinishedMutationPanics(t *testing.T) { + rm := New(t.Logf) + m := rm.Begin() + m.Commit() + defer func() { + if recover() == nil { + t.Error("op on finished Mutation did not panic") + } + }() + m.upsertPeer(peer1()) +} + +func TestPeerModifyRoutes(t *testing.T) { + rm := New(t.Logf) + p := peer1() + p.Routes = []netip.Prefix{pfx("10.0.0.0/24")} + commit(rm, func(m *Mutation) { + m.upsertPeer(p) + m.SetPrefs(Prefs{RouteAll: true}) + }) + wantOutbound(t, rm, "10.0.0.5", k1, true) + + // Swap the advertised route for another. + p.Routes = []netip.Prefix{pfx("192.168.1.0/24")} + res := commit(rm, func(m *Mutation) { m.upsertPeer(p) }) + if !res.OutboundChanged { + t.Errorf("route swap: %+v", res) + } + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) + wantOutbound(t, rm, "192.168.1.5", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "192.168.1.0/24") +} + +// BenchmarkUpsertOnePeer measures the cost of a single-peer mutation +// against a manager already tracking many peers, the case the +// incremental design exists for. +func BenchmarkUpsertOnePeer(b *testing.B) { + rm := New(nil) + m := rm.Begin() + for i := range 10_000 { + id := tailcfg.NodeID(i + 1) + m.upsertPeer(peerView{ + ID: id, + Key: key.NewNode().Public(), + SelfAddrs: []netip.Prefix{ + netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 64, byte(i >> 8), byte(i)}), 32), + }, + }) + } + m.Commit() + + p := peerView{ + ID: 99_999, + Key: key.NewNode().Public(), + SelfAddrs: []netip.Prefix{pfx("100.100.1.1/32")}, + } + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + p.SelfAddrs[0] = netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 100, byte(i >> 8), byte(i)}), 32) + m := rm.Begin() + m.upsertPeer(p) + m.Commit() + } +} + +func TestUpsertPeerNodeView(t *testing.T) { + rm := New(t.Logf) + n := &tailcfg.Node{ + ID: 1, + Key: k1, + HomeDERP: 1, + Addresses: []netip.Prefix{ + pfx("100.64.0.1/32"), + pfx("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + pfx("100.64.0.1/32"), + pfx("fd7a:115c:a1e0::1/128"), + pfx("10.0.0.0/24"), + pfx("0.0.0.0/0"), + pfx("::/0"), + }, + IsJailed: true, + SelfNodeV4MasqAddrForThisPeer: new(addr("100.99.0.5")), + } + commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) + + // The data-plane attributes are carried through from the node. + if pr, ok := rm.Outbound().Lookup(addr("100.64.0.1")); !ok || !pr.Jailed || pr.MasqAddr4 != addr("100.99.0.5") || pr.MasqAddr6.IsValid() { + t.Errorf("attrs = %+v, %v; want jailed with v4 masq only", pr, ok) + } + + // Self addresses are live immediately; the subnet route and the + // exit routes were classified as routes and stay gated by prefs. + wantOutbound(t, rm, "100.64.0.1", k1, true) + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) + wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) + + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 1}) }) + wantOutbound(t, rm, "10.0.0.5", k1, true) + wantOutbound(t, rm, "8.8.8.8", k1, true) +} + +// TestUpsertPeerNodeViewVIPService checks that a single Tailscale IP +// in a peer's AllowedIPs but not in its Addresses (such as a VIP +// service address hosted by the peer) is classified as a self address +// and stays routable without Prefs.RouteAll, mirroring nmcfg's +// cidrIsSubnet. +func TestUpsertPeerNodeViewVIPService(t *testing.T) { + rm := New(t.Logf) + n := &tailcfg.Node{ + ID: 1, + Key: k1, + HomeDERP: 1, + Addresses: []netip.Prefix{ + pfx("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{ + pfx("100.64.0.1/32"), + pfx("100.100.5.5/32"), // VIP service address + pfx("192.168.1.99/32"), // single non-Tailscale IP: a subnet route + pfx("10.0.0.0/24"), + }, + } + commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) + + // With RouteAll off, the node address and the VIP service address + // are routable, but the subnet routes are not. + wantOutbound(t, rm, "100.64.0.1", k1, true) + wantOutbound(t, rm, "100.100.5.5", k1, true) + wantOutbound(t, rm, "192.168.1.99", key.NodePublic{}, false) + wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) + wantOSRoutes(t, rm, "100.64.0.1/32", "100.100.5.5/32") + + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantOutbound(t, rm, "192.168.1.99", k1, true) + wantOutbound(t, rm, "10.0.0.5", k1, true) +} + +// TestUpsertPeerNodeViewEmptyAllowedIPs checks that AllowedIPs is +// the sole source of prefixes: a peer whose AllowedIPs is empty is +// not routable even if it has addresses, matching nmcfg.WGCfg. +func TestUpsertPeerNodeViewEmptyAllowedIPs(t *testing.T) { + rm := New(t.Logf) + n := &tailcfg.Node{ + ID: 1, + Key: k1, + HomeDERP: 1, + Addresses: []netip.Prefix{pfx("100.64.0.1/32")}, + } + commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) + wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) + wantOSRoutes(t, rm) + if _, ok := rm.PeerAllowedIPs(1); ok { + t.Error("PeerAllowedIPs = ok; want !ok for peer with no AllowedIPs") + } +} + +// TestUpsertPeerNodeViewIneligible checks that peers we cannot +// communicate with (expired, or predating both DERP and disco) are +// tracked but contribute no prefixes, mirroring nmcfg.WGCfg's peer +// filtering, and that a later update restores them. +func TestUpsertPeerNodeViewIneligible(t *testing.T) { + base := func() *tailcfg.Node { + return &tailcfg.Node{ + ID: 1, + Key: k1, + HomeDERP: 1, + Addresses: []netip.Prefix{pfx("100.64.0.1/32")}, + AllowedIPs: []netip.Prefix{ + pfx("100.64.0.1/32"), + }, + } + } + + for _, tc := range []struct { + name string + mod func(*tailcfg.Node) + }{ + {"expired", func(n *tailcfg.Node) { n.Expired = true }}, + {"noDERPOrDisco", func(n *tailcfg.Node) { n.HomeDERP = 0 }}, + } { + t.Run(tc.name, func(t *testing.T) { + rm := New(t.Logf) + n := base() + tc.mod(n) + commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) + wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) + wantOSRoutes(t, rm) + if _, ok := rm.PeerAllowedIPs(1); ok { + t.Error("PeerAllowedIPs = ok; want !ok for ineligible peer") + } + + // An update that clears the condition restores the peer. + commit(rm, func(m *Mutation) { m.UpsertPeer(base().View()) }) + wantOutbound(t, rm, "100.64.0.1", k1, true) + }) + } +} + +// wantChangedAllowedIPs checks res.AllowedIPs against want, where +// want maps each expected key to its expected new prefixes (nil for +// "no allowed prefixes"). +func wantChangedAllowedIPs(t *testing.T, res Result, want map[key.NodePublic][]string) { + t.Helper() + wantMap := make(map[key.NodePublic][]netip.Prefix) + for k, ss := range want { + var pfxs []netip.Prefix + for _, s := range ss { + pfxs = append(pfxs, pfx(s)) + } + tsaddr.SortPrefixes(pfxs) + wantMap[k] = pfxs + } + if len(want) == 0 { + if res.AllowedIPs != nil { + t.Errorf("Result.AllowedIPs = %v; want nil", res.AllowedIPs) + } + return + } + if !reflect.DeepEqual(res.AllowedIPs, wantMap) { + t.Errorf("Result.AllowedIPs = %v; want %v", res.AllowedIPs, wantMap) + } +} + +func TestResultAllowedIPs(t *testing.T) { + rm := New(t.Logf) + + // A new peer appears in the map with its prefixes. + res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, + }) + + // An identical re-upsert reports nothing. + res = commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + wantChangedAllowedIPs(t, res, nil) + + // Add a second peer advertising a subnet route and the exit + // routes; with default prefs only its self addresses count. + p2 := peer2() + p2.Routes = []netip.Prefix{pfx("10.0.0.0/24"), pfx("0.0.0.0/0"), pfx("::/0")} + res = commit(rm, func(m *Mutation) { m.upsertPeer(p2) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128"}, + }) + + // RouteAll adds the subnet route to peer 2 only; peer 1 has no + // routes, so it does not appear. + res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24"}, + }) + + // Selecting peer 2 as exit node adds the /0s to it only. + res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 2}) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24", "0.0.0.0/0", "::/0"}, + }) + + // Deselecting removes them again. + res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24"}, + }) + + // A score change affects only the outbound winner, not any + // peer's allowed prefixes. + res = commit(rm, func(m *Mutation) { m.SetScore(2, pfx("10.0.0.0/24"), 100) }) + wantChangedAllowedIPs(t, res, nil) + + // A key rotation reports the old key with no prefixes and the + // new key with the peer's prefixes. + rotated := peer1() + rotated.Key = k3 + res = commit(rm, func(m *Mutation) { m.upsertPeer(rotated) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: nil, + k3: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, + }) + + // A removed peer reports its key with no prefixes. + res = commit(rm, func(m *Mutation) { m.RemovePeer(2) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k2: nil, + }) + + // A prefs change combined with an upsert of a brand-new peer in + // the same commit reports both correctly: peer 1 gains nothing + // from RouteAll going away (it has no routes), and the new peer + // appears with its prefixes. + p4 := peerView{ + ID: 4, + Key: key.NewNode().Public(), + SelfAddrs: []netip.Prefix{pfx("100.64.0.4/32")}, + } + res = commit(rm, func(m *Mutation) { + m.SetPrefs(Prefs{}) + m.upsertPeer(p4) + }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + p4.Key: {"100.64.0.4/32"}, + }) +} + +func TestPeerAllowedIPs(t *testing.T) { + rm := New(t.Logf) + p := peer1() + p.Routes = []netip.Prefix{pfx("10.0.0.0/24"), pfx("0.0.0.0/0"), pfx("::/0")} + commit(rm, func(m *Mutation) { m.upsertPeer(p) }) + + wantAllowed := func(want ...string) { + t.Helper() + var wantPfxs []netip.Prefix + for _, s := range want { + wantPfxs = append(wantPfxs, pfx(s)) + } + tsaddr.SortPrefixes(wantPfxs) + got, ok := rm.PeerAllowedIPs(1) + if len(want) == 0 { + if ok { + t.Errorf("PeerAllowedIPs = %v; want !ok", got) + } + return + } + if !ok || !slices.Equal(got, wantPfxs) { + t.Errorf("PeerAllowedIPs = %v, %v; want %v", got, ok, wantPfxs) + } + } + + // Default prefs: self addresses only. + wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128") + + // RouteAll adds the subnet route but not the exit routes. + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128", "10.0.0.0/24") + + // Selecting the peer as exit node adds the /0s. + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 1}) }) + wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128", "10.0.0.0/24", "0.0.0.0/0", "::/0") + + // Unknown peers report !ok. + if _, ok := rm.PeerAllowedIPs(42); ok { + t.Error("PeerAllowedIPs(42) = ok; want !ok") + } + + // A removed peer reports !ok. + commit(rm, func(m *Mutation) { m.RemovePeer(1) }) + wantAllowed() +} + +func TestExtraAllowedIPs(t *testing.T) { + rm := New(t.Logf) + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + + // Extras appear in the outbound table and in PeerAllowedIPs, + // but not in the OS route set, even for prefixes (like the + // CGNAT one here) whose class would otherwise be coarsened. + res := commit(rm, func(m *Mutation) { + m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ + 1: {pfx("fe80::1234/128"), pfx("100.100.100.100/32")}, + }) + }) + if !res.ExtrasChanged { + t.Error("ExtrasChanged = false; want true") + } + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::1234/128", "100.100.100.100/32"}, + }) + wantOutbound(t, rm, "fe80::1234", k1, true) + wantOutbound(t, rm, "100.100.100.100", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") + + // An identical re-set is a no-op. + res = commit(rm, func(m *Mutation) { + m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ + 1: {pfx("fe80::1234/128"), pfx("100.100.100.100/32")}, + }) + }) + if res.ExtrasChanged || res.OutboundChanged || res.AllowedIPs != nil { + t.Errorf("no-op re-set changed something: %+v", res) + } + + // Replacing the set drops the old prefixes and adds the new one. + res = commit(rm, func(m *Mutation) { + m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ + 1: {pfx("fe80::5678/128")}, + }) + }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::5678/128"}, + }) + wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) + wantOutbound(t, rm, "fe80::5678", k1, true) + + // Clearing the set removes the remaining extra. + res = commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(nil) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, + }) + wantOutbound(t, rm, "fe80::5678", key.NodePublic{}, false) +} + +func TestExtraAllowedIPsPeerLifecycle(t *testing.T) { + rm := New(t.Logf) + + // Extras for a peer that doesn't exist yet have no visible + // effect until the peer is upserted. + res := commit(rm, func(m *Mutation) { + m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ + 1: {pfx("fe80::1234/128")}, + }) + }) + if !res.ExtrasChanged || res.OutboundChanged || res.AllowedIPs != nil { + t.Errorf("extras for unknown peer: %+v", res) + } + wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) + + res = commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ + k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::1234/128"}, + }) + wantOutbound(t, rm, "fe80::1234", k1, true) + + // Removing the peer removes its extras from the tables, but the + // stored entry survives and re-applies if the peer comes back. + res = commit(rm, func(m *Mutation) { m.RemovePeer(1) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{k1: nil}) + wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) + + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + wantOutbound(t, rm, "fe80::1234", k1, true) + + // A non-communicable peer (no addresses or routes) contributes + // no prefixes at all, including its extras. + res = commit(rm, func(m *Mutation) { m.upsertPeer(peerView{ID: 1, Key: k1}) }) + wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{k1: nil}) + wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) + + // Extras survive a prefs-driven full rebuild. + commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) + commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) + wantOutbound(t, rm, "fe80::1234", k1, true) + wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") +}