Files
headscale/hscontrol/state/maprequest_test.go
T
Kristoffer Dalby 99e8a9228a mapper: trim the Hostinfo sent to peers and to the node itself
As on Tailscale SaaS, the node's own entry keeps Hostname, RequestTags,
RoutableIPs and Services; peers get Hostname, OS, Services without
peerapi-dns-proxy, SSH host keys and AppConnector, and resend on those.
2026-11-02 19:15:40 +01:00

1533 lines
48 KiB
Go

package state
import (
"fmt"
"net/netip"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"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 TestMapRequestOwnsRequestData(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
node, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
req := tailcfg.MapRequest{
NodeKey: node.NodeKey(),
DiscoKey: node.DiscoKey(),
Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:41641")},
Hostinfo: &tailcfg.Hostinfo{
Hostname: "original",
Services: []tailcfg.Service{{Proto: "tcp", Port: 80}},
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
DERPLatency: map[string]float64{"1-v4": 0.1},
},
},
}
original := req.Hostinfo.Clone()
_, err := s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(t, err)
require.Equal(t, original, req.Hostinfo, "map update must not modify its request")
req.Hostinfo.Hostname = "changed"
req.Hostinfo.Services[0].Port = 443
req.Hostinfo.NetInfo.PreferredDERP = 2
req.Hostinfo.NetInfo.DERPLatency["1-v4"] = 0.2
req.Endpoints[0] = netip.MustParseAddrPort("192.0.2.2:41641")
stored, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
require.Equal(t, original, stored.Hostinfo().AsStruct())
require.Equal(t, netip.MustParseAddrPort("192.0.2.1:41641"), stored.Endpoints().At(0))
}
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.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: true},
{
name: "dns proxy service",
mutate: func(hi *tailcfg.Hostinfo) { hi.Services = []tailcfg.Service{{Proto: tailcfg.PeerAPIDNS, Port: 1}} },
want: true,
},
{name: "app connector false", mutate: func(hi *tailcfg.Hostinfo) { hi.AppConnector = opt.NewBool(false) }, want: true},
{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().View(), changed.View()))
})
}
require.True(t, peerHostinfoEqual(tailcfg.HostinfoView{}, tailcfg.HostinfoView{}))
require.False(t, peerHostinfoEqual(tailcfg.HostinfoView{}, base().View()))
}
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) {
discoKeys, discoEndpoints := saasDiscoRotation(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: discoKeys,
wantEndpoints: discoEndpoints,
},
{
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")
})
}
}
// saasDiscoRotation reads how Tailscale SaaS told a peer about a running
// node's disco key rotation: whether the peer's entry carried the node key
// and the endpoints. Test data source:
// ../servertest/testdata/steps/map-delivery.hujson, step S2.2, where laptop
// rotates and bystander watches; the servertest step captures live there.
func saasDiscoRotation(t *testing.T) (bool, bool) {
t.Helper()
c, err := testcapture.Read(filepath.Join("..", "servertest", "testdata", "steps", "map-delivery.hujson"))
require.NoError(t, err)
id := c.NodeIDs["laptop"]
var last *tailcfg.MapRequest
for _, step := range c.Steps {
if step.Name != "S2.2" {
for _, f := range step.Frames["laptop"] {
if f.Request != nil {
last = f.Request
}
}
continue
}
// The rotation's first report changes the disco key and nothing
// else this row looks at.
first := step.Frames["laptop"][0].Request
require.NotNil(t, first, "S2.2 starts with laptop's report")
require.NotNil(t, last, "laptop reported before S2.2")
require.NotEqual(t, last.DiscoKey, first.DiscoKey, "disco key rotated")
require.Equal(t, last.Endpoints, first.Endpoints, "endpoints kept")
require.Equal(t, last.Hostinfo.NetInfo.PreferredDERP, first.Hostinfo.NetInfo.PreferredDERP, "DERP home kept")
for _, f := range step.Frames["bystander"] {
if f.Response == nil {
continue
}
for _, n := range f.Response.PeersChanged {
if n.ID == id {
return !n.Key.IsZero() && n.DiscoKey == first.DiscoKey, len(n.Endpoints) > 0
}
}
for _, p := range f.Response.PeersChangedPatch {
if p.NodeID == id {
return p.Key != nil && p.DiscoKey != nil, p.Endpoints != nil
}
}
}
}
t.Fatal("no disco rotation reached bystander")
return false, false
}
// benchMapRequestSetup pre-creates a sqlite database with n registered
// nodes spread across 10 users (~10% tagged tag:srv, half of those also
// carrying an approved and announced 10.x.0.0/24 route), then constructs a State
// that loads them, at benchmark scale the same way persistTestSetup
// does for a single node. Returns the State and the ID of node 0, a
// plain node with neither tag nor route, to drive requests against.
func benchMapRequestSetup(b *testing.B, n int) (*State, types.NodeID) {
b.Helper()
dbPath := b.TempDir() + "/headscale.db"
cfg := persistTestConfig(dbPath)
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(b, err)
users := make([]*types.User, 10)
for i := range users {
users[i] = database.CreateUserForTest(fmt.Sprintf("u%d", i+1))
}
var targetID types.NodeID
for i := range n {
node := database.CreateRegisteredNodeForTest(users[i%len(users)], fmt.Sprintf("n%d", i))
if i == 0 {
targetID = node.ID
continue
}
if i%10 != 0 {
continue
}
node.Tags = []string{"tag:srv"}
if i%20 == 0 {
subnet := netip.PrefixFrom(netip.AddrFrom4([4]byte{10, byte((i / 20) % 256), 0, 0}), 24) //nolint:gosec
node.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{subnet}}
node.ApprovedRoutes = []netip.Prefix{subnet}
}
require.NoError(b, database.DB.Save(node).Error)
}
require.NoError(b, database.Close())
s, err := NewState(cfg)
require.NoError(b, err)
b.Cleanup(func() { _ = s.Close() })
target, ok := s.GetNodeByID(targetID)
require.True(b, ok)
require.False(b, target.IsTagged(), "target must be plain")
require.Empty(b, target.AnnouncedRoutes(), "target must be plain")
routers := 0
for _, nv := range s.ListNodes().All() {
if len(nv.AnnouncedRoutes()) > 0 {
routers++
}
}
require.Positive(b, routers, "setup must create subnet routers")
return s, targetID
}
// BenchmarkUpdateNodeFromMapRequest measures the no-op path
// TestNoOpMapRequestSkipsPersist and TestNoOpMapRequestEmitsNoPeerChange
// pin the behaviour of: a MapRequest that is value-identical to the
// node's current state, against a realistic node count. The baseline
// later NodeStore write-path changes are compared against.
func BenchmarkUpdateNodeFromMapRequest(b *testing.B) {
b.Run("identical/n=617", func(b *testing.B) {
s, nodeID := benchMapRequestSetup(b, 617)
nv, ok := s.GetNodeByID(nodeID)
require.True(b, ok, "target node should exist in NodeStore")
req := tailcfg.MapRequest{
NodeKey: nv.NodeKey(),
DiscoKey: nv.DiscoKey(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: nv.Hostname(),
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
// Establish the Hostinfo/DERP state once so every request timed
// below is a genuine no-op.
_, err := s.UpdateNodeFromMapRequest(nodeID, req)
require.NoError(b, err)
b.ReportAllocs()
for b.Loop() {
_, err := s.UpdateNodeFromMapRequest(nodeID, req)
if err != nil {
b.Fatal(err)
}
}
})
}
// autoApproveTestPolicy lets aa-user's nodes see each other and
// auto-approve subnets under 10.55.0.0/16 and exit routes; aa-other's
// node only reaches the internet, so it sees an aa-user node only once
// that node is an approved exit node.
const autoApproveTestPolicy = `{
"acls": [
{"action": "accept", "src": ["aa-user@"], "dst": ["aa-user@:*", "10.55.0.0/16:*"]},
{"action": "accept", "src": ["aa-other@"], "dst": ["autogroup:internet:*"]}
],
"autoApprovers": {
"routes": {"10.55.0.0/16": ["aa-user@"]},
"exitNode": ["aa-user@"]
}}`
// newAutoApproveTestState returns a State under autoApproveTestPolicy with
// three aa-user nodes followed by one aa-other node. prepare edits the
// database rows before the State loads them, and the returned counter
// counts peer builds from then on.
func newAutoApproveTestState(
t *testing.T,
prepare func(nodes []*types.Node),
) (*State, []types.NodeID, *atomic.Int64) {
t.Helper()
dbPath := t.TempDir() + "/headscale.db"
cfg := persistTestConfig(dbPath)
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(t, err)
user := database.CreateUserForTest("aa-user")
other := database.CreateUserForTest("aa-other")
nodes := database.CreateRegisteredNodesForTest(user, 3, "aa-node")
nodes = append(nodes, database.CreateRegisteredNodeForTest(other, "aa-other-node"))
if prepare != nil {
prepare(nodes)
for _, n := range nodes {
require.NoError(t, database.DB.Save(n).Error)
}
}
require.NoError(t, database.Close())
s, err := NewState(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
_, err = s.SetPolicy([]byte(autoApproveTestPolicy))
require.NoError(t, err)
ids := make([]types.NodeID, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
}
return s, ids, countStatePeerBuilds(t, s)
}
func checkAutoApproveAdjacency(t *testing.T, s *State) {
t.Helper()
users, err := s.ListAllUsers()
require.NoError(t, err)
checkAdjacencyMatchesFullBuild(t, s.nodeStore, autoApproveTestPolicy, users)
}
func routeMapRequest(t *testing.T, s *State, id types.NodeID, routes ...netip.Prefix) tailcfg.MapRequest {
t.Helper()
nv, ok := s.GetNodeByID(id)
require.True(t, ok)
return tailcfg.MapRequest{
NodeKey: nv.NodeKey(),
DiscoKey: nv.DiscoKey(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: nv.Hostname(),
RoutableIPs: routes,
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
}
}
// countNodeRowUpdates counts UPDATEs of the nodes table from now on.
func countNodeRowUpdates(t *testing.T, s *State) *atomic.Int64 {
t.Helper()
var n atomic.Int64
gdb := s.DB().DB
name := t.Name() + "_count_node_updates"
err := gdb.Callback().Update().After("gorm:update").Register(name, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "nodes" {
n.Add(1)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = gdb.Callback().Update().Remove(name) })
return &n
}
// TestMapRequestAutoApprovalIsOneWrite pins that a map request whose
// announced route the policy auto-approves lands in one route-changing
// NodeStore write (one peer build) and one row update, persists the
// approval, and reports the policy change the new route causes.
func TestMapRequestAutoApprovalIsOneWrite(t *testing.T) {
s, ids, builds := newAutoApproveTestState(t, nil)
route := netip.MustParsePrefix("10.55.1.0/24")
req := routeMapRequest(t, s, ids[0], route)
rowUpdates := countNodeRowUpdates(t, s)
c, err := s.UpdateNodeFromMapRequest(ids[0], req)
require.NoError(t, err)
assert.Equal(t, int64(1), builds.Load(), "peer builds for one map request")
assert.Equal(t, int64(1), rowUpdates.Load(), "node row updates for one map request")
assert.Equal(t, "policy", c.Type())
row, err := s.DB().GetNodeByID(ids[0])
require.NoError(t, err)
assert.Contains(t, row.ApprovedRoutes, route, "the approval must be persisted")
nv, ok := s.GetNodeByID(ids[0])
require.True(t, ok)
assert.Contains(t, nv.SubnetRoutes(), route)
checkAutoApproveAdjacency(t, s)
}
// TestMapRequestWithdrawingRoutesClearsUnhealthy pins that a node whose
// announced set shrinks to empty stops being an unhealthy HA candidate.
func TestMapRequestWithdrawingRoutesClearsUnhealthy(t *testing.T) {
route := netip.MustParsePrefix("10.55.1.0/24")
s, ids, _ := newAutoApproveTestState(t, func(nodes []*types.Node) {
nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}}
nodes[0].ApprovedRoutes = []netip.Prefix{route}
})
_, ok := s.nodeStore.UpdateNode(ids[0], func(n *types.Node) { n.Unhealthy = true })
require.True(t, ok)
_, err := s.UpdateNodeFromMapRequest(ids[0], routeMapRequest(t, s, ids[0]))
require.NoError(t, err)
nv, ok := s.GetNodeByID(ids[0])
require.True(t, ok)
assert.Empty(t, nv.AllApprovedRoutes())
assert.False(t, nv.Unhealthy(), "a node with no approved routes is no HA candidate")
}
// TestSetApprovedRoutesReportsVisibility pins which change an admin route
// approval reports: a whole-peer update when nothing peers or the policy
// read moved, a policy change otherwise.
func TestSetApprovedRoutesReportsVisibility(t *testing.T) {
subnetA := netip.MustParsePrefix("10.55.1.0/24")
subnetB := netip.MustParsePrefix("10.55.2.0/24")
exit := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
tests := []struct {
name string
prepare func(nodes []*types.Node)
approve []netip.Prefix
// wantType is the returned change's [change.Change.Type].
wantType string
// wantNewPeer is the index of a node that must become a peer.
wantNewPeer int
}{
{
name: "unannounced route moves nothing peers see",
prepare: func(nodes []*types.Node) {
nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{subnetA}}
nodes[0].ApprovedRoutes = []netip.Prefix{subnetA}
},
approve: []netip.Prefix{subnetA, netip.MustParsePrefix("10.99.0.0/24")},
wantType: "peers",
wantNewPeer: -1,
},
{
// No primary moves and no peer is gained, but the node's
// approved subnets are a policy input (wildcard sources, via
// grants and its own reduced filter read them), so the
// policy manager reports it.
name: "second subnet as HA standby is a policy input",
prepare: func(nodes []*types.Node) {
nodes[1].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{subnetB}}
nodes[1].ApprovedRoutes = []netip.Prefix{subnetB}
nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{subnetA, subnetB}}
nodes[0].ApprovedRoutes = []netip.Prefix{subnetA}
},
approve: []netip.Prefix{subnetA, subnetB},
wantType: "policy",
wantNewPeer: -1,
},
{
name: "first exit approval makes the node visible to a new peer",
prepare: func(nodes []*types.Node) {
nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exit}
},
approve: exit,
wantType: "policy",
wantNewPeer: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, ids, _ := newAutoApproveTestState(t, tt.prepare)
primariesBefore := s.nodeStore.PrimaryRoutes()
peersBefore := s.nodeStore.ListPeerIDs(ids[0])
_, c, err := s.SetApprovedRoutes(ids[0], tt.approve)
require.NoError(t, err)
assert.Equal(t, tt.wantType, c.Type())
if c.Type() == "peers" {
assert.Equal(t, ids[0], c.OriginNode)
assert.Contains(t, c.PeersChanged, ids[0])
}
peersAfter := s.nodeStore.ListPeerIDs(ids[0])
if tt.wantNewPeer < 0 {
assert.Equal(t, peersBefore, peersAfter, "scenario must keep the node's peers")
assert.Equal(t, primariesBefore, s.nodeStore.PrimaryRoutes(), "scenario must keep every primary")
} else {
assert.NotContains(t, peersBefore, ids[tt.wantNewPeer])
assert.Contains(t, peersAfter, ids[tt.wantNewPeer])
}
checkAutoApproveAdjacency(t, s)
})
}
}
func TestMapRequestKeepsCurrentNodeKey(t *testing.T) {
for _, queued := range []bool{false, true} {
t.Run(fmt.Sprintf("queued=%t", queued), func(t *testing.T) {
s := newRetagTestState(t)
user := s.CreateUserForTest("map-key-user")
k, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
c := registerForPAKRereg(t, s, k)
view, pak := c.lookup(t, k.Key)
req := c.regReq
req.NodeKey = key.NewNode().Public()
oldRequest := tailcfg.MapRequest{
NodeKey: c.regReq.NodeKey,
Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:1234")},
Hostinfo: &tailcfg.Hostinfo{Hostname: "stale-host"},
}
if queued {
entered, release := make(chan struct{}), make(chan struct{})
unblock := sync.OnceFunc(func() { close(release) })
defer unblock()
go s.nodeStore.UpdateNode(c.node.ID(), func(*types.Node) {
close(entered)
<-release
})
<-entered
registered := make(chan error, 1)
go func() { _, err := c.reregister(view, pak, req); registered <- err }()
waitParkedOnWriteQueue(t, "reregisterNodeWithPAK")
mapped := make(chan error, 1)
go func() { _, err := s.UpdateNodeFromMapRequest(c.node.ID(), oldRequest); mapped <- err }()
waitParkedOnWriteQueue(t, "UpdateNodeFromMapRequest")
unblock()
require.NoError(t, <-registered)
require.ErrorContains(t, <-mapped, "node key is no longer current")
} else {
_, err := c.reregister(view, pak, req)
require.NoError(t, err)
change, err := s.UpdateNodeFromMapRequest(c.node.ID(), oldRequest)
require.ErrorContains(t, err, "node key is no longer current")
require.True(t, change.IsEmpty())
}
current, ok := s.GetNodeByID(c.node.ID())
require.True(t, ok)
stored, err := s.db.GetNodeByID(c.node.ID())
require.NoError(t, err)
require.Equal(t, req.NodeKey, current.NodeKey())
require.Equal(t, req.NodeKey, stored.NodeKey)
require.Equal(t, "h1", current.Hostname())
require.Equal(t, "h1", stored.Hostname)
require.Empty(t, current.Endpoints().AsSlice())
require.Empty(t, stored.Endpoints)
})
}
}