tailscale/types/netmap/nodemut.go
Brad Fitzpatrick af2f228a18 ipn/ipnlocal, types/netmap, tsnet: filter unsigned peers on delta path
aa5da2e5f2 (in the 1.99.x dev series, unstable) introduced some bugs,
only some of which were later fixed. This fixed another. As of that
change, tkaFilterNetmapLocked ran only on full netmaps through
LocalBackend.setClientStatusLocked and not peer upserts via new or
changed peers. The later ae743642d9 fixed a regression in the
Engine layer but didn't fix the tkaFilter code from re-running on
upserts.

This add a tkaFilterDeltaMutsLocked pass before
nodeBackend.UpdateNetmapDelta. For each NodeMutationUpsert whose
peer fails the same signature check tkaFilterNetmapLocked applies,
rewrite the upsert in place into a NodeMutationRemove targeting the
same node ID, so magicsock's per-mutation dispatch and
nodeBackend.peers both drop the peer, matching the prior full-netmap
semantics.

New tsnet tests added:

  - TestTailnetLockFiltersUnsignedDeltaPeer covers the new-peer
    case.
  - TestTailnetLockFiltersUnsignedDeltaPeerReplacement covers the
    existing-peer-replacement case, to an empty signature.
  - TestTailnetLockFiltersDeltaPeerWithInvalidSignature like above
    but with a bogus signature.

Updates #12542
Updates tailscale/corp#43767

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib35d0391541fee654867c26489847dbc5b7e2ae8
2026-06-23 08:12:36 -07:00

217 lines
6.6 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmap
import (
"cmp"
"net/netip"
"reflect"
"slices"
"sync"
"time"
"tailscale.com/tailcfg"
)
// 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 = new(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 = new(m.LastSeen)
}
// NodeMutationUpsert is a NodeMutation that says a peer's full Node value
// should be inserted or replaced.
//
// Apply is a no-op: consumers of NodeMutationUpsert must type-switch to handle
// upserts by storing Node in their peer map.
type NodeMutationUpsert struct {
Node tailcfg.NodeView
}
func (m NodeMutationUpsert) NodeIDBeingMutated() tailcfg.NodeID { return m.Node.ID() }
func (m NodeMutationUpsert) Apply(*tailcfg.Node) {}
// NodeMutationRemove is a NodeMutation that says a peer has been removed.
// Apply is a no-op: consumers of NodeMutationRemove must type-switch to handle
// removes by deleting the node from their peer map.
type NodeMutationRemove struct {
mutatingNodeID
}
func (m NodeMutationRemove) Apply(*tailcfg.Node) {}
// MakeNodeMutationRemove returns a NodeMutationRemove that targets nodeID.
// It is the constructor for NodeMutationRemove for callers outside this
// package, since the embedded ID field is unexported.
func MakeNodeMutationRemove(nodeID tailcfg.NodeID) NodeMutationRemove {
return NodeMutationRemove{mutatingNodeID(nodeID)}
}
var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
var fields []reflect.StructField
rt := reflect.TypeFor[tailcfg.PeerChange]()
for field := range rt.Fields() {
fields = append(fields, field)
}
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-delta field as defined
// by mapResponseContainsNonPatchFields.
//
// Upserts and removes (from res.PeersChanged / res.PeersRemoved) are emitted
// as NodeMutationUpsert / NodeMutationRemove entries. A PeersChanged entry can
// be either a new peer or a full replacement for an existing peer that couldn't
// be represented as PeerChangedPatch. Callers must type-switch to handle those
// alongside field mutations.
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
if now.IsZero() {
now = time.Now()
}
if mapResponseContainsNonPatchFields(res) {
return nil, false
}
for _, id := range res.PeersRemoved {
ret = append(ret, NodeMutationRemove{mutatingNodeID(id)})
}
for _, n := range res.PeersChanged {
// Any n still in PeersChanged after patchifyPeersChanged is a
// truly-new (or replaced) peer.
ret = append(ret, NodeMutationUpsert{Node: n.View()})
}
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 any field
// that can't be expressed as a per-peer NodeMutation (including the new
// NodeMutationUpsert / NodeMutationRemove variants) or via the sibling narrow
// setter methods on the map-session backend (e.g. UpdatePacketFilter).
//
// When this returns true, the caller must fall back to rebuilding and
// dispatching a full NetworkMap. When it returns false, the response can be
// handled incrementally.
//
// PeersChanged, PeersRemoved, and PacketFilter(s) are intentionally not in
// this list: upserted/removed peers ride NodeMutationUpsert/Remove, packet
// filter updates are delivered via the backend's UpdatePacketFilter
// method, and UserProfile updates ride the backend's UpdateUserProfiles
// method.
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
return res.Node != nil ||
res.DERPMap != nil ||
res.DNSConfig != nil ||
res.Domain != "" ||
res.CollectServices != "" ||
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.DeprecatedDefaultAutoUpdate != ""
}