Files
headscale/hscontrol/state/maprequest_test.go
T
Kristoffer Dalby 59f3ff12a7 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
2026-09-10 13:06:13 +02:00

1011 lines
32 KiB
Go

package state
import (
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/opt"
)
func TestNetInfoFromMapRequest(t *testing.T) {
nodeID := types.NodeID(1)
tests := []struct {
name string
currentHostinfo *tailcfg.Hostinfo
reqHostinfo *tailcfg.Hostinfo
expectNetInfo *tailcfg.NetInfo
}{
{
name: "no current NetInfo - return nil",
currentHostinfo: nil,
reqHostinfo: &tailcfg.Hostinfo{
Hostname: "test-node",
},
expectNetInfo: nil,
},
{
name: "current has NetInfo, request has NetInfo - use request",
currentHostinfo: &tailcfg.Hostinfo{
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
reqHostinfo: &tailcfg.Hostinfo{
Hostname: "test-node",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 2},
},
expectNetInfo: &tailcfg.NetInfo{PreferredDERP: 2},
},
{
name: "current has NetInfo, request has no NetInfo - use current",
currentHostinfo: &tailcfg.Hostinfo{
NetInfo: &tailcfg.NetInfo{PreferredDERP: 3},
},
reqHostinfo: &tailcfg.Hostinfo{
Hostname: "test-node",
},
expectNetInfo: &tailcfg.NetInfo{PreferredDERP: 3},
},
{
name: "current has NetInfo, no request Hostinfo - use current",
currentHostinfo: &tailcfg.Hostinfo{
NetInfo: &tailcfg.NetInfo{PreferredDERP: 4},
},
reqHostinfo: nil,
expectNetInfo: &tailcfg.NetInfo{PreferredDERP: 4},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := netInfoFromMapRequest(nodeID, tt.currentHostinfo, tt.reqHostinfo)
if tt.expectNetInfo == nil {
assert.Nil(t, result, "expected nil NetInfo")
} else {
require.NotNil(t, result, "expected non-nil NetInfo")
assert.Equal(t, tt.expectNetInfo.PreferredDERP, result.PreferredDERP, "DERP mismatch")
}
})
}
}
func TestNetInfoPreservationInRegistrationFlow(t *testing.T) {
nodeID := types.NodeID(1)
// This test reproduces the bug in registration flows where NetInfo was lost
// because we used the wrong hostinfo reference when calling [netInfoFromMapRequest]
t.Run("registration_flow_bug_reproduction", func(t *testing.T) {
// Simulate existing node with NetInfo (before re-registration)
existingNodeHostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 5},
}
// Simulate new registration request (no NetInfo)
newRegistrationHostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
OS: "linux",
// NetInfo is nil - this is what comes from the registration request
}
// Simulate what was happening in the bug: we passed the "current node being modified"
// hostinfo (which has no NetInfo) instead of the existing node's hostinfo
nodeBeingModifiedHostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
// NetInfo is nil because this node is being modified/reset
}
// BUG: Using the node being modified (no NetInfo) instead of existing node (has NetInfo)
buggyResult := netInfoFromMapRequest(nodeID, nodeBeingModifiedHostinfo, newRegistrationHostinfo)
assert.Nil(t, buggyResult, "Bug: Should return nil when using wrong hostinfo reference")
// CORRECT: Using the existing node's hostinfo (has NetInfo)
correctResult := netInfoFromMapRequest(nodeID, existingNodeHostinfo, newRegistrationHostinfo)
assert.NotNil(t, correctResult, "Fix: Should preserve NetInfo when using correct hostinfo reference")
assert.Equal(t, tailcfg.DERPRegionID(5), correctResult.PreferredDERP, "Should preserve the DERP region from existing node")
})
t.Run("new_node_creation_for_different_user_should_preserve_netinfo", func(t *testing.T) {
// This test covers the scenario where:
// 1. A node exists for user1 with NetInfo
// 2. The same machine logs in as user2 (different user)
// 3. A NEW node is created for user2 (pre-auth key flow)
// 4. The new node should preserve NetInfo from the old node
// Existing node for user1 with NetInfo
existingNodeUser1Hostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
NetInfo: &tailcfg.NetInfo{PreferredDERP: 7},
}
// New registration request for user2 (no NetInfo yet)
newNodeUser2Hostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
OS: "linux",
// NetInfo is nil - registration request doesn't include it
}
// When creating a new node for user2, we should preserve NetInfo from user1's node
result := netInfoFromMapRequest(types.NodeID(2), existingNodeUser1Hostinfo, newNodeUser2Hostinfo)
assert.NotNil(t, result, "New node for user2 should preserve NetInfo from user1's node")
assert.Equal(t, tailcfg.DERPRegionID(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, int(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, tailcfg.DERPRegionID(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, tailcfg.DERPRegionID(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 tailcfg.DERPRegionID) 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 tailcfg.DERPRegionID
}{
{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 []tailcfg.DERPRegionID{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, tailcfg.DERPRegionID(0), hostinfoDERP(nil))
require.Equal(t, tailcfg.DERPRegionID(0), hostinfoDERP(&tailcfg.Hostinfo{}))
require.Equal(t, tailcfg.DERPRegionID(0), hostinfoDERP(&tailcfg.Hostinfo{NetInfo: &tailcfg.NetInfo{}}))
require.Equal(t, tailcfg.DERPRegionID(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 tailcfg.DERPRegionID
wantEmpty bool
wantWholePeer bool
wantKeys bool
wantEndpoints bool
wantDERP tailcfg.DERPRegionID
}{
{
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")
})
}
}