Files
tailscale/types/netmap/nodemut.go
Brad Fitzpatrick ac0b15356d tailcfg, control/controlclient: start moving MapResponse.DefaultAutoUpdate to a nodeattr
And fix up the TestAutoUpdateDefaults integration tests as they
weren't testing reality: the DefaultAutoUpdate is supposed to only be
relevant on the first MapResponse in the stream, but the tests weren't
testing that. They were instead injecting a 2nd+ MapResponse.

This changes the test control server to add a hook to modify the first
map response, and then makes the test control when the node goes up
and down to make new map responses.

Also, the test now runs on macOS where the auto-update feature being
disabled would've previously t.Skipped the whole test.

Updates #11502

Change-Id: If2319bd1f71e108b57d79fe500b2acedbc76e1a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-11-25 10:45:34 -08:00

182 lines
5.2 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netmap
import (
"cmp"
"net/netip"
"reflect"
"slices"
"sync"
"time"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
)
// NodeMutation is the common interface for types that describe
// the change of a node's state.
type NodeMutation interface {
NodeIDBeingMutated() tailcfg.NodeID
Apply(*tailcfg.Node)
}
type mutatingNodeID tailcfg.NodeID
func (m mutatingNodeID) NodeIDBeingMutated() tailcfg.NodeID { return tailcfg.NodeID(m) }
// NodeMutationDERPHome is a NodeMutation that says a node
// has changed its DERP home region.
type NodeMutationDERPHome struct {
mutatingNodeID
DERPRegion int
}
func (m NodeMutationDERPHome) Apply(n *tailcfg.Node) {
n.HomeDERP = m.DERPRegion
}
// NodeMutationEndpoints is a NodeMutation that says a node's endpoints have changed.
type NodeMutationEndpoints struct {
mutatingNodeID
Endpoints []netip.AddrPort
}
func (m NodeMutationEndpoints) Apply(n *tailcfg.Node) {
n.Endpoints = slices.Clone(m.Endpoints)
}
// NodeMutationOnline is a NodeMutation that says a node is now online or
// offline.
type NodeMutationOnline struct {
mutatingNodeID
Online bool
}
func (m NodeMutationOnline) Apply(n *tailcfg.Node) {
n.Online = ptr.To(m.Online)
}
// NodeMutationLastSeen is a NodeMutation that says a node's LastSeen
// value should be set to the current time.
type NodeMutationLastSeen struct {
mutatingNodeID
LastSeen time.Time
}
func (m NodeMutationLastSeen) Apply(n *tailcfg.Node) {
n.LastSeen = ptr.To(m.LastSeen)
}
var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
var fields []reflect.StructField
rt := reflect.TypeFor[tailcfg.PeerChange]()
for i := range rt.NumField() {
fields = append(fields, rt.Field(i))
}
return fields
})
// NodeMutationsFromPatch returns the NodeMutations that
// p describes. If p describes something not yet supported
// by a specific NodeMutation type, it returns (nil, false).
func NodeMutationsFromPatch(p *tailcfg.PeerChange) (_ []NodeMutation, ok bool) {
if p == nil || p.NodeID == 0 {
return nil, false
}
var ret []NodeMutation
rv := reflect.ValueOf(p).Elem()
for i, sf := range peerChangeFields() {
if rv.Field(i).IsZero() {
continue
}
switch sf.Name {
default:
// Unhandled field.
return nil, false
case "NodeID":
continue
case "DERPRegion":
ret = append(ret, NodeMutationDERPHome{mutatingNodeID(p.NodeID), p.DERPRegion})
case "Endpoints":
ret = append(ret, NodeMutationEndpoints{mutatingNodeID(p.NodeID), slices.Clone(p.Endpoints)})
case "Online":
ret = append(ret, NodeMutationOnline{mutatingNodeID(p.NodeID), *p.Online})
case "LastSeen":
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(p.NodeID), *p.LastSeen})
}
}
return ret, true
}
// MutationsFromMapResponse returns all the discrete node mutations described
// by res. It returns ok=false if res contains any non-patch field as defined
// by mapResponseContainsNonPatchFields.
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
if now.IsZero() {
now = time.Now()
}
if mapResponseContainsNonPatchFields(res) {
return nil, false
}
// All that remains is PeersChangedPatch, OnlineChange, and LastSeenChange.
for _, p := range res.PeersChangedPatch {
deltas, ok := NodeMutationsFromPatch(p)
if !ok {
return nil, false
}
ret = append(ret, deltas...)
}
for nid, v := range res.OnlineChange {
ret = append(ret, NodeMutationOnline{mutatingNodeID(nid), v})
}
for nid, v := range res.PeerSeenChange {
if v {
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(nid), now})
}
}
slices.SortStableFunc(ret, func(a, b NodeMutation) int {
return cmp.Compare(a.NodeIDBeingMutated(), b.NodeIDBeingMutated())
})
return ret, true
}
// mapResponseContainsNonPatchFields reports whether res contains only "patch"
// fields set (PeersChangedPatch primarily, but also including the legacy
// PeerSeenChange and OnlineChange fields).
//
// It ignores any of the meta fields that are handled by PollNetMap before the
// peer change handling gets involved.
//
// The purpose of this function is to ask whether this is a tricky enough
// MapResponse to warrant a full netmap update. When this returns false, it
// means the response can be handled incrementally, patching up the local state.
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
return res.Node != nil ||
res.DERPMap != nil ||
res.DNSConfig != nil ||
res.Domain != "" ||
res.CollectServices != "" ||
res.PacketFilter != nil ||
res.PacketFilters != nil ||
res.UserProfiles != nil ||
res.Health != nil ||
res.DisplayMessages != nil ||
res.SSHPolicy != nil ||
res.TKAInfo != nil ||
res.DomainDataPlaneAuditLogID != "" ||
res.Debug != nil ||
res.ControlDialPlan != nil ||
res.ClientVersion != nil ||
res.Peers != nil ||
res.PeersRemoved != nil ||
// PeersChanged is too coarse to be considered a patch. Also, we convert
// PeersChanged to PeersChangedPatch in patchifyPeersChanged before this
// function is called, so it should never be set anyway. But for
// completedness, and for tests, check it too:
res.PeersChanged != nil ||
res.DeprecatedDefaultAutoUpdate != ""
}