Processing a peer add/remove delta still materialized the full netmap (an O(n) slicesx.MapValues plus sort over all peers, at 10k+ peers in a large tailnet) twice per delta: once in UpdateNetmapDelta purely to hand the self node to Engine.SetSelfNode, and once in authReconfigLocked. Neither needs peers anymore. SetSelfNode gets the self node from the existing nodeBackend.Self accessor. authReconfigLocked only reads self-node fields (SelfNode, NodeKey, GetAddresses, HasCap) now that WireGuard peers ride the incremental route manager and per-peer config source, so it can use the peers-free NetMap accessor. That also makes nmcfg.WGCfg vestigial: since wgcfg.Config lost its Peers field, its peer walk existed only to emit the [v1] skip logs (expired peers, unselected exit nodes, unaccepted subnet routes), duplicating filtering the route manager already does. Delete the package and construct the two-field wgcfg.Config inline. The skip logs go away; if they're missed, the route manager can log them incrementally at upsert time instead of rescanning every peer on every reconfig. With this, the runtime.DidRange analysis (see the ts_rangehook test) shows a delta netmap update performing no O(n) range loops except updateRouteManagerExtras, and the delta phase of that test drops from 1.09s to 0.14s for 400 deltas at n=10000 (from 4.79s at the start of this effort, before the incremental route manager work). Updates #12542 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Ia0e03ef9db0c988790b2c29de1f0505305e93f58
1046 lines
32 KiB
Go
1046 lines
32 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 or the
|
|
// selected exit node does not resolve to a current peer.
|
|
// (Callers resolve ipn.Prefs's stable node ID to a NodeID.)
|
|
ExitNodeID tailcfg.NodeID
|
|
|
|
// ExitNodeSelected is whether the prefs select any exit node at
|
|
// all, even one that doesn't resolve to a current peer (in which
|
|
// case ExitNodeID is zero). When set, the exit routes are always
|
|
// in the OS route set: with a resolved exit node they carry its
|
|
// traffic, and without one they blackhole internet traffic
|
|
// rather than let it escape to the local network, per the
|
|
// [tailscale.com/ipn.Prefs.ExitNodeID] docs. MDM's "auto:any"
|
|
// placeholder relies on the blackhole while an exit node is
|
|
// still being chosen.
|
|
ExitNodeSelected bool
|
|
|
|
// RouteAll is whether advertised subnet routes (non-exit
|
|
// routes) from peers are accepted.
|
|
RouteAll bool
|
|
}
|
|
|
|
// 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 describes the peers whose allowed source prefixes
|
|
// changed in this commit. The map is nil when no peer's allowed
|
|
// prefixes changed.
|
|
AllowedIPs PeersWithRouteChanges
|
|
}
|
|
|
|
// PeersWithRouteChanges maps the public key of each peer whose
|
|
// allowed source prefixes changed 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; consumers
|
|
// should treat such peers as deleted. When a peer's key changes, the
|
|
// old key maps to nil and the new key to the peer's prefixes.
|
|
type PeersWithRouteChanges 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.
|
|
//
|
|
// 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. For the self-vs-route split,
|
|
// 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).
|
|
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 rm.prefs.ExitNodeSelected && tsaddr.IsExitRoute(pfx) {
|
|
// The exit routes stay in the OS route set as long as an
|
|
// exit node is selected, even with no eligible
|
|
// contributor, to blackhole rather than leak internet
|
|
// traffic. See [Prefs.ExitNodeSelected].
|
|
wantOS = true
|
|
}
|
|
|
|
if cur, ok := out.Get(pfx); (want != nil) != ok || (ok && cur != want) {
|
|
if want != nil {
|
|
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 rm.prefs.ExitNodeSelected {
|
|
// Blackhole (or carry) internet traffic while any exit node
|
|
// is selected, resolved or not. See [Prefs.ExitNodeSelected].
|
|
for _, pfx := range tsaddr.ExitRoutes() {
|
|
osr.Insert(pfx)
|
|
}
|
|
}
|
|
if len(rm.ulaPfxs) > 0 {
|
|
osr.Insert(tsaddr.TailscaleULARange())
|
|
}
|
|
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
|
|
}
|
|
}
|