Files
tailscale/net/routemanager/routemanager.go
T
Brad FitzpatrickandBrad Fitzpatrick a5102d3fcb net/routemanager: add incremental route manager
Add a new RouteManager type that tracks per-peer self addresses and
advertised routes and incrementally maintains two read-only
snapshots: an IP-to-outbound-peer bart table carrying the per-peer
attributes the data plane needs (jailed state, masquerade addresses),
and a coarsened OS route set (including OneCGNAT consolidation).

Mutations are staged in a transaction (Begin/Commit) and applied to
the snapshots via bart's Persist methods, which path-copy only the
few trie nodes along the affected prefix, so a single-peer delta
costs a bounded amount of work independent of the number of peers,
instead of the O(n) full-world rebuild done today. Snapshots are
published via atomic pointer swap for lock-free reads on the hot
path, and Commit reports which peers' allowed IPs changed so callers
can sync wireguard-go incrementally. This is the same immutable value
snapshot pattern as used in the recent containerboot change,
364b952d62.

Nothing uses it yet; this is pulled out of a future change that wires
it into ipnlocal and wgengine, to make that PR smaller.

Updates #12542

Change-Id: Iccc5258024e6f90311835b79fd2d83b2adb0d09d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-10 16:57:09 -07:00

1017 lines
30 KiB
Go

// 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
}
}