state: classify map requests before broadcasting

Each request is reduced to the narrowest change it justifies, so a
keepalive or endpoint bump no longer resends the whole node to peers.

Updates #3417

0.29 pins an older tailscale where Hostinfo.NetInfo.PreferredDERP is an int,
so the DERPRegionID types are int here.

(cherry picked from commit 59f3ff12a7)
This commit is contained in:
Kristoffer Dalby
2026-09-10 11:15:07 +00:00
parent b8da1c2d6f
commit 8973347d22
6 changed files with 1240 additions and 216 deletions
+17
View File
@@ -187,6 +187,23 @@ func TestEndpointBroadcastWorthy(t *testing.T) {
newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN, tailcfg.EndpointLocal}, newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN, tailcfg.EndpointLocal},
want: true, want: true,
}, },
{
// Peers hold no endpoints for this node yet, and a
// suppressed delta is never resent, so the first set is
// the only one they would ever get.
name: "first endpoints ever, STUN only - broadcast",
stored: nil,
newEPs: []netip.AddrPort{stun},
newType: []tailcfg.EndpointType{tailcfg.EndpointSTUN},
want: true,
},
{
name: "no stored and none announced - suppress",
stored: nil,
newEPs: nil,
newType: nil,
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
+150 -4
View File
@@ -6,10 +6,78 @@ package state
import ( import (
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
// mapRequestDelta carries the classified facts extracted from one MapRequest
// against the currently-stored node. It separates the raw wire-level peer
// change (what the client actually sent) from the broadcast/persist/policy
// decisions made from it, so that each downstream decision (broadcast, persist,
// policy refresh, relation rebuild) sees only its own input.
//
// Construction happens once per MapRequest inside the NodeStore write callback
// (so comparisons run against serialized current state); classification happens
// after the write succeeds. Splitting these avoids redoing comparisons and
// avoids having the broadcast classifier depend on incidental branch order.
type mapRequestDelta struct {
// peerChange is the wire-level delta produced by
// [types.Node.PeerChangeFromMapRequest] - LastSeen is always stamped.
peerChange tailcfg.PeerChange
// hostinfoChanged reports whether anything in Hostinfo changed apart
// from PreferredDERP (tracked by derpChanged) and DERP latency jitter.
// It gates storing and persisting the new Hostinfo.
hostinfoChanged bool
// peerHostinfoChanged reports whether a Hostinfo field that peers read
// changed (see [peerHostinfo]). Only that forces a whole-node resend.
peerHostinfoChanged bool
// routesChanged reports whether announced routes (RoutableIPs)
// changed. Routes are policy and election inputs, so they are tracked
// on their own.
routesChanged bool
// derpChanged reports whether PreferredDERP changed. oldDERP/newDERP
// carry the values; DERP zero means "unchanged" on the wire, so
// clearing DERP to zero cannot be sent as a patch and forces a
// whole-peer update.
derpChanged bool
oldDERP, newDERP int
// endpointBroadcast reports whether the endpoint delta is worth fanning
// out (i.e., a newly-added useful non-STUN endpoint). Storage still
// happens regardless; this gates only broadcast.
endpointBroadcast bool
// keyChanged and discoKeyChanged report whether the node's wire keys
// changed. Key patches already carry the resulting endpoints/expiry,
// so a key change subsumes endpoint/DERP patches.
keyChanged bool
discoKeyChanged bool
// persistWorthy reports whether the request carries data that should
// hit the database. LastSeen-only updates are not persist-worthy.
persistWorthy bool
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler].
func (d mapRequestDelta) MarshalZerologObject(e *zerolog.Event) {
e.Bool("hostinfo.changed", d.hostinfoChanged).
Bool("hostinfo.peer_changed", d.peerHostinfoChanged).
Bool("routes.changed", d.routesChanged).
Bool("derp.changed", d.derpChanged).
Int("derp.old", d.oldDERP).
Int("derp.new", d.newDERP).
Bool("endpoint.broadcast", d.endpointBroadcast).
Bool("key.changed", d.keyChanged).
Bool("disco_key.changed", d.discoKeyChanged).
Bool("persist", d.persistWorthy)
}
// netInfoFromMapRequest determines the correct [tailcfg.NetInfo] to use. // netInfoFromMapRequest determines the correct [tailcfg.NetInfo] to use.
// Returns the [tailcfg.NetInfo] that should be used for this request. // Returns the [tailcfg.NetInfo] that should be used for this request.
func netInfoFromMapRequest( func netInfoFromMapRequest(
@@ -26,8 +94,8 @@ func netInfoFromMapRequest(
if currentHostinfo != nil && currentHostinfo.NetInfo != nil { if currentHostinfo != nil && currentHostinfo.NetInfo != nil {
log.Debug(). log.Debug().
Caller(). Caller().
Uint64("node.id", nodeID.Uint64()). Uint64(zf.NodeID, nodeID.Uint64()).
Int("preferredDERP", currentHostinfo.NetInfo.PreferredDERP). Int(zf.DERP, currentHostinfo.NetInfo.PreferredDERP).
Msg("using NetInfo from previous Hostinfo in MapRequest") Msg("using NetInfo from previous Hostinfo in MapRequest")
return currentHostinfo.NetInfo return currentHostinfo.NetInfo
@@ -43,9 +111,87 @@ func netInfoFromMapRequest(
log.Debug(). log.Debug().
Caller(). Caller().
Uint64("node.id", nodeID.Uint64()). Uint64(zf.NodeID, nodeID.Uint64()).
Str("node.hostname", hostname). Str(zf.Hostname, hostname).
Msg("node sent update but has no NetInfo in request or database") Msg("node sent update but has no NetInfo in request or database")
return nil return nil
} }
// hostinfoDERP returns the PreferredDERP value from a Hostinfo, or 0 when
// either pointer is nil. 0 is the wire "unchanged" sentinel.
func hostinfoDERP(hi *tailcfg.Hostinfo) int {
if hi == nil || hi.NetInfo == nil {
return 0
}
return hi.NetInfo.PreferredDERP
}
// hostinfoEqual reports whether two Hostinfo values are the same apart from
// PreferredDERP, which the caller tracks on its own, and DERP latency
// jitter, which [tailcfg.NetInfo.BasicallyEqual] skips.
func hostinfoEqual(oldHI, newHI *tailcfg.Hostinfo) bool {
if oldHI == nil || newHI == nil {
return oldHI == newHI
}
if !netInfoEqualIgnoringDERP(oldHI.NetInfo, newHI.NetInfo) {
return false
}
// NetInfo is compared above; drop it so nil-vs-empty inside it does
// not leak through reflect.DeepEqual.
oldCopy := *oldHI
oldCopy.NetInfo = nil
newCopy := *newHI
newCopy.NetInfo = nil
return oldCopy.Equal(&newCopy)
}
// peerHostinfo keeps the Hostinfo fields another node's client reads from
// a peer: what tailscale status shows, PeerAPI services, SSH known hosts,
// exit-node location, app-connector eligibility, and the routes this server
// feeds into policy. Everything else is stored but never fanned out, and a
// peer's NetInfo is never read at all.
func peerHostinfo(hi *tailcfg.Hostinfo) *tailcfg.Hostinfo {
if hi == nil {
return nil
}
return &tailcfg.Hostinfo{
Hostname: hi.Hostname,
OS: hi.OS,
Services: hi.Services,
SSH_HostKeys: hi.SSH_HostKeys,
Location: hi.Location,
AppConnector: hi.AppConnector,
RoutableIPs: hi.RoutableIPs,
}
}
// peerHostinfoEqual reports whether the fields peers read are unchanged.
func peerHostinfoEqual(oldHI, newHI *tailcfg.Hostinfo) bool {
return peerHostinfo(oldHI).Equal(peerHostinfo(newHI))
}
// netInfoEqualIgnoringDERP compares two NetInfo values via
// [tailcfg.NetInfo.BasicallyEqual] after zeroing PreferredDERP on both, so
// that DERP-only changes do not appear here (they are tracked separately).
func netInfoEqualIgnoringDERP(old, current *tailcfg.NetInfo) bool {
if old == nil && current == nil {
return true
}
if (old == nil) != (current == nil) {
return false
}
oldCopy := *old
oldCopy.PreferredDERP = 0
currentCopy := *current
currentCopy.PreferredDERP = 0
return oldCopy.BasicallyEqual(&currentCopy)
}
-74
View File
@@ -1,74 +0,0 @@
package state
import (
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// TestNoOpMapRequestSkipsPersist ensures an identical, no-op MapRequest does
// not issue a database UPDATE (nor the O(n) policy SetNodes scan that follows
// persistNodeAndRefreshPolicy). The node state is unchanged, so persisting is pure waste on
// the hot map-request path.
func TestNoOpMapRequestSkipsPersist(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
var nodeUpdateCount atomic.Int64
gdb := s.DB().DB
cbName := "noop_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement == nil {
return
}
if tx.Statement.Table == "nodes" ||
strings.Contains(strings.ToLower(tx.Statement.SQL.String()), "update \"nodes\"") {
nodeUpdateCount.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(cbName) })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
stored := nv.AsStruct()
req := tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
// First request establishes the Hostinfo/DERP state (expected to persist).
_, err = s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(t, err)
nodeUpdateCount.Store(0)
// Second request is value-identical: a no-op.
req2 := tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
_, err = s.UpdateNodeFromMapRequest(nodeID, req2)
require.NoError(t, err)
require.Equalf(t, int64(0), nodeUpdateCount.Load(),
"no-op MapRequest should not issue any nodes-table UPDATE, got %d",
nodeUpdateCount.Load())
}
+875
View File
@@ -1,12 +1,21 @@
package state package state
import ( import (
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing" "testing"
"time"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/opt"
) )
func TestNetInfoFromMapRequest(t *testing.T) { func TestNetInfoFromMapRequest(t *testing.T) {
@@ -133,3 +142,869 @@ func TestNetInfoPreservationInRegistrationFlow(t *testing.T) {
assert.Equal(t, 7, result.PreferredDERP, "Should preserve DERP region from existing node") assert.Equal(t, 7, result.PreferredDERP, "Should preserve DERP region from existing node")
}) })
} }
// TestNoOpMapRequestSkipsPersist ensures an identical, no-op MapRequest does
// not issue a database UPDATE (nor the O(n) policy SetNodes scan that follows
// persistNodeAndRefreshPolicy). The node state is unchanged, so persisting is pure waste on
// the hot map-request path.
func TestNoOpMapRequestSkipsPersist(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
var nodeUpdateCount atomic.Int64
gdb := s.DB().DB
cbName := "noop_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement == nil {
return
}
if tx.Statement.Table == "nodes" ||
strings.Contains(strings.ToLower(tx.Statement.SQL.String()), "update \"nodes\"") {
nodeUpdateCount.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(cbName) })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
stored := nv.AsStruct()
req := tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
// First request establishes the Hostinfo/DERP state (expected to persist).
_, err = s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(t, err)
nodeUpdateCount.Store(0)
// Second request is value-identical: a no-op.
req2 := tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
_, err = s.UpdateNodeFromMapRequest(nodeID, req2)
require.NoError(t, err)
require.Equalf(t, int64(0), nodeUpdateCount.Load(),
"no-op MapRequest should not issue any nodes-table UPDATE, got %d",
nodeUpdateCount.Load())
}
// TestNoOpMapRequestEmitsNoPeerChange ensures an identical, no-op MapRequest
// does not emit a peer-visible change.
func TestNoOpMapRequestEmitsNoPeerChange(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
stored := nv.AsStruct()
req := func() tailcfg.MapRequest {
return tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
}
// First request establishes the Hostinfo/DERP state.
_, err := s.UpdateNodeFromMapRequest(nodeID, req())
require.NoError(t, err)
beforeSeen := mustLastSeen(t, s, nodeID)
// Second request is value-identical: a no-op.
c, err := s.UpdateNodeFromMapRequest(nodeID, req())
require.NoError(t, err)
require.Truef(t, c.IsEmpty(),
"no-op MapRequest must not emit a change, got reason=%q type=%q peersChanged=%v",
c.Reason, c.Type(), c.PeersChanged)
require.True(t, mustLastSeen(t, s, nodeID).After(beforeSeen),
"no-op request must still stamp LastSeen in the NodeStore")
}
// TestSTUNOnlyEndpointUpdateEmitsNoPeerChange ensures endpoint churn that
// endpointBroadcastWorthy suppresses stays suppressed instead of escalating
// to a whole-peer change.
func TestSTUNOnlyEndpointUpdateEmitsNoPeerChange(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
stored := nv.AsStruct()
hi := func() *tailcfg.Hostinfo {
return &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
}
}
local := netip.MustParseAddrPort("192.168.1.5:41641")
// First request establishes the Hostinfo/DERP state and a useful
// endpoint, so the delta below is churn on a set peers already hold
// rather than the node's first endpoints.
_, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: hi(),
Endpoints: []netip.AddrPort{local},
EndpointTypes: []tailcfg.EndpointType{tailcfg.EndpointLocal},
})
require.NoError(t, err)
// Second request adds a single STUN-derived endpoint and nothing else.
c, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: hi(),
Endpoints: []netip.AddrPort{local, netip.MustParseAddrPort("198.51.100.7:41641")},
EndpointTypes: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN},
})
require.NoError(t, err)
require.Truef(t, c.IsEmpty(),
"suppressed STUN-only endpoint delta must not emit a change, got reason=%q type=%q peersChanged=%v",
c.Reason, c.Type(), c.PeersChanged)
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Contains(t, after.Endpoints().AsSlice(), netip.MustParseAddrPort("198.51.100.7:41641"),
"suppressed endpoint must still be stored")
}
// TestFirstEndpointsReachPeersEvenWhenSTUNOnly pins that the first endpoint
// set a node announces is always broadcast. Peers hold no endpoints for it
// yet, and a suppressed delta is never resent, so suppressing the first set
// leaves peers with no direct path for the life of the node.
func TestFirstEndpointsReachPeersEvenWhenSTUNOnly(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
require.Empty(t, nv.Endpoints().AsSlice(), "precondition: node starts with no endpoints")
stored := nv.AsStruct()
stun := netip.MustParseAddrPort("198.51.100.7:41641")
c, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Endpoints: []netip.AddrPort{stun},
EndpointTypes: []tailcfg.EndpointType{tailcfg.EndpointSTUN},
})
require.NoError(t, err)
require.Falsef(t, c.IsEmpty(),
"a node's first endpoints must reach peers, got reason=%q type=%q", c.Reason, c.Type())
require.Len(t, c.PeerPatches, 1, "expected a single endpoint patch")
require.Contains(t, c.PeerPatches[0].Endpoints, stun,
"the patch must carry the announced endpoint")
}
// TestMapRequestDERPLatencyJitterEmitsNoPeerChange pins the wiring between
// [State.UpdateNodeFromMapRequest] and the Hostinfo comparison: DERP latency
// is transport diagnostics that no peer reads, so jitter must not reach the
// classifier as a change. hostinfoEqual covers the comparison itself; this
// covers the path.
func TestMapRequestDERPLatencyJitterEmitsNoPeerChange(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok, "node should exist in NodeStore")
stored := nv.AsStruct()
hi := func(latency float64) *tailcfg.Hostinfo {
return &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
DERPLatency: map[string]float64{"1-v4": latency},
},
}
}
// Establish the Hostinfo, DERP region and a first latency sample.
_, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: hi(0.010),
})
require.NoError(t, err)
// Only the latency sample moves.
c, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: hi(0.025),
})
require.NoError(t, err)
require.Truef(t, c.IsEmpty(),
"DERP latency jitter must not emit a change, got reason=%q type=%q peersChanged=%v",
c.Reason, c.Type(), c.PeersChanged)
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, 1, after.Hostinfo().NetInfo().PreferredDERP(),
"the DERP region must survive a latency-only request")
}
// TestMapRequestOmittedNetInfoIsNoChange pins that a MapRequest carrying
// Hostinfo with NetInfo omitted is compared against the preserved NetInfo, not
// against the bare request. Tailscale clients send NetInfo only when it
// changed, so classifying the omission as a Hostinfo change turns
// every routine map request into a database write, an O(n) policy rescan and a
// whole-peer broadcast.
func TestMapRequestOmittedNetInfoIsNoChange(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
var nodeUpdateCount atomic.Int64
gdb := s.DB().DB
cbName := "omitted_netinfo_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "nodes" {
nodeUpdateCount.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(cbName) })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
stored := nv.AsStruct()
// Establish Hostinfo with NetInfo.
_, err = s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
OS: "linux",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
})
require.NoError(t, err)
require.Positive(t, nodeUpdateCount.Load(), "first request must persist")
nodeUpdateCount.Store(0)
// Same Hostinfo, NetInfo omitted: the client is saying "unchanged".
c, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
OS: "linux",
},
})
require.NoError(t, err)
require.True(t, c.IsEmpty(), "omitted NetInfo must not broadcast, got %+v", c)
require.Equalf(t, int64(0), nodeUpdateCount.Load(),
"omitted NetInfo must not persist, got %d updates", nodeUpdateCount.Load())
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, int(1), hostinfoDERP(after.AsStruct().Hostinfo),
"stored NetInfo must survive a request that omits it")
}
// TestMapRequestDERPClearToZeroIsStoredAndBroadcast pins that a node reporting
// PreferredDERP 0 has its home region cleared. tailcfg.PeerChange.DERPRegion
// zero means "unchanged" on the wire, so the clear cannot ride a patch and has
// to escalate to a whole-peer update.
func TestMapRequestDERPClearToZeroIsStoredAndBroadcast(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
var nodeUpdateCount atomic.Int64
gdb := s.DB().DB
cbName := "derp_clear_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "nodes" {
nodeUpdateCount.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(cbName) })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
stored := nv.AsStruct()
_, err = s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
})
require.NoError(t, err)
nodeUpdateCount.Store(0)
c, err := s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 0},
},
})
require.NoError(t, err)
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, int(0), hostinfoDERP(after.AsStruct().Hostinfo),
"clearing PreferredDERP must be stored")
require.Contains(t, c.PeersChanged, nodeID,
"clearing PreferredDERP cannot be a patch, got %+v", c)
require.Empty(t, c.PeerPatches,
"clearing PreferredDERP must not emit a DERP patch, got %+v", c)
require.Positive(t, nodeUpdateCount.Load(), "clearing PreferredDERP must be persisted")
}
// TestMapRequestDERPOnlyChangeKeepsGivenName pins that a request changing only
// PreferredDERP does not re-derive GivenName. Peers only receive a DERP patch
// for such a request, so a rename here would never reach them.
func TestMapRequestDERPOnlyChangeKeepsGivenName(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
stored := nv.AsStruct()
req := func(derp int) tailcfg.MapRequest {
return tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
NetInfo: &tailcfg.NetInfo{PreferredDERP: derp},
},
}
}
_, err := s.UpdateNodeFromMapRequest(nodeID, req(1))
require.NoError(t, err)
// A collision-bumped name whose base is free again is exactly what the
// auto-derive path rewrites.
bumped := stored.Hostname + "-1"
_, ok = s.nodeStore.UpdateNode(nodeID, func(n *types.Node) { n.GivenName = bumped })
require.True(t, ok)
c, err := s.UpdateNodeFromMapRequest(nodeID, req(2))
require.NoError(t, err)
require.NotEmpty(t, c.PeerPatches, "DERP-only change must be a patch, got %+v", c)
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, bumped, after.GivenName(), "DERP-only change must not touch GivenName")
}
func TestConcurrentMapRequestDERPUsesPersistedState(t *testing.T) {
for _, rotateKey := range []bool{false, true} {
for _, tt := range []struct {
name string
firstDERP, lastDERP int
}{
{name: "newer region", firstDERP: 2, lastDERP: 3},
{name: "newer clear", firstDERP: 2, lastDERP: 0},
{name: "superseded clear", firstDERP: 0, lastDERP: 3},
} {
name := tt.name
if rotateKey {
name += " with key rotation"
}
t.Run(name, func(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
req := tailcfg.MapRequest{
NodeKey: nv.NodeKey(),
DiscoKey: nv.DiscoKey(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: nv.Hostname(),
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
_, err := s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(t, err)
if rotateKey {
req.NodeKey = key.NewNode().Public()
req.DiscoKey = key.NewDisco().Public()
}
// Let both requests publish their NodeStore writes before
// either can persist. Both must then use the last region,
// regardless of which response reaches peers first.
s.persistMu.Lock()
unlockPersist := sync.OnceFunc(s.persistMu.Unlock)
var wg sync.WaitGroup
t.Cleanup(func() {
unlockPersist()
wg.Wait()
})
type result struct {
change change.Change
err error
}
results := make(chan result, 2)
for _, derp := range []int{tt.firstDERP, tt.lastDERP} {
request := req
request.Hostinfo = req.Hostinfo.Clone()
request.Hostinfo.NetInfo.PreferredDERP = derp
wg.Go(func() {
c, err := s.UpdateNodeFromMapRequest(nodeID, request)
results <- result{change: c, err: err}
})
require.EventuallyWithT(t, func(c *assert.CollectT) {
stored, exists := s.GetNodeByID(nodeID)
require.True(c, exists)
require.Equal(c, derp, stored.Hostinfo().NetInfo().PreferredDERP())
}, 5*time.Second, time.Millisecond, "request must update NodeStore before persisting")
}
unlockPersist()
wg.Wait()
close(results)
for result := range results {
require.NoError(t, result.err)
if tt.lastDERP == 0 {
require.Contains(t, result.change.PeersChanged, nodeID,
"the current region is zero, so neither response can use a DERP patch")
require.Empty(t, result.change.PeerPatches)
continue
}
require.Empty(t, result.change.PeersChanged)
require.Len(t, result.change.PeerPatches, 1)
require.Equal(t, tt.lastDERP, result.change.PeerPatches[0].DERPRegion,
"neither response may restore a superseded DERP region")
}
persisted, err := s.DB().GetNodeByID(nodeID)
require.NoError(t, err)
require.Equal(t, tt.lastDERP, persisted.Hostinfo.NetInfo.PreferredDERP)
// A resend is still a no-op; correct delivery cannot depend
// on a future identical request repairing the peer's state.
req.Hostinfo = req.Hostinfo.Clone()
req.Hostinfo.NetInfo.PreferredDERP = tt.lastDERP
c, err := s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(t, err)
require.True(t, c.IsEmpty())
})
}
}
}
// TestMapRequestPeerInvisibleHostinfoChangeIsStoredNotBroadcast pins that
// a Hostinfo field no peer reads is stored and persisted but does not
// resend the whole node to every peer.
func TestMapRequestPeerInvisibleHostinfoChangeIsStoredNotBroadcast(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
var nodeUpdateCount atomic.Int64
gdb := s.DB().DB
cbName := "peer_invisible_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "nodes" {
nodeUpdateCount.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(cbName) })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
stored := nv.AsStruct()
req := func(model string) tailcfg.MapRequest {
return tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
OS: "linux",
DeviceModel: model,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
}
_, err = s.UpdateNodeFromMapRequest(nodeID, req("a"))
require.NoError(t, err)
nodeUpdateCount.Store(0)
c, err := s.UpdateNodeFromMapRequest(nodeID, req("b"))
require.NoError(t, err)
require.True(t, c.IsEmpty(), "peers read no DeviceModel, got %+v", c)
require.Positive(t, nodeUpdateCount.Load(), "the new DeviceModel must be persisted")
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, "b", after.Hostinfo().DeviceModel(), "the new DeviceModel must be stored")
}
// TestMapRequestPeerVisibleHostinfoChangeIsBroadcast pins that a Hostinfo
// field peers read still resends the whole node.
func TestMapRequestPeerVisibleHostinfoChangeIsBroadcast(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
nv, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
stored := nv.AsStruct()
req := func(os string) tailcfg.MapRequest {
return tailcfg.MapRequest{
NodeKey: stored.NodeKey,
DiscoKey: stored.DiscoKey,
Hostinfo: &tailcfg.Hostinfo{
Hostname: stored.Hostname,
OS: os,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
}
_, err := s.UpdateNodeFromMapRequest(nodeID, req("linux"))
require.NoError(t, err)
c, err := s.UpdateNodeFromMapRequest(nodeID, req("windows"))
require.NoError(t, err)
require.Contains(t, c.PeersChanged, nodeID, "peers read OS, got %+v", c)
}
func mustLastSeen(t *testing.T, s *State, id types.NodeID) time.Time {
t.Helper()
nv, ok := s.GetNodeByID(id)
require.True(t, ok)
seen, ok := nv.LastSeen().GetOk()
require.True(t, ok, "LastSeen must be stamped")
return seen
}
func TestHostinfoEqual(t *testing.T) {
t.Run("both nil", func(t *testing.T) {
require.True(t, hostinfoEqual(nil, nil))
})
t.Run("one nil", func(t *testing.T) {
require.False(t, hostinfoEqual(&tailcfg.Hostinfo{}, nil))
require.False(t, hostinfoEqual(nil, &tailcfg.Hostinfo{}))
})
t.Run("identical", func(t *testing.T) {
a := &tailcfg.Hostinfo{Hostname: "node1", OS: "linux"}
b := &tailcfg.Hostinfo{Hostname: "node1", OS: "linux"}
require.True(t, hostinfoEqual(a, b))
})
t.Run("DERPLatency jitter is not a change", func(t *testing.T) {
a := &tailcfg.Hostinfo{
Hostname: "node1",
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
DERPLatency: map[string]float64{"1-v4": 0.010},
},
}
b := &tailcfg.Hostinfo{
Hostname: "node1",
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
DERPLatency: map[string]float64{"1-v4": 0.025}, // jitter
},
}
require.True(t, hostinfoEqual(a, b),
"DERPLatency jitter must not count as a change")
})
t.Run("PreferredDERP change is not a change here", func(t *testing.T) {
a := &tailcfg.Hostinfo{
Hostname: "node1",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
}
b := &tailcfg.Hostinfo{
Hostname: "node1",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 2},
}
require.True(t, hostinfoEqual(a, b),
"PreferredDERP change is tracked separately")
})
t.Run("hostname change", func(t *testing.T) {
a := &tailcfg.Hostinfo{Hostname: "node1"}
b := &tailcfg.Hostinfo{Hostname: "node2"}
require.False(t, hostinfoEqual(a, b))
})
t.Run("route change", func(t *testing.T) {
a := &tailcfg.Hostinfo{Hostname: "node1", RoutableIPs: nil}
b := &tailcfg.Hostinfo{Hostname: "node1"}
// RoutableIPs nil vs nil: equal
require.True(t, hostinfoEqual(a, b))
// Add a route.
// (also tracked separately as routesChangedInput)
b.RoutableIPs = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}
require.False(t, hostinfoEqual(a, b))
})
}
func TestPeerHostinfoEqual(t *testing.T) {
base := func() *tailcfg.Hostinfo {
return &tailcfg.Hostinfo{
Hostname: "node",
OS: "linux",
DeviceModel: "laptop",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1, WorkingUDP: opt.NewBool(true)},
}
}
tests := []struct {
name string
mutate func(*tailcfg.Hostinfo)
want bool
}{
{name: "no change", mutate: func(*tailcfg.Hostinfo) {}, want: true},
{name: "device model", mutate: func(hi *tailcfg.Hostinfo) { hi.DeviceModel = "desktop" }, want: true},
{name: "shields up", mutate: func(hi *tailcfg.Hostinfo) { hi.ShieldsUp = true }, want: true},
{name: "ipn version", mutate: func(hi *tailcfg.Hostinfo) { hi.IPNVersion = "1.99" }, want: true},
{name: "netinfo udp", mutate: func(hi *tailcfg.Hostinfo) { hi.NetInfo.WorkingUDP = opt.NewBool(false) }, want: true},
{name: "netinfo dropped", mutate: func(hi *tailcfg.Hostinfo) { hi.NetInfo = nil }, want: true},
{name: "hostname", mutate: func(hi *tailcfg.Hostinfo) { hi.Hostname = "other" }, want: false},
{name: "os", mutate: func(hi *tailcfg.Hostinfo) { hi.OS = "windows" }, want: false},
{
name: "services",
mutate: func(hi *tailcfg.Hostinfo) { hi.Services = []tailcfg.Service{{Proto: "peerapi4", Port: 1}} },
want: false,
},
{name: "ssh host keys", mutate: func(hi *tailcfg.Hostinfo) { hi.SSH_HostKeys = []string{"ssh-ed25519 AAAA"} }, want: false},
{name: "location", mutate: func(hi *tailcfg.Hostinfo) { hi.Location = &tailcfg.Location{Priority: 5} }, want: false},
{name: "app connector", mutate: func(hi *tailcfg.Hostinfo) { hi.AppConnector = opt.NewBool(true) }, want: false},
{
name: "routes",
mutate: func(hi *tailcfg.Hostinfo) { hi.RoutableIPs = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} },
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base()
tt.mutate(changed)
require.Equal(t, tt.want, peerHostinfoEqual(base(), changed))
})
}
require.True(t, peerHostinfoEqual(nil, nil))
require.False(t, peerHostinfoEqual(nil, base()))
}
func TestNetInfoEqualIgnoringDERP(t *testing.T) {
t.Run("both nil", func(t *testing.T) {
require.True(t, netInfoEqualIgnoringDERP(nil, nil))
})
t.Run("ignores PreferredDERP", func(t *testing.T) {
a := &tailcfg.NetInfo{PreferredDERP: 1, WorkingUDP: opt.NewBool(true)}
b := &tailcfg.NetInfo{PreferredDERP: 9, WorkingUDP: opt.NewBool(true)}
require.True(t, netInfoEqualIgnoringDERP(a, b))
})
t.Run("ignores DERPLatency", func(t *testing.T) {
a := &tailcfg.NetInfo{DERPLatency: map[string]float64{"1": 0.01}}
b := &tailcfg.NetInfo{DERPLatency: map[string]float64{"1": 0.99}}
require.True(t, netInfoEqualIgnoringDERP(a, b))
})
t.Run("catches WorkingUDP change", func(t *testing.T) {
a := &tailcfg.NetInfo{WorkingUDP: opt.NewBool(true)}
b := &tailcfg.NetInfo{WorkingUDP: opt.NewBool(false)}
require.False(t, netInfoEqualIgnoringDERP(a, b))
})
}
func TestHostinfoDERP(t *testing.T) {
require.Equal(t, int(0), hostinfoDERP(nil))
require.Equal(t, int(0), hostinfoDERP(&tailcfg.Hostinfo{}))
require.Equal(t, int(0), hostinfoDERP(&tailcfg.Hostinfo{NetInfo: &tailcfg.NetInfo{}}))
require.Equal(t, int(5), hostinfoDERP(&tailcfg.Hostinfo{NetInfo: &tailcfg.NetInfo{PreferredDERP: 5}}))
}
func TestBuildMapRequestChangeResponse(t *testing.T) {
node := types.Node{
ID: 1,
NodeKey: key.NewNode().Public(),
DiscoKey: key.NewDisco().Public(),
Endpoints: []netip.AddrPort{netip.MustParseAddrPort("203.0.113.9:41641")},
Hostinfo: &tailcfg.Hostinfo{NetInfo: &tailcfg.NetInfo{PreferredDERP: 2}},
}
tests := []struct {
name string
delta mapRequestDelta
nodeDERP int
wantEmpty bool
wantWholePeer bool
wantKeys bool
wantEndpoints bool
wantDERP int
}{
{
name: "nothing peer visible",
delta: mapRequestDelta{},
wantEmpty: true,
},
{
name: "peer visible hostinfo",
delta: mapRequestDelta{peerHostinfoChanged: true, endpointBroadcast: true},
wantWholePeer: true,
},
{
name: "derp clear to zero",
delta: mapRequestDelta{derpChanged: true, oldDERP: 1, newDERP: 0},
wantWholePeer: true,
},
{
name: "disco key and derp clear to zero",
delta: mapRequestDelta{discoKeyChanged: true, derpChanged: true, oldDERP: 1, newDERP: 0},
wantWholePeer: true,
},
{
name: "disco key only",
delta: mapRequestDelta{discoKeyChanged: true},
wantKeys: true,
wantEndpoints: true,
},
{
name: "disco key and derp move",
delta: mapRequestDelta{discoKeyChanged: true, derpChanged: true, oldDERP: 1, newDERP: 2},
nodeDERP: 2,
wantKeys: true,
wantEndpoints: true,
wantDERP: 2,
},
{
name: "useful endpoint only",
delta: mapRequestDelta{endpointBroadcast: true},
wantEndpoints: true,
},
{
name: "derp move only",
delta: mapRequestDelta{derpChanged: true, oldDERP: 1, newDERP: 2},
nodeDERP: 2,
wantDERP: 2,
},
{
name: "endpoint and derp move",
delta: mapRequestDelta{endpointBroadcast: true, derpChanged: true, oldDERP: 1, newDERP: 2},
nodeDERP: 2,
wantEndpoints: true,
wantDERP: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
current := node.Clone()
current.Hostinfo.NetInfo.PreferredDERP = tt.nodeDERP
c := buildMapRequestChangeResponse(node.ID, current.View(), tt.delta)
if tt.wantEmpty {
require.True(t, c.IsEmpty(), "got %+v", c)
return
}
if tt.wantWholePeer {
require.Contains(t, c.PeersChanged, node.ID, "got %+v", c)
require.Empty(t, c.PeerPatches)
return
}
require.Empty(t, c.PeersChanged, "got %+v", c)
require.Len(t, c.PeerPatches, 1)
patch := c.PeerPatches[0]
require.Equal(t, tt.wantKeys, patch.Key != nil && patch.DiscoKey != nil, "keys on patch")
require.Equal(t, tt.wantEndpoints, patch.Endpoints != nil, "endpoints on patch")
require.Equal(t, tt.wantDERP, patch.DERPRegion, "DERP on patch")
})
}
}
+196 -135
View File
@@ -31,6 +31,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/puzpuzpuz/xsync/v4" "github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -2958,25 +2959,22 @@ func isAutoDerivedGivenName(given, hostname string) bool {
// //
// TODO(kradalby): This is essentially a patch update that could be sent directly to nodes, // TODO(kradalby): This is essentially a patch update that could be sent directly to nodes,
// which means we could shortcut the whole change thing if there are no other important updates. // which means we could shortcut the whole change thing if there are no other important updates.
// When a field is added to this function, remember to also add it to: // When a field is added to a MapRequest that is stored on the node, also add
// - node.PeerChangeFromMapRequest // it to [types.Node.PeerChangeFromMapRequest], [types.Node.ApplyPeerChange],
// - node.ApplyPeerChange // the mapRequestDelta classification, and (if the policy reads it)
// - logTracePeerChange in poll.go. // [types.NodeView.HasPolicyChange].
func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { //nolint:gocyclo // central map-request reconciliation; the sequential branch flow reads clearer as one function than split across helpers
log.Trace(). log.Trace().
Caller(). Caller().
Uint64(zf.NodeID, id.Uint64()). Uint64(zf.NodeID, id.Uint64()).
Interface("request", req). EmbedObject(zlog.MapRequest(&req)).
Msg("Processing MapRequest for node") Msg("Processing MapRequest for node")
var ( var (
delta mapRequestDelta
routeChange bool routeChange bool
hostinfoChanged bool
needsRouteApproval bool needsRouteApproval bool
autoApprovedRoutes []netip.Prefix autoApprovedRoutes []netip.Prefix
endpointChanged bool
derpChanged bool
persistWorthy bool
) )
// Snapshot the primary assignment so we can tell whether the // Snapshot the primary assignment so we can tell whether the
// Hostinfo + auto-approval that follows shifted any prefix. // Hostinfo + auto-approval that follows shifted any prefix.
@@ -2985,49 +2983,84 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
// We need to ensure we update the node as it is in the [NodeStore] at // We need to ensure we update the node as it is in the [NodeStore] at
// the time of the request. // the time of the request.
updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) { updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) {
peerChange := currentNode.PeerChangeFromMapRequest(req) // Capture the raw wire-level peer change. LastSeen is always
// stamped here, so classification tests must not rely on it.
delta.peerChange = currentNode.PeerChangeFromMapRequest(req)
delta.keyChanged = delta.peerChange.Key != nil
delta.discoKeyChanged = delta.peerChange.DiscoKey != nil
// Track what specifically changed. An endpoint delta is only // Normalize before classifying. A nil req.Hostinfo means the client
// broadcast-worthy when it adds a useful (non-STUN) endpoint; // did not send one (e.g., endpoint-only/lite requests); we must NOT
// STUN-only churn and pure shrinks are suppressed to reduce peer // clobber the stored Hostinfo with a shell containing only NetInfo.
// churn (see endpointBroadcastWorthy). The new set is still stored // When Hostinfo is present but NetInfo is omitted (Tailscale >= 1.66
// via ApplyPeerChange below regardless of this decision. // sends NetInfo only when it changed), the stored NetInfo is carried
endpointChanged = peerChange.Endpoints != nil && // over. Every comparison below runs against this normalized value,
endpointBroadcastWorthy(currentNode.Endpoints, req.Endpoints, req.EndpointTypes) // otherwise an omitted NetInfo reads as a Hostinfo change and turns
derpChanged = peerChange.DERPRegion != 0 // a routine map request into a whole-peer broadcast.
hostinfoChanged = !hostinfoEqual(currentNode.View(), req.Hostinfo) var newHostinfo *tailcfg.Hostinfo
// Get the correct NetInfo to use
netInfo := netInfoFromMapRequest(id, currentNode.Hostinfo, req.Hostinfo)
if req.Hostinfo != nil { if req.Hostinfo != nil {
req.Hostinfo.NetInfo = netInfo // Copy so the classification never mutates the caller's request.
} else { hi := *req.Hostinfo
req.Hostinfo = &tailcfg.Hostinfo{NetInfo: netInfo} hi.NetInfo = netInfoFromMapRequest(id, currentNode.Hostinfo, req.Hostinfo)
newHostinfo = &hi
} }
// Re-check hostinfoChanged after potential NetInfo preservation // DERP comparison is independent of the rest of Hostinfo:
hostinfoChanged = !hostinfoEqual(currentNode.View(), req.Hostinfo) // PreferredDERP has its own wire patch representation, and
// DERP zero on the wire means "unchanged", so a clear-to-zero
// must be detected here and escalated to a whole-peer update
// during classification. A request without Hostinfo says nothing
// about DERP, so it compares equal.
storedDERP := hostinfoDERP(currentNode.Hostinfo)
requestedDERP := storedDERP
// A change carrying only an updated LastSeen is not worth a full-row if newHostinfo != nil {
// database UPDATE plus the O(n) policy rescan persistNodeAndRefreshPolicy triggers: requestedDERP = hostinfoDERP(newHostinfo)
// LastSeen is best-effort and rides along the next substantive write.
// PeerChangeFromMapRequest always stamps LastSeen, so test the other
// fields explicitly.
persistWorthy = peerChangePersistWorthy(peerChange) || hostinfoChanged
// If there is no changes and nothing to save,
// return early.
if peerChangeEmpty(peerChange) && !hostinfoChanged {
return
} }
// Calculate route approval before [NodeStore] update to avoid calling View() inside callback delta.oldDERP = storedDERP
delta.newDERP = requestedDERP
delta.derpChanged = requestedDERP != storedDERP
// Endpoint broadcast-worthiness is gated separately from storage:
// the new set is always stored via ApplyPeerChange below, but only
// newly-added useful (non-STUN) endpoints justify a peer broadcast.
// STUN-only churn and pure shrinks are suppressed to keep peers'
// views stable. See endpointBroadcastWorthy.
delta.endpointBroadcast = delta.peerChange.Endpoints != nil &&
endpointBroadcastWorthy(currentNode.Endpoints, req.Endpoints, req.EndpointTypes)
// Routes are policy and election inputs, so they are compared on
// their own. Any other Hostinfo change is stored, but only fields
// peers read are worth resending the whole node for.
delta.routesChanged = newHostinfo != nil &&
routesChanged(currentNode.View(), newHostinfo)
delta.hostinfoChanged = newHostinfo != nil &&
!hostinfoEqual(currentNode.Hostinfo, newHostinfo)
delta.peerHostinfoChanged = newHostinfo != nil &&
!peerHostinfoEqual(currentNode.Hostinfo, newHostinfo)
// A change carrying only an updated LastSeen is not worth a
// full-row database UPDATE plus the O(n) policy rescan
// persistNodeAndRefreshPolicy triggers: LastSeen is best-effort and rides
// along the next substantive write. DERP is called out because
// peerChangePersistWorthy cannot see a clear-to-zero.
delta.persistWorthy = peerChangePersistWorthy(delta.peerChange) ||
delta.hostinfoChanged ||
delta.derpChanged
hostinfoToStore := delta.hostinfoChanged || delta.derpChanged
// Calculate route approval before [NodeStore] update to avoid
// calling View() inside callback
var hasNewRoutes bool var hasNewRoutes bool
if hi := req.Hostinfo; hi != nil { if hi := req.Hostinfo; hi != nil {
hasNewRoutes = len(hi.RoutableIPs) > 0 hasNewRoutes = len(hi.RoutableIPs) > 0
} }
needsRouteApproval = hostinfoChanged && (routesChanged(currentNode.View(), req.Hostinfo) || (hasNewRoutes && len(currentNode.ApprovedRoutes) == 0)) needsRouteApproval = delta.hostinfoChanged &&
(delta.routesChanged || (hasNewRoutes && len(currentNode.ApprovedRoutes) == 0))
if needsRouteApproval { if needsRouteApproval {
// Extract announced routes from request // Extract announced routes from request
var announcedRoutes []netip.Prefix var announcedRoutes []netip.Prefix
@@ -3047,59 +3080,52 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
} }
// Log when routes change but approval doesn't // Log when routes change but approval doesn't
if hostinfoChanged && !routeChange { if delta.routesChanged && !routeChange {
if hi := req.Hostinfo; hi != nil { if hi := req.Hostinfo; hi != nil {
if routesChanged(currentNode.View(), hi) { log.Debug().
log.Debug(). Caller().
Caller(). Uint64(zf.NodeID, id.Uint64()).
Uint64(zf.NodeID, id.Uint64()). Strs(zf.OldAnnouncedRoutes, util.PrefixesToString(currentNode.AnnouncedRoutes())).
Strs(zf.OldAnnouncedRoutes, util.PrefixesToString(currentNode.AnnouncedRoutes())). Strs(zf.NewAnnouncedRoutes, util.PrefixesToString(hi.RoutableIPs)).
Strs(zf.NewAnnouncedRoutes, util.PrefixesToString(hi.RoutableIPs)). Strs(zf.ApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)).
Strs(zf.ApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)). Bool(zf.RouteChanged, routeChange).
Bool(zf.RouteChanged, routeChange). Msg("announced routes changed but approved routes did not")
Msg("announced routes changed but approved routes did not")
}
} }
} }
currentNode.ApplyPeerChange(&peerChange) currentNode.ApplyPeerChange(&delta.peerChange)
if hostinfoChanged { if hostinfoToStore {
// The node might not set NetInfo if it has not changed and if currentNode.Hostinfo = newHostinfo
// the full HostInfo object is overwritten, the information is lost. }
// If there is no NetInfo, keep the previous one.
// From 1.66 the client only sends it if changed:
// https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2
// TODO(kradalby): evaluate if we need better comparing of hostinfo
// before we take the changes.
// NetInfo preservation has already been handled above before early return check
currentNode.Hostinfo = req.Hostinfo
if req.Hostinfo != nil && req.Hostinfo.Hostname != "" {
// Preserve an admin-renamed GivenName: only auto-derive when the
// current GivenName is still what SanitizeHostname of the old
// Hostname would produce (possibly with a "-N" collision bump).
autoDerived := isAutoDerivedGivenName(currentNode.GivenName, currentNode.Hostname)
currentNode.Hostname = req.Hostinfo.Hostname // Only a real hostname change may re-derive GivenName: it is peer
if autoDerived { // visible, so the whole node is resent and peers learn the name.
currentNode.GivenName = dnsname.SanitizeHostname(req.Hostinfo.Hostname) if newHostinfo != nil && newHostinfo.Hostname != "" &&
// [NodeStore.UpdateNode] auto-bumps GivenName on collision. newHostinfo.Hostname != currentNode.Hostname {
} // Preserve an admin-renamed GivenName: only auto-derive
// when the current GivenName is still what
// SanitizeHostname of the old Hostname would produce
// (possibly with a "-N" collision bump).
autoDerived := isAutoDerivedGivenName(currentNode.GivenName, currentNode.Hostname)
currentNode.Hostname = newHostinfo.Hostname
if autoDerived {
currentNode.GivenName = dnsname.SanitizeHostname(newHostinfo.Hostname)
// [NodeStore.UpdateNode] auto-bumps GivenName on collision.
} }
}
if routeChange { if routeChange {
// Apply pre-calculated route approval // Always apply the route approval result so routes are
// Always apply the route approval result to ensure consistency, // cleared when auto-approvers are removed from the policy,
// regardless of whether the policy evaluation detected changes. // even if the policy evaluation itself detected no change.
// This fixes the bug where routes weren't properly cleared when log.Info().
// auto-approvers were removed from the policy. Uint64(zf.NodeID, id.Uint64()).
log.Info(). Strs(zf.OldApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)).
Uint64(zf.NodeID, id.Uint64()). Strs(zf.NewApprovedRoutes, util.PrefixesToString(autoApprovedRoutes)).
Strs(zf.OldApprovedRoutes, util.PrefixesToString(currentNode.ApprovedRoutes)). Bool(zf.RouteChanged, routeChange).
Strs(zf.NewApprovedRoutes, util.PrefixesToString(autoApprovedRoutes)). Msg("applying route approval results")
Bool(zf.RouteChanged, routeChange).
Msg("applying route approval results")
}
} }
// AllApprovedRoutes is announced ∩ approved; a Hostinfo // AllApprovedRoutes is announced ∩ approved; a Hostinfo
@@ -3122,6 +3148,9 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
Msg("Persisting auto-approved routes from MapRequest") Msg("Persisting auto-approved routes from MapRequest")
// [State.SetApprovedRoutes] will update both database and PrimaryRoutes table // [State.SetApprovedRoutes] will update both database and PrimaryRoutes table
// TODO(kradalby): approval should ride the map request write above.
// Writing it separately costs a second NodeStore write and a second
// peer-map rebuild for one request.
_, c, err := s.SetApprovedRoutes(id, autoApprovedRoutes) _, c, err := s.SetApprovedRoutes(id, autoApprovedRoutes)
if err != nil { if err != nil {
return change.Change{}, fmt.Errorf("persisting auto-approved routes: %w", err) return change.Change{}, fmt.Errorf("persisting auto-approved routes: %w", err)
@@ -3153,15 +3182,32 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
// A no-op MapRequest (identical re-send / reconnect with matching state) // A no-op MapRequest (identical re-send / reconnect with matching state)
// leaves the node untouched, so skip the full-row UPDATE and the O(n) // leaves the node untouched, so skip the full-row UPDATE and the O(n)
// policy SetNodes scan that persistNodeAndRefreshPolicy performs. // policy SetNodes scan that persistNodeAndRefreshPolicy performs.
//
// On the MapRequest path we deliberately bypass persistNodeAndRefreshPolicy's
// synthetic NodeAdded fallback: persistence must not fabricate a wire
// notification. We persist the row directly and refresh the policy
// manager only when node inputs visible to the policy actually changed
// (structural Hostinfo or routes), letting updatePolicyManagerNodes
// decide whether matchers changed.
policyChange := change.Change{} policyChange := change.Change{}
if persistWorthy { if delta.persistWorthy {
var err error var err error
_, policyChange, err = s.persistNodeAndRefreshPolicy(updatedNode) updatedNode, err = s.persistNode(updatedNode)
if err != nil { if err != nil {
return change.Change{}, fmt.Errorf("saving to database: %w", err) return change.Change{}, fmt.Errorf("saving to database: %w", err)
} }
// Only refresh the policy manager when something it depends on
// might have moved. Endpoint/key/DERP/LastSeen-only updates do not
// affect policy evaluation and are deliberately skipped here.
if delta.peerHostinfoChanged || delta.routesChanged {
policyChange, err = s.updatePolicyManagerNodes()
if err != nil {
return change.Change{}, fmt.Errorf("updating policy manager after node save: %w", err)
}
}
} }
if !policyChange.IsEmpty() { if !policyChange.IsEmpty() {
@@ -3172,9 +3218,20 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
return nodeRouteChange, nil return nodeRouteChange, nil
} }
// Determine the most specific change type based on what actually changed. // Determine the most specific change type from the classified delta.
// This allows us to send lightweight patch updates instead of full map responses. // This allows us to send lightweight patch updates instead of full
return buildMapRequestChangeResponse(id, updatedNode, hostinfoChanged, endpointChanged, derpChanged) // map responses.
c := buildMapRequestChangeResponse(id, updatedNode, delta)
// One trace line per classified request so a "peer cannot reach me"
// report can be matched to the classification that narrowed it.
log.Trace().
Uint64(zf.NodeID, id.Uint64()).
Str(zf.Type, c.Type()).
EmbedObject(delta).
Msg("classified MapRequest")
return c, nil
} }
// endpointBroadcastWorthy reports whether an endpoint-only delta is worth // endpointBroadcastWorthy reports whether an endpoint-only delta is worth
@@ -3185,9 +3242,9 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
// telling peers about. Suppressing this churn keeps peers' views stable. // telling peers about. Suppressing this churn keeps peers' views stable.
// //
// The decision is intentionally conservative: it gates the broadcast only, // The decision is intentionally conservative: it gates the broadcast only,
// not storage. The node's full endpoint set (STUN included) is still stored // not storage. A suppressed delta is never resent on its own, so suppression
// and rides along the next substantive change or full MapResponse, so no // is only safe once peers already hold an endpoint set to fall back on; the
// reachable path is permanently hidden from peers. // first set a node announces is therefore always broadcast.
// //
// Limitation: headscale stores bare []netip.AddrPort with no per-endpoint // Limitation: headscale stores bare []netip.AddrPort with no per-endpoint
// type, so we can only classify the *new* request's endpoints (via the // type, so we can only classify the *new* request's endpoints (via the
@@ -3200,6 +3257,12 @@ func endpointBroadcastWorthy(
stored, newEPs []netip.AddrPort, stored, newEPs []netip.AddrPort,
newTypes []tailcfg.EndpointType, newTypes []tailcfg.EndpointType,
) bool { ) bool {
// Peers hold no endpoints for this node yet, so the first set announced
// is the only one they would ever get. Type does not matter here.
if len(stored) == 0 {
return len(newEPs) > 0
}
storedSet := make(map[netip.AddrPort]struct{}, len(stored)) storedSet := make(map[netip.AddrPort]struct{}, len(stored))
for _, ep := range stored { for _, ep := range stored {
storedSet[ep] = struct{}{} storedSet[ep] = struct{}{}
@@ -3234,52 +3297,60 @@ func isUsefulEndpointType(t tailcfg.EndpointType) bool {
return t != tailcfg.EndpointSTUN && t != tailcfg.EndpointSTUN4LocalPort return t != tailcfg.EndpointSTUN && t != tailcfg.EndpointSTUN4LocalPort
} }
// buildMapRequestChangeResponse determines the appropriate response type for a [tailcfg.MapRequest] update. // buildMapRequestChangeResponse picks the narrowest broadcast for a processed
// Hostinfo changes require a full update, while endpoint/DERP changes can use lightweight patches. // MapRequest delta. Policy and route changes are handled by the caller.
//
// Two wire constraints shape the order: tailcfg.PeerChange.DERPRegion == 0
// means "unchanged", so clearing DERP cannot ride a patch and needs a whole
// peer; and a key patch already carries endpoints, key expiry, and DERP, so a
// key change subsumes an endpoint or DERP patch in the same request.
// The delta decides which fields to send; their values come from the fresh
// node because concurrent requests may have superseded the captured values.
func buildMapRequestChangeResponse( func buildMapRequestChangeResponse(
id types.NodeID, id types.NodeID,
node types.NodeView, node types.NodeView,
hostinfoChanged, endpointChanged, derpChanged bool, delta mapRequestDelta,
) (change.Change, error) { ) change.Change {
// Hostinfo changes require NodeAdded (full update) as they may affect many fields. if delta.peerHostinfoChanged {
if hostinfoChanged { return change.NodeAdded(id)
return change.NodeAdded(id), nil
} }
// Return specific change types for endpoint and/or DERP updates. var currentDERP int
if endpointChanged || derpChanged {
if delta.derpChanged {
if hi := node.Hostinfo(); hi.Valid() && hi.NetInfo().Valid() {
currentDERP = hi.NetInfo().PreferredDERP()
}
if currentDERP == 0 {
return change.NodeAdded(id)
}
}
if delta.keyChanged || delta.discoKeyChanged {
c := change.NodeKeyRotated(node)
if delta.derpChanged {
c.PeerPatches[0].DERPRegion = currentDERP
}
return c
}
if delta.endpointBroadcast || delta.derpChanged {
patch := &tailcfg.PeerChange{NodeID: id.NodeID()} patch := &tailcfg.PeerChange{NodeID: id.NodeID()}
if endpointChanged { if delta.endpointBroadcast {
patch.Endpoints = node.Endpoints().AsSlice() patch.Endpoints = node.Endpoints().AsSlice()
} }
if derpChanged { if delta.derpChanged {
if hi := node.Hostinfo(); hi.Valid() { patch.DERPRegion = currentDERP
if ni := hi.NetInfo(); ni.Valid() {
patch.DERPRegion = ni.PreferredDERP()
}
}
} }
return change.EndpointOrDERPUpdate(id, patch), nil return change.EndpointOrDERPUpdate(id, patch)
} }
return change.NodeAdded(id), nil return change.Change{}
}
func hostinfoEqual(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool {
if !oldNode.Valid() && newHI == nil {
return true
}
if !oldNode.Valid() || newHI == nil {
return false
}
old := oldNode.AsStruct().Hostinfo
return old.Equal(newHI)
} }
func routesChanged(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool { func routesChanged(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool {
@@ -3299,16 +3370,6 @@ func routesChanged(oldNode types.NodeView, newHI *tailcfg.Hostinfo) bool {
return !slices.Equal(oldRoutes, newRoutes) return !slices.Equal(oldRoutes, newRoutes)
} }
func peerChangeEmpty(peerChange tailcfg.PeerChange) bool {
return peerChange.Key == nil &&
peerChange.DiscoKey == nil &&
peerChange.Online == nil &&
peerChange.Endpoints == nil &&
peerChange.DERPRegion == 0 &&
peerChange.LastSeen == nil &&
peerChange.KeyExpiry == nil
}
// peerChangePersistWorthy reports whether a peer change carries anything that // peerChangePersistWorthy reports whether a peer change carries anything that
// warrants a database write. It deliberately ignores LastSeen, which // warrants a database write. It deliberately ignores LastSeen, which
// [Node.PeerChangeFromMapRequest] always stamps: a keepalive that only bumps // [Node.PeerChangeFromMapRequest] always stamps: a keepalive that only bumps
+2 -3
View File
@@ -675,9 +675,8 @@ func (node *Node) MarshalZerologObject(e *zerolog.Event) {
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node // PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and // to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
// inform peers about smaller changes to the node. // inform peers about smaller changes to the node.
// When a field is added to this function, remember to also add it to: // When a field is added to this function, also add it to
// - [Node.ApplyPeerChange] // [Node.ApplyPeerChange].
// - logTracePeerChange in poll.go.
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange { func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
ret := tailcfg.PeerChange{ ret := tailcfg.PeerChange{
NodeID: tailcfg.NodeID(node.ID), //nolint:gosec // NodeID is bounded NodeID: tailcfg.NodeID(node.ID), //nolint:gosec // NodeID is bounded