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>
1017 lines
30 KiB
Go
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
|
|
}
|
|
}
|