diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 843f60336..bf9823cd3 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -633,6 +633,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo nb.ready() e.SetPeerByIPPacketFunc(b.lookupPeerByIP) + e.SetPeerConfigFunc(b.peerAllowedIPs) e.SetPeerForIPFunc(b.peerForIP) e.SetPeerSessionStateFunc(b.onPeerWireGuardState) e.SetNetLogSource(netLogNodeSource{b}) @@ -2462,7 +2463,8 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo // the full-netmap behavior of [tkaFilterNetmapLocked]. muts = b.tkaFilterDeltaMutsLocked(muts) needsAuthReconfig := netmapDeltaNeedsAuthReconfig(cn, muts) - cn.UpdateNetmapDelta(muts) + + changedAllowedIPs, _ := cn.UpdateNetmapDelta(muts) if buildfeatures.HasDrive { // Drive's lazy remotes-source caches its rebuild keyed by this // generation, so any delta — peer add/remove, address change, @@ -2504,20 +2506,27 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo } ms.UpdateNetmapDelta(muts) + // Sync the WireGuard device for exactly the peers whose allowed + // source prefixes changed, as computed by the route manager when + // the delta was applied above. Removed peers appear with a nil + // value and are removed from the device; SyncDevicePeer reads + // the live per-peer config, so only the keys matter here. + for k := range changedAllowedIPs { + b.e.SyncDevicePeer(k) + } + // Force a full authReconfig + SetSelfNode on any peer add or - // remove. netmapDeltaNeedsAuthReconfig only considered - // NodeMutationUpsert of already-known NodeIDs whose - // peerRouteConfigChanged, so brand-new peers and removes left - // e.lastCfgFull and wgdev's PeerLookupFunc closure stale, and - // outbound wgdev encryption missed those peers. authReconfig - // fixes the wireguard side; SetSelfNode refreshes the engine's - // cached self node. PeerForIP / lookupPeerByIP staleness was - // addressed separately in d4f2917c1b, which routes those lookups - // through nodeBackend's live data, and as part of the broader - // netmap.NetworkMap removal effort the engine no longer caches - // the netmap at all (see tailscale/corp#43394). As of 2026-06-24 - // the only remaining staleness this guards against is - // e.lastCfgFull and the wgdev peer set. + // remove. The WireGuard device itself no longer depends on this: + // the SyncDevicePeer calls above keep its peer set delta-correct, + // and its lazy peer creation reads live state via + // [wgengine.Engine.SetPeerConfigFunc]. What still rides + // authReconfig is everything else derived from the full peer set: + // OS routes (router.Config), the quad-100 resolver's MagicDNS + // hosts map (dnsConfigForNetmap), and tstun's per-peer config + // (masquerade addresses and jailed peers, via SetWGConfig). Once + // those become delta-aware too, this can be gated on the route + // manager's OS-routes changes and the tstun-relevant fields + // instead of firing on every peer change. needsAuthReconfig = needsAuthReconfig || peersUpsertedOrRemoved if needsAuthReconfig { if peersUpsertedOrRemoved { @@ -6126,12 +6135,21 @@ func (b *LocalBackend) authReconfigLocked() { } oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.NetMon.Get(), b.sys.ControlKnobs(), version.OS()) - cn.updateRouteManagerPrefs(routePrefs{ + // Sync the WireGuard device for any peers whose allowed source + // prefixes changed with the new prefs, such as the old and new + // exit node when the selection changes. The Reconfig below still + // converges every peer via its full device sync, but this + // incremental sync is what will remain once the full reconfig is + // gated on actual router/DNS changes. + changedAllowedIPs := cn.updateRouteManagerPrefs(routePrefs{ ExitNodeID: prefs.ExitNodeID(), ExitNodeSelected: prefs.ExitNodeID() != "" || prefs.ExitNodeIP().IsValid(), RouteAll: flags&netmap.AllowSubnetRoutes != 0, OneCGNAT: oneCGNATRoute, }) + for k := range changedAllowedIPs { + b.e.SyncDevicePeer(k) + } rcfg := b.routerConfigLocked(cfg, prefs, nm, oneCGNATRoute) // Add these extra Allowed IPs after router configuration, because the expected diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 0de7a9f34..be7b6e0c2 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -2751,7 +2751,7 @@ func TestNotifyForSessionUserProfilesDedupResetsOnSelfChange(t *testing.T) { // tests LocalBackend.updateNetmapDeltaLocked func TestUpdateNetmapDelta(t *testing.T) { b := newTestLocalBackend(t) - if b.currentNode().UpdateNetmapDelta(nil) { + if _, handled := b.currentNode().UpdateNetmapDelta(nil); handled { t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap") } @@ -2790,7 +2790,7 @@ func TestUpdateNetmapDelta(t *testing.T) { t.Fatal("netmap.MutationsFromMapResponse failed") } - if !b.currentNode().UpdateNetmapDelta(muts) { + if _, handled := b.currentNode().UpdateNetmapDelta(muts); !handled { t.Fatalf("updateNetmapDeltaLocked() = false, want true with new netmap") } diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 964b6402d..3aab1a402 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -267,6 +267,22 @@ func (nb *nodeBackend) NodeByKey(k key.NodePublic) (_ tailcfg.NodeID, ok bool) { return nid, ok } +// PeerAllowedIPs returns the prefixes from which the peer with the +// given public key is currently allowed to originate traffic, or +// ok=false if the key is unknown or the peer currently contributes no +// prefixes. +func (nb *nodeBackend) PeerAllowedIPs(k key.NodePublic) ([]netip.Prefix, bool) { + nb.mu.Lock() + defer nb.mu.Unlock() + id, ok := nb.nodeByKey[k] + if !ok { + return nil, false + } + // Holding nb.mu satisfies routeMgr's serialization requirement: + // all routeMgr mutations also run under nb.mu. + return nb.routeMgr.PeerAllowedIPs(id) +} + // NodeByWireGuardString returns the node ID of the peer whose // [key.NodePublic.WireGuardGoString] form is s (e.g. "peer(IMTB…r7lM)"). // ok is false if no current peer matches. @@ -793,11 +809,6 @@ func (nb *nodeBackend) updatePeersLocked() { rt.Commit() } -// updateRouteManagerPrefs pushes the routing-relevant prefs and the -// OneCGNAT decision into the route manager. The exit node arrives as -// a stable ID from prefs and is resolved to the current numeric node -// ID here; it resolves to zero (no exit node) if that peer is not in -// the netmap yet, in which case a later netmap update re-runs this. // routePrefs is the subset of routing-relevant prefs (and derived // state) that [nodeBackend.updateRouteManagerPrefs] pushes into the // RouteManager. @@ -821,7 +832,13 @@ type routePrefs struct { OneCGNAT bool } -func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) { +// updateRouteManagerPrefs pushes p into the route manager. +// +// It returns the peers whose allowed source prefixes changed as a +// result (for example the old and new exit node when the selection +// changes), as described by [routemanager.Result.AllowedIPs]. +// In particular, the value for a key will be nil when that peer was removed. +func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) (changedAllowedIPs map[key.NodePublic][]netip.Prefix) { nb.mu.Lock() defer nb.mu.Unlock() var exitID tailcfg.NodeID @@ -840,7 +857,8 @@ func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) { RouteAll: p.RouteAll, }) rt.SetTailnetConfig(routemanager.TailnetConfig{OneCGNAT: p.OneCGNAT}) - rt.Commit() + res := rt.Commit() + return res.AllowedIPs } // setPacketFilter stores the live packet filter rules and parsed @@ -872,11 +890,16 @@ func (nb *nodeBackend) mergeUserProfiles(profiles map[tailcfg.UserID]tailcfg.Use } } -func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) { +// UpdateNetmapDelta applies the given netmap mutations to the live +// peer state. It returns the peers whose allowed source prefixes +// changed (as described by [routemanager.Result.AllowedIPs]) so the +// caller can sync those peers to the WireGuard device, and reports +// whether it handled all of the mutations. +func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (changedAllowedIPs map[key.NodePublic][]netip.Prefix, handled bool) { nb.mu.Lock() defer nb.mu.Unlock() if nb.netMap == nil { - return false + return nil, false } // Locally cloned mutable nodes, to avoid calling AsStruct (clone) @@ -890,7 +913,10 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo // in place as the loop runs. And if we return false, we'll just // get a full netmap soon and reset all our state anyway. rt := nb.routeMgr.Begin() - defer rt.Commit() + defer func() { + res := rt.Commit() + changedAllowedIPs = res.AllowedIPs + }() for _, m := range muts { switch m := m.(type) { @@ -932,7 +958,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo nv, ok := nb.peers[nid] if !ok { // TODO(bradfitz): unexpected metric? - return false + return nil, false } n = nv.AsStruct() mak.Set(&mutableNodes, nv.ID(), n) @@ -943,7 +969,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo nb.peers[nid] = n.View() } nb.signalKeyWaitersForTestLocked() - return true + return nil, true } // unlockedNodesPermitted reports whether any peer with theUnsignedPeerAPIOnly bool set true has any of its allowed IPs diff --git a/ipn/ipnlocal/node_backend_test.go b/ipn/ipnlocal/node_backend_test.go index 6ca34c4d9..63f68fb6f 100644 --- a/ipn/ipnlocal/node_backend_test.go +++ b/ipn/ipnlocal/node_backend_test.go @@ -317,15 +317,21 @@ func TestNodeBackendRouteManager(t *testing.T) { wantPeerFor("8.8.8.8", tailcfg.NodeView{}) // exit node not selected // Selecting peer 2 as the exit node resolves its stable ID and - // installs its /0 routes. - nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "stable2", ExitNodeSelected: true}) + // installs its /0 routes. The commit reports peer 2's allowed + // prefixes as changed. + if changed := nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "stable2", ExitNodeSelected: true}); len(changed) != 1 || changed[p2.Key()] == nil { + t.Errorf("updateRouteManagerPrefs(exit=stable2) changed = %v; want just %v", changed, p2.Key()) + } wantPeerFor("8.8.8.8", p2) // A selected exit node that resolves to no current peer must // blackhole internet traffic, not fall back to "no exit node": // the default routes stay in the OS route set with no outbound - // peer to carry them. - nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "no-such-node", ExitNodeSelected: true}) + // peer to carry them. Peer 2's allowed prefixes lose the /0s, + // which the commit reports. + if changed := nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "no-such-node", ExitNodeSelected: true}); len(changed) != 1 || changed[p2.Key()] == nil { + t.Errorf("updateRouteManagerPrefs(exit=unresolved) changed = %v; want just %v", changed, p2.Key()) + } wantPeerFor("8.8.8.8", tailcfg.NodeView{}) if !nb.routeMgr.OSRoutes().Get(netip.MustParsePrefix("0.0.0.0/0")) { t.Error("unresolved exit node: OSRoutes missing 0.0.0.0/0 blackhole route") @@ -339,12 +345,19 @@ func TestNodeBackendRouteManager(t *testing.T) { // Incremental deltas: add peer 3, remove peer 1. p3 := mkPeer(3, "stable3", "100.64.0.3/32") - if !nb.UpdateNetmapDelta([]netmap.NodeMutation{ + changed, handled := nb.UpdateNetmapDelta([]netmap.NodeMutation{ netmap.NodeMutationUpsert{Node: p3}, netmap.MakeNodeMutationRemove(1), - }) { + }) + if !handled { t.Fatal("UpdateNetmapDelta not handled") } + if len(changed) != 2 || changed[p3.Key()] == nil { + t.Errorf("UpdateNetmapDelta changed = %v; want entries for %v and %v", changed, p3.Key(), p1.Key()) + } + if v, ok := changed[p1.Key()]; !ok || v != nil { + t.Errorf("UpdateNetmapDelta changed[%v] = %v, %v; want nil, true for removed peer", p1.Key(), v, ok) + } wantPeerFor("100.64.0.3", p3) wantPeerFor("100.64.0.1", tailcfg.NodeView{}) diff --git a/ipn/ipnlocal/resolve.go b/ipn/ipnlocal/resolve.go index debc6e09d..7fd1121d1 100644 --- a/ipn/ipnlocal/resolve.go +++ b/ipn/ipnlocal/resolve.go @@ -13,25 +13,38 @@ import ( "tailscale.com/wgengine" ) -// lookupPeerByIP returns the node public key for the peer that owns the -// given IP address. It is the fast path for [Engine.SetPeerByIPPacketFunc], -// handling exact-IP matches against node addresses; subnet routes and exit -// nodes are handled by a BART-based fallback in userspaceEngine that uses -// the wireguard-filtered peer list (see lastCfgFull). +// lookupPeerByIP returns the node public key for the peer that should +// handle traffic to the given IP address. It is installed as the +// [wgengine.Engine.SetPeerByIPPacketFunc] callback: exact node +// addresses hit the nodeByAddr fast path, and subnet routes and +// exit-node default routes fall back to the RouteManager's outbound +// table, so it stays correct under incremental netmap deltas. // -// It is called by wireguard-go on every outbound packet (not cached), so -// it must be fast. +// It is called by wireguard-go on every outbound packet (not cached), +// so it must be fast. func (b *LocalBackend) lookupPeerByIP(ip netip.Addr) (key.NodePublic, bool) { nb := b.currentNode() - nid, ok := nb.NodeByAddr(ip) - if !ok { - return key.NodePublic{}, false + if nid, ok := nb.NodeByAddr(ip); ok { + peer, ok := nb.NodeByID(nid) + if !ok { + return key.NodePublic{}, false + } + return peer.Key(), true } - peer, ok := nb.NodeByID(nid) - if !ok { - return key.NodePublic{}, false + if pr, ok := nb.routeMgr.Outbound().Lookup(ip); ok { + return pr.Key, true } - return peer.Key(), true + return key.NodePublic{}, false +} + +// peerAllowedIPs returns the prefixes from which the peer with the +// given public key is currently allowed to originate traffic, or +// ok=false if the peer is unknown (or currently routable via no +// prefix at all). It is installed as the +// [wgengine.Engine.SetPeerConfigFunc] callback, backing wireguard-go's +// lazy peer creation and per-delta peer sync. +func (b *LocalBackend) peerAllowedIPs(k key.NodePublic) (_ []netip.Prefix, ok bool) { + return b.currentNode().PeerAllowedIPs(k) } // resolveMagicDNS resolves a MagicDNS hostname to the owning node's IP diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index e768c70c5..9b738370a 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -2016,6 +2016,9 @@ func (e *mockEngine) InstallCaptureHook(packet.CaptureCallback) {} func (e *mockEngine) SetPeerByIPPacketFunc(func(netip.Addr) (_ key.NodePublic, ok bool)) {} func (e *mockEngine) SetPeerForIPFunc(func(netip.Addr) (_ wgengine.PeerForIP, ok bool)) {} +func (e *mockEngine) SetPeerConfigFunc(func(key.NodePublic) (allowedIPs []netip.Prefix, ok bool)) { +} +func (e *mockEngine) SyncDevicePeer(key.NodePublic) {} func (e *mockEngine) PeerKeyForIP(netip.Addr) (_ key.NodePublic, _ netip.Prefix, ok bool) { return key.NodePublic{}, netip.Prefix{}, false } diff --git a/wgengine/userspace.go b/wgengine/userspace.go index 7d289670a..068144a7b 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -122,6 +122,12 @@ type userspaceEngine struct { // for the cold-path control lookups (Ping, TSMP, pendopen, etc). peerForIP atomic.Pointer[func(netip.Addr) (_ PeerForIP, ok bool)] + // peerConfigFn, if non-nil, is the live per-peer allowed-IPs + // source installed via [userspaceEngine.SetPeerConfigFunc]. When + // set, wgdev's PeerLookupFunc queries it directly, so reconfigs + // no longer install per-config lookup closures. + peerConfigFn atomic.Pointer[func(key.NodePublic) (allowedIPs []netip.Prefix, ok bool)] + lastCfgFull wgcfg.Config lastRouter *router.Config lastDNSConfig dns.ConfigView // or invalid if none @@ -726,20 +732,84 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked() error { e.peerByIPRoute.Store(rt) e.logf("wgengine: Reconfig: configuring userspace WireGuard config (with %d peers)", len(full.Peers)) - if err := wgcfg.ReconfigDevice(e.wgdev, &full, e.logf); err != nil { + if e.peerConfigFn.Load() != nil { + // The device has a long-lived PeerLookupFunc backed by the + // live config source, so only the peer set needs syncing; + // there is no per-config lookup closure to (re)install, and + // no removed peer can be resurrected with stale state. + // + // TODO(bradfitz): remove this O(n peers) sync. It's redundant + // with the incremental SyncDevicePeer calls that LocalBackend + // makes for exactly the peers whose allowed IPs changed. It + // only remains because peer changes still force a full + // Reconfig; once that's gated on actual router/DNS changes, + // this sync (and full-config peer syncing generally) can go. + peers := make(map[device.NoisePublicKey][]netip.Prefix, len(full.Peers)) + for _, p := range full.Peers { + peers[p.PublicKey.Raw32()] = p.AllowedIPs + } + e.wgdev.RemoveMatchingPeers(func(pk device.NoisePublicKey) bool { + _, exists := peers[pk] + return !exists + }) + // Update AllowedIPs on any already-active peers whose config + // may have changed. Peers that don't exist yet will get the + // correct AllowedIPs from the device's PeerLookupFunc when + // they are lazily created. + for pk, allowedIPs := range peers { + if peer, ok := e.wgdev.LookupActivePeer(pk); ok { + peer.SetAllowedIPs(allowedIPs) + } + } + } else if err := wgcfg.ReconfigDevice(e.wgdev, &full, e.logf); err != nil { e.logf("wgdev.Reconfig: %v", err) return err } return nil } +// SetPeerConfigFunc implements [Engine.SetPeerConfigFunc]. It stores +// fn and installs a single wgdev PeerLookupFunc wrapping it, so +// lazily-created peers always get current allowed IPs and the lookup +// func never needs to be reinstalled as the peer set changes. +func (e *userspaceEngine) SetPeerConfigFunc(fn func(key.NodePublic) (allowedIPs []netip.Prefix, ok bool)) { + if fn == nil { + panic("SetPeerConfigFunc: nil fn") + } + e.peerConfigFn.Store(&fn) + e.wgdev.SetPeerLookupFunc(wgcfg.NewPeerLookupFunc(e.wgdev.Bind(), e.logf, func(pubk device.NoisePublicKey) ([]netip.Prefix, bool) { + return fn(key.NodePublicFromRaw32(mem.B(pubk[:]))) + })) +} + +// SyncDevicePeer implements [Engine.SyncDevicePeer]. +func (e *userspaceEngine) SyncDevicePeer(k key.NodePublic) { + fn := e.peerConfigFn.Load() + if fn == nil { + return + } + e.wgLock.Lock() + defer e.wgLock.Unlock() + allowedIPs, ok := (*fn)(k) + if !ok { + e.wgdev.RemovePeer(k.Raw32()) + return + } + if peer, ok := e.wgdev.LookupActivePeer(k.Raw32()); ok { + peer.SetAllowedIPs(allowedIPs) + } +} + // SetPeerByIPPacketFunc installs a callback used by wireguard-go to look up // which peer should handle an outbound packet by destination IP. // -// fn is an optional fast path for exact node-address matches (e.g. dst is a -// Tailscale IP). On miss (or if fn is nil), the engine's own BART table -// ([userspaceEngine.peerByIPRoute], built from the wireguard-filtered peer -// list) is consulted to handle subnet routes and exit-node default routes. +// If fn is non-nil it is authoritative: LocalBackend's implementation +// consults both the exact node-address fast path and the RouteManager's +// outbound table (covering subnet routes and exit-node default routes), +// and stays correct under incremental netmap deltas. The engine's own +// BART table ([userspaceEngine.peerByIPRoute], rebuilt only on full +// reconfigs) is used only when no fn is installed (e.g. engines running +// without a LocalBackend). // // [NewUserspaceEngine] installs a BART-only default at engine creation time, // so callers that don't call SetPeerByIPPacketFunc (e.g. those not running @@ -750,6 +820,7 @@ func (e *userspaceEngine) SetPeerByIPPacketFunc(fn func(netip.Addr) (_ key.NodeP if pk, ok := fn(dst); ok { return pk.Raw32(), true } + return device.NoisePublicKey{}, false } if rt := e.peerByIPRoute.Load(); rt != nil { if pk, ok := rt.Lookup(dst); ok { diff --git a/wgengine/wgcfg/device.go b/wgengine/wgcfg/device.go index 2963c344d..554cc9c1e 100644 --- a/wgengine/wgcfg/device.go +++ b/wgengine/wgcfg/device.go @@ -18,6 +18,27 @@ func NewDevice(tunDev tun.Device, bind conn.Bind, logger *device.Logger) *device return device.NewDevice(tunDev, bind, logger) } +// NewPeerLookupFunc returns a [device.PeerLookupFunc] that lazily +// creates peers using allowedIPs as the source of each peer's allowed +// IPs. The peer's endpoint is derived from its public key via bind. +func NewPeerLookupFunc(bind conn.Bind, logf logger.Logf, allowedIPs func(device.NoisePublicKey) ([]netip.Prefix, bool)) device.PeerLookupFunc { + return func(pubk device.NoisePublicKey) (_ *device.NewPeerConfig, ok bool) { + ips, ok := allowedIPs(pubk) + if !ok { + return nil, false + } + ep, err := bind.ParseEndpoint(fmt.Sprintf("%x", pubk[:])) + if err != nil { + logf("wgcfg: failed to parse endpoint for peer %x: %v", pubk[:8], err) + return nil, false + } + return &device.NewPeerConfig{ + AllowedIPs: ips, + Endpoint: ep, + }, true + } +} + // ReconfigDevice replaces the existing device configuration with cfg. // // Instead of using the UAPI text protocol, it uses the wireguard-go direct API diff --git a/wgengine/wgengine.go b/wgengine/wgengine.go index 7d389d64d..573bc06d4 100644 --- a/wgengine/wgengine.go +++ b/wgengine/wgengine.go @@ -197,6 +197,33 @@ type Engine interface { // look up which peer should handle an outbound packet by destination IP. SetPeerByIPPacketFunc(func(netip.Addr) (_ key.NodePublic, ok bool)) + // SetPeerConfigFunc installs the live source of per-peer WireGuard + // configuration: given a peer's public key, fn returns the prefixes + // the peer is currently allowed to originate traffic from, or + // ok=false if the peer is unknown (in which case it must not exist + // in the WireGuard device). The engine installs a single + // [device.PeerLookupFunc] wrapping fn, so lazily-created peers + // always see current state and the lookup func never needs to be + // reinstalled as peers come and go. + // + // It is expected to be called once during LocalBackend construction, + // before the first [Engine.Reconfig]. fn is called rarely (when + // wireguard-go first hears from a peer it doesn't have) and may + // acquire locks. + SetPeerConfigFunc(fn func(key.NodePublic) (allowedIPs []netip.Prefix, ok bool)) + + // SyncDevicePeer synchronizes the WireGuard device's state for a + // single peer with the config source installed via + // [Engine.SetPeerConfigFunc]: if the source no longer knows the + // peer, it is removed from the device; if the peer is active in the + // device, its allowed IPs are updated. It does O(1) work (plus the + // config source lookup) and is intended to be called for each peer + // added, updated, or removed by an incremental netmap delta, + // avoiding a full [Engine.Reconfig]. + // + // It is a no-op if no config source is installed. + SyncDevicePeer(key.NodePublic) + // SetNetLogSource installs the [NetLogSource] consulted by the // engine's network flow logger for node lookups and the current // audit logging identity.