cmd/containerboot: track peers from IPN bus updates, stop using netmap.NetworkMap

Some tests in another repo were broken by tailscale/tailscale#19607.
This fixes them, by finishing off the rest of the migration away from
netmap.NetworkMap on the IPN bus in containerboot.

Containerboot used to rebuild a full NetworkMap-shaped view while
reacting to IPN bus notifications. Now it insteads has its own
netmapState type (immutable) of exactly what it needs to track, and
sends those immutable values around, making cheap edits of new
immutable values when an IPN bus edit arrives.

This should make cmd/containerboot scale to much larger tailnets now too.

Fixes #19852
Fixes tailscale/corp#42347
Updates #12542

Change-Id: I88adaf061f85f677f954a764935e6654329d75a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-05-27 14:12:48 -07:00
committed by Brad Fitzpatrick
parent 80dc7a8d07
commit 364b952d62
15 changed files with 508 additions and 176 deletions
+21 -8
View File
@@ -5,6 +5,7 @@ package ipn
import (
"fmt"
"slices"
"strings"
"time"
@@ -35,15 +36,27 @@ const (
// ID tokens used by the Android client.
const GoogleIDTokenType = "ts_android_google_login"
var stateStrings = [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running",
}
func (s State) String() string {
return [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running"}[s]
return stateStrings[s]
}
// StateFromString parses s as a State string value.
func StateFromString(s string) (_ State, ok bool) {
i := slices.Index(stateStrings[:], s)
if i == -1 {
return NoState, false
}
return State(i), true
}
// EngineStatus contains WireGuard engine stats.
+34 -2
View File
@@ -1417,6 +1417,7 @@ func (b *LocalBackend) updateStatusLocked(sb *ipnstate.StatusBuilder) {
}
if nm != nil {
s.CertDomains = append([]string(nil), nm.DNS.CertDomains...)
s.ExtraRecords = append([]tailcfg.DNSRecord(nil), nm.DNS.ExtraRecords...)
s.MagicDNSSuffix = nm.MagicDNSSuffix()
if s.CurrentTailnet == nil {
s.CurrentTailnet = &ipnstate.TailnetStatus{}
@@ -2325,6 +2326,7 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
defer b.mu.Unlock()
cn := b.currentNode()
needsAuthReconfig := netmapDeltaNeedsAuthReconfig(cn, muts)
cn.UpdateNetmapDelta(muts)
// Dispatch Upsert/Remove per-peer to magicsock, and any per-field
@@ -2348,6 +2350,9 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
}
}
ms.UpdateNetmapDelta(muts)
if needsAuthReconfig {
b.authReconfigLocked()
}
// If auto exit nodes are enabled and our exit node went offline,
// we need to schedule picking a new one.
@@ -2411,6 +2416,33 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
return true
}
func netmapDeltaNeedsAuthReconfig(cn *nodeBackend, muts []netmap.NodeMutation) bool {
for _, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationUpsert:
old, ok := cn.NodeByID(m.Node.ID())
if !ok {
continue
}
if peerRouteConfigChanged(old, m.Node) {
return true
}
}
}
return false
}
func peerRouteConfigChanged(old, new tailcfg.NodeView) bool {
return old.Key() != new.Key() ||
old.DiscoKey() != new.DiscoKey() ||
!views.SliceEqual(old.AllowedIPs(), new.AllowedIPs()) ||
old.Expired() != new.Expired() ||
old.IsJailed() != new.IsJailed() ||
old.IsWireGuardOnly() != new.IsWireGuardOnly() ||
old.SelfNodeV4MasqAddrForThisPeer() != new.SelfNodeV4MasqAddrForThisPeer() ||
old.SelfNodeV6MasqAddrForThisPeer() != new.SelfNodeV6MasqAddrForThisPeer()
}
// UpdatePacketFilter implements [controlclient.PacketFilterUpdater].
//
// It is called by the controlclient when a MapResponse carries a new packet
@@ -2506,7 +2538,7 @@ func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutati
// [tailcfg.PeerChange] for use in [ipn.Notify.PeerChangedPatch]. Multiple
// mutations against the same node are merged into a single PeerChange.
//
// Add/Remove mutations are skipped (they ride
// Upsert/Remove mutations are skipped (they ride
// [ipn.Notify.PeersChanged]/[ipn.Notify.PeersRemoved]). Any other mutation
// type that doesn't fit a [tailcfg.PeerChange] causes ok=false; the caller
// should fall back to a full netmap rebuild.
@@ -5662,7 +5694,7 @@ func (b *LocalBackend) authReconfigLocked() {
cn := b.currentNode()
nm := cn.NetMap()
nm := cn.netMapWithPeers()
if nm == nil {
b.logf("[v1] authReconfig: netmap not yet valid. Skipping.")
return
+43
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"math/rand/v2"
"net/netip"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -1573,6 +1574,48 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
}
}
func TestEngineReconfigOnPeerRouteDelta(t *testing.T) {
connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true}
peerAddr := netip.MustParsePrefix("100.64.1.1/32")
vipAddr := netip.MustParsePrefix("100.99.99.99/32")
peer := makePeer(1, withName("node-1"), withAddresses(peerAddr))
peerStruct := peer.AsStruct()
peerStruct.AllowedIPs = []netip.Prefix{peerAddr}
peer = peerStruct.View()
nm := buildNetmapWithPeers(
makePeer(2, withName("node-2"), withAddresses(netip.MustParsePrefix("100.64.1.2/32"))),
peer,
)
lb, engine, cc := newLocalBackendWithMockEngineAndControl(t, false)
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(nm)
replacement := nm.Peers[0].AsStruct()
replacement.AllowedIPs = append(replacement.AllowedIPs, vipAddr)
if !lb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.NodeMutationUpsert{Node: replacement.View()}}) {
t.Fatal("UpdateNetmapDelta = false, want true")
}
cfg := engine.Config()
if cfg == nil {
t.Fatal("engine config is nil")
}
for _, peer := range cfg.Peers {
if peer.PublicKey != replacement.Key {
continue
}
if !slices.Contains(peer.AllowedIPs, vipAddr) {
t.Fatalf("peer AllowedIPs = %v; want %v", peer.AllowedIPs, vipAddr)
}
return
}
t.Fatalf("engine config missing peer %v", replacement.Key.ShortString())
}
// TestSendPreservesAuthURL tests that wgengine updates arriving in the middle of
// processing an auth URL doesn't result in the auth URL being cleared.
func TestSendPreservesAuthURL(t *testing.T) {
+3
View File
@@ -73,6 +73,9 @@ type Status struct {
// trailing periods, and without any "_acme-challenge." prefix.
CertDomains []string
// ExtraRecords contains extra DNS records to add to the DNS resolver.
ExtraRecords []tailcfg.DNSRecord
// Peer is the state of each peer, keyed by each peer's current public key.
Peer map[key.NodePublic]*PeerStatus