From 490305417c52595b57fa437821761767fb6e9d53 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:11:45 +0000 Subject: [PATCH] policy/v2: make a user's own devices peers under any policy As SaaS. Without the peer, sending a file to your own device failed with 404 under a restrictive policy. The packet filter is unchanged. --- hscontrol/mapper/batcher_test.go | 4 +- hscontrol/mapper/batcher_unit_test.go | 4 +- hscontrol/mapper/mapper_test.go | 6 +- hscontrol/policy/v2/canaccess_equiv_test.go | 2 +- hscontrol/policy/v2/peermap_scan_test.go | 25 ++-- hscontrol/policy/v2/policy.go | 21 ++- .../v2/tailscale_routes_data_compat_test.go | 11 -- hscontrol/servertest/mapstream_compat_test.go | 16 --- hscontrol/servertest/scale_test.go | 128 +++++++++++------- hscontrol/servertest/via_compat_test.go | 11 -- hscontrol/state/reload_policy_test.go | 6 +- 11 files changed, 127 insertions(+), 107 deletions(-) diff --git a/hscontrol/mapper/batcher_test.go b/hscontrol/mapper/batcher_test.go index 6c4bfcc87..9bdd9a6b5 100644 --- a/hscontrol/mapper/batcher_test.go +++ b/hscontrol/mapper/batcher_test.go @@ -2291,8 +2291,8 @@ func TestEvictedSessionLeavesPeerTrackingAlone(t *testing.T) { td, cleanup := setupBatcherWithTestData( t, NewBatcherAndMapper, - 1, 2, + 1, normalBufferSize, ) defer cleanup() @@ -2379,8 +2379,8 @@ func TestOlderInitialMapLeavesNewerStreamsPeers(t *testing.T) { td, cleanup := setupBatcherWithTestData( t, NewBatcherAndMapper, - 1, 2, + 1, normalBufferSize, ) defer cleanup() diff --git a/hscontrol/mapper/batcher_unit_test.go b/hscontrol/mapper/batcher_unit_test.go index ca4e16e11..83f22e594 100644 --- a/hscontrol/mapper/batcher_unit_test.go +++ b/hscontrol/mapper/batcher_unit_test.go @@ -385,7 +385,7 @@ func TestBatcherRetryUsesCurrentPolicyAfterReconnect(t *testing.T) { } func TestBatcherUnreadyRetryRegeneratesRemovals(t *testing.T) { - testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, 1) + testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 2, 1, 1) defer cleanup() b := NewBatcher(time.Hour, 1, testData.Batcher.mapper) @@ -1767,7 +1767,7 @@ func TestMultiChannelSend_SSHPolicyPerConnection(t *testing.T) { } func TestPartialPeerAdditionThenRevocation(t *testing.T) { - td, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, 1) + td, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 2, 1, 1) defer cleanup() _, err := td.State.SetPolicy([]byte(`{"acls":[]}`)) diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index b58393c1d..90c71e32c 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -673,8 +673,8 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) { // wantFull pins the actual peer-visibility semantics so the cross-path // check below cannot pass vacuously (e.g. if every path broke to zero // identically). - // Note deny_all: an empty ACL set yields no peer adjacency, so nothing is - // visible on any path. user_isolation and autogroup_self remain the + // Note deny_all: an empty ACL set yields no peer adjacency beyond a user's + // own devices, on any path. user_isolation and autogroup_self remain the // discriminating cases that prove filtering works. tests := []struct { name string @@ -690,7 +690,7 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) { ]}`, 1, }, - {"deny_all", `{"acls":[]}`, 0}, + {"deny_all", `{"acls":[]}`, 1}, { "autogroup_self", `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}`, diff --git a/hscontrol/policy/v2/canaccess_equiv_test.go b/hscontrol/policy/v2/canaccess_equiv_test.go index 20fff0785..1d9397b25 100644 --- a/hscontrol/policy/v2/canaccess_equiv_test.go +++ b/hscontrol/policy/v2/canaccess_equiv_test.go @@ -197,7 +197,7 @@ func peerMapPrefixReference( canAccessPrefixReference(b, a, mi) || canAccessPrefixReference(a, b, mj) || canAccessPrefixReference(b, a, mj) - if linkedByEitherPolicy { + if linkedByEitherPolicy || sameUser(a.View(), b.View()) { peers[a.ID] = append(peers[a.ID], b.ID) peers[b.ID] = append(peers[b.ID], a.ID) } diff --git a/hscontrol/policy/v2/peermap_scan_test.go b/hscontrol/policy/v2/peermap_scan_test.go index 3ae704d5a..6d6aec716 100644 --- a/hscontrol/policy/v2/peermap_scan_test.go +++ b/hscontrol/policy/v2/peermap_scan_test.go @@ -44,8 +44,10 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView]) } ri, rj := routeInfo[nodes.At(i).ID()], routeInfo[nodes.At(j).ID()] - if nodes.At(i).CanAccessWithRoutes(pm.matchers, nodes.At(j), ri.subnet, rj.subnet, rj.isExit) || - nodes.At(j).CanAccessWithRoutes(pm.matchers, nodes.At(i), rj.subnet, ri.subnet, ri.isExit) { + + linked := nodes.At(i).CanAccessWithRoutes(pm.matchers, nodes.At(j), ri.subnet, rj.subnet, rj.isExit) || + nodes.At(j).CanAccessWithRoutes(pm.matchers, nodes.At(i), rj.subnet, ri.subnet, ri.isExit) + if linked || sameUser(nodes.At(i), nodes.At(j)) { ret[nodes.At(i).ID()] = append(ret[nodes.At(i).ID()], nodes.At(j).ID()) ret[nodes.At(j).ID()] = append(ret[nodes.At(j).ID()], nodes.At(i).ID()) } @@ -70,10 +72,13 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView]) matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()] riJ := routeInfo[nodeJ.ID()] - if hasFilterI && nodeI.CanAccessWithRoutes(matchersI, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) || - hasFilterJ && nodeJ.CanAccessWithRoutes(matchersJ, nodeI, riJ.subnet, riI.subnet, riI.isExit) || - hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit) || - hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) { + canIAccessJ := hasFilterI && nodeI.CanAccessWithRoutes(matchersI, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) + canJAccessI := hasFilterJ && nodeJ.CanAccessWithRoutes(matchersJ, nodeI, riJ.subnet, riI.subnet, riI.isExit) + canJReachI := hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit) + canIReachJ := hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) + + linked := canIAccessJ || canJAccessI || canJReachI || canIReachJ + if linked || sameUser(nodeI, nodeJ) { ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ.ID()) ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI.ID()) } @@ -578,8 +583,8 @@ func buildPeerMapSortedSlice( continue } - if sortedMatcherSetsIntersect(srcOf[i], dstOf[j]) || - sortedMatcherSetsIntersect(srcOf[j], dstOf[i]) { + linked := sortedMatcherSetsIntersect(srcOf[i], dstOf[j]) || sortedMatcherSetsIntersect(srcOf[j], dstOf[i]) + if linked || sameUser(nodes.At(i), nodes.At(j)) { ret[ids[i]] = append(ret[ids[i]], ids[j]) ret[ids[j]] = append(ret[ids[j]], ids[i]) } @@ -674,8 +679,8 @@ func buildPeerMapSliceMap( continue } - if sliceMapMatcherSetsIntersect(srcOf[i], dstOf[j]) || - sliceMapMatcherSetsIntersect(srcOf[j], dstOf[i]) { + linked := sliceMapMatcherSetsIntersect(srcOf[i], dstOf[j]) || sliceMapMatcherSetsIntersect(srcOf[j], dstOf[i]) + if linked || sameUser(nodes.At(i), nodes.At(j)) { ret[ids[i]] = append(ret[ids[i]], ids[j]) ret[ids[j]] = append(ret[ids[j]], ids[i]) } diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index ae35bfc99..bf5a2a3fe 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -906,7 +906,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ continue } - if canAccess(srcOf[i], dstOf[j]) || canAccess(srcOf[j], dstOf[i]) { + linked := canAccess(srcOf[i], dstOf[j]) || canAccess(srcOf[j], dstOf[i]) + if linked || sameUser(nodes.At(i), nodes.At(j)) { ret[ids[i]] = append(ret[ids[i]], ids[j]) ret[ids[j]] = append(ret[ids[j]], ids[i]) } @@ -959,7 +960,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ canJReachI := hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit) canIReachJ := hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) - if canIAccessJ || canJAccessI || canJReachI || canIReachJ { + linked := canIAccessJ || canJAccessI || canJReachI || canIReachJ + if linked || sameUser(nodeI, nodeJ) { ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ.ID()) ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI.ID()) } @@ -983,6 +985,21 @@ func matcherReachesDestination( (isExit && m.DestsIsTheInternet()) } +// sameUser reports whether two nodes belong to one user. A user's own devices +// are always each other's peers, whatever the rules say, as on SaaS: clients +// need the peer to send a file to their own device. It grants no access, so +// the packet filter still decides what can connect. +func sameUser(a, b types.NodeView) bool { + if a.IsTagged() || b.IsTagged() { + return false + } + + userA, okA := a.UserID().GetOk() + userB, okB := b.UserID().GetOk() + + return okA && okB && userA == userB +} + // filterRulesForNodeLocked returns the unreduced compiled filter rules // for a node, combining pre-compiled global rules with per-node self // and via rules from the stored compiled grants. diff --git a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go index 49e486239..6e25a692e 100644 --- a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go @@ -189,15 +189,6 @@ var routesSkipReasons = map[string]string{ "routes-m4-member-self/m-odin1/filter": filterAddressForm, "routes-m4-member-self/m-odin2/filter": filterAddressForm, "routes-m4-member-self/r-exit/filter": "FILTER_ADDRESS_FORM: same addresses, different wire form and rule order", - - "routes-m1-wildcard-src-multi-range/m-odin1/peers": sameUserPeers, - "routes-m1-wildcard-src-multi-range/m-odin2/peers": sameUserPeers, - "routes-m2-member-partial-subnet/m-odin1/peers": sameUserPeers, - "routes-m2-member-partial-subnet/m-odin2/peers": sameUserPeers, - "routes-m3-subnet-route-as-src/m-odin1/peers": sameUserPeers, - "routes-m3-subnet-route-as-src/m-odin2/peers": sameUserPeers, - "routes-m6-subnet-to-subnet-multi-route/m-odin1/peers": sameUserPeers, - "routes-m6-subnet-to-subnet-multi-route/m-odin2/peers": sameUserPeers, } // routesNotCompared lists, by key prefix, checks left out: SaaS's answer @@ -276,8 +267,6 @@ func notCompared(prefixes []string, key string) bool { const filterAddressForm = "FILTER_ADDRESS_FORM: same addresses, different wire form" -const sameUserPeers = "SAME_USER_PEERS: SaaS lists a user's own devices as each other's peers with no rule linking them; headscale peers only what the policy links" - // routesSetByAPI reports whether the capture's routes were approved through // the routes API rather than by the policy. func routesSetByAPI(c *testcapture.Capture) bool { diff --git a/hscontrol/servertest/mapstream_compat_test.go b/hscontrol/servertest/mapstream_compat_test.go index c05e10464..196d36e0c 100644 --- a/hscontrol/servertest/mapstream_compat_test.go +++ b/hscontrol/servertest/mapstream_compat_test.go @@ -63,9 +63,6 @@ var mapStreamSkipReasons = map[string]string{ "map-delivery/S2.5/laptop/endpoints-gap-bystander": restartEndpointsKept, "map-delivery/S2.5/laptop/endpoints-gap-svc": restartEndpointsKept, - "map-delivery/S3.6/bystander/peers": sameUserPeers, - "map-delivery/S3.6/laptop/peers": sameUserPeers, - "hidden-peers/S2/witness/presence": policyResponseToEveryNode, "hidden-peers/S8/observer/presence": policyResponseToEveryNode, "hidden-peers/S9/observer/presence": policyResponseToEveryNode, @@ -81,19 +78,6 @@ var mapStreamSkipReasons = map[string]string{ "route-announce/RA9/rtr/presence": policyResponseToEveryNode, "route-announce/RA9/srv/presence": policyResponseToEveryNode, "route-announce/RA9/wit/presence": policyResponseToEveryNode, - - "policy-generation/G2/thor-1/peers": sameUserPeers, - "policy-generation/G2/thor-2/peers": sameUserPeers, - "policy-generation/G3/thor-1/peers": sameUserPeers, - "policy-generation/G3/thor-2/peers": sameUserPeers, - "policy-generation/G4/thor-1/peers": sameUserPeers, - "policy-generation/G4/thor-2/peers": sameUserPeers, - "policy-generation/G2/thor-1/peer-owners": sameUserPeers, - "policy-generation/G2/thor-2/peer-owners": sameUserPeers, - "policy-generation/G3/thor-1/peer-owners": sameUserPeers, - "policy-generation/G3/thor-2/peer-owners": sameUserPeers, - "policy-generation/G4/thor-1/peer-owners": sameUserPeers, - "policy-generation/G4/thor-2/peer-owners": sameUserPeers, } const ( diff --git a/hscontrol/servertest/scale_test.go b/hscontrol/servertest/scale_test.go index 55f1614be..a35f4076b 100644 --- a/hscontrol/servertest/scale_test.go +++ b/hscontrol/servertest/scale_test.go @@ -30,7 +30,6 @@ type scaleCase struct { policy string taggedService bool ssh bool - peerCount func(node, total int) int canSend func(source, destination int) bool } @@ -47,10 +46,6 @@ func TestScale(t *testing.T) { require.GreaterOrEqual(t, nodes, 2, "need at least two connected nodes") } - personalPeers := func(node, total int) int { - first := node / scaleDevicesPerUser * scaleDevicesPerUser - return min(scaleDevicesPerUser, total-first) - 1 - } personalVisibility := func(node, peer int) bool { return node/scaleDevicesPerUser == peer/scaleDevicesPerUser } @@ -61,14 +56,12 @@ func TestScale(t *testing.T) { policy: `{ "grants": [{"src": ["*"], "dst": ["*"], "ip": ["tcp:443"]}] }`, - peerCount: func(_, total int) int { return total - 1 }, - canSend: func(_, _ int) bool { return true }, + canSend: func(_, _ int) bool { return true }, }, { - name: "isolated", - policy: `{"acls": []}`, - peerCount: func(_, _ int) int { return 0 }, - canSend: func(_, _ int) bool { return false }, + name: "isolated", + policy: `{"acls": []}`, + canSend: func(_, _ int) bool { return false }, }, { name: "personal_devices", @@ -78,8 +71,7 @@ func TestScale(t *testing.T) { "ip": ["tcp:443"] }] }`, - peerCount: personalPeers, - canSend: personalVisibility, + canSend: personalVisibility, }, { name: "shared_service", @@ -91,14 +83,7 @@ func TestScale(t *testing.T) { }] }`, taggedService: true, - peerCount: func(node, total int) int { - if node == 0 { - return total - 1 - } - - return 1 - }, - canSend: func(source, destination int) bool { return source != 0 && destination == 0 }, + canSend: func(source, destination int) bool { return source != 0 && destination == 0 }, }, { name: "personal_ssh", @@ -112,9 +97,8 @@ func TestScale(t *testing.T) { "dst": ["autogroup:self"], "users": ["root"] }] }`, - ssh: true, - peerCount: personalPeers, - canSend: personalVisibility, + ssh: true, + canSend: personalVisibility, }, } @@ -220,10 +204,7 @@ func runScale(t *testing.T, nodes int, tc scaleCase) { deadline := time.Now().Add(convergenceTimeout) for i, c := range clients { - wantPeers := tc.peerCount(i, nodes) - if revoked { - wantPeers = 0 - } + wantPeers := tc.peerCount(i, nodes, revoked) c.WaitForCondition( t, @@ -244,10 +225,13 @@ func runScale(t *testing.T, nodes int, tc scaleCase) { return false } - return tc.peersMatch(nm, i, indices, osMarker) + // The policy's attribute can reach the node a frame ahead of + // its filter, and peers alone may not tell the two apart. + return tc.peersMatch(nm, i, indices, osMarker) && + scalePacketFilterMismatch(tc, nm, selfNodes, i, revoked) == "" }, ) - checkScalePacketFilter(t, tc, c.Netmap(), selfNodes, i, revoked) + require.Empty(t, scalePacketFilterMismatch(tc, c.Netmap(), selfNodes, i, revoked)) if tc.ssh && !revoked { checkScaleSSH(t, c.Netmap().SSHPolicy, selfNodes, i) @@ -337,6 +321,40 @@ func runScale(t *testing.T, nodes int, tc scaleCase) { }) } +// peerCount is how many peers viewer has: the nodes it can reach or be reached +// from, and its user's other devices, which are peers under any policy. +func (tc scaleCase) peerCount(viewer, total int, revoked bool) int { + count := 0 + + for j := range total { + if j == viewer { + continue + } + + reachable := !revoked && tc.linked(viewer, j) + if reachable || tc.sameUser(viewer, j) { + count++ + } + } + + return count +} + +// sameUser reports whether two nodes are devices of one user. The tagged +// service belongs to no user. +func (tc scaleCase) sameUser(a, b int) bool { + if tc.taggedService && (a == 0 || b == 0) { + return false + } + + return a/scaleDevicesPerUser == b/scaleDevicesPerUser +} + +// linked reports whether the policy lets either node reach the other. +func (tc scaleCase) linked(a, b int) bool { + return tc.canSend(a, b) || tc.canSend(b, a) +} + func (tc scaleCase) peersMatch( nm *netmap.NetworkMap, viewer int, @@ -347,8 +365,11 @@ func (tc scaleCase) peersMatch( for _, peer := range nm.Peers { j, ok := indices[peer.ID()] - if !ok || j == viewer || (!tc.canSend(viewer, j) && !tc.canSend(j, viewer)) || - peer.ID() <= previous { + if !ok || j == viewer || peer.ID() <= previous { + return false + } + + if !tc.linked(viewer, j) && !tc.sameUser(viewer, j) { return false } @@ -405,24 +426,25 @@ func updateScaleClients( } } -// Probe a few sources per node instead of making the test itself all-pairs. -func checkScalePacketFilter( - t *testing.T, +// scalePacketFilterMismatch names the first verdict nm's packet filter gets +// wrong, or returns "". Probe a few sources per node instead of making the test +// itself all-pairs. +func scalePacketFilterMismatch( tc scaleCase, nm *netmap.NetworkMap, selfNodes []tailcfg.NodeView, viewer int, revoked bool, -) { - t.Helper() - +) string { var localIPs netipx.IPSetBuilder for _, prefix := range nm.SelfNode.Addresses().All() { localIPs.AddPrefix(prefix) } local, err := localIPs.IPSet() - require.NoError(t, err) + if err != nil { + return err.Error() + } packetFilter := filter.New(nm.PacketFilter, nil, local, nil, nil, logger.Discard) @@ -439,20 +461,30 @@ func checkScalePacketFilter( want = filter.Accept } - require.Equal(t, want, packetFilter.CheckTCP(src, dst.Addr(), 443), - "%d -> %d TCP 443", selfNodes[source].ID(), selfNodes[viewer].ID()) - require.Equal(t, filter.Drop, packetFilter.CheckTCP(src, dst.Addr(), 80)) - require.Equal( - t, - filter.Drop, - packetFilter.Check(src, dst.Addr(), 443, ipproto.UDP), - ) + type verdict struct { + name string + want, got filter.Response + } + checks := []verdict{ + {"TCP 443", want, packetFilter.CheckTCP(src, dst.Addr(), 443)}, + {"TCP 80", filter.Drop, packetFilter.CheckTCP(src, dst.Addr(), 80)}, + {"UDP 443", filter.Drop, packetFilter.Check(src, dst.Addr(), 443, ipproto.UDP)}, + } if tc.ssh { - require.Equal(t, want, packetFilter.CheckTCP(src, dst.Addr(), 22)) + checks = append(checks, verdict{"TCP 22", want, packetFilter.CheckTCP(src, dst.Addr(), 22)}) + } + + for _, check := range checks { + if check.got != check.want { + return fmt.Sprintf("%d -> %d %s: got %v, want %v", + selfNodes[source].ID(), selfNodes[viewer].ID(), check.name, check.got, check.want) + } } } } + + return "" } func checkScaleSSH( diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index c1ee5f1c7..6db3efc2c 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -143,19 +143,8 @@ var aclMapCompatTests = []string{ // same way. A listed check that matches fails, so a fix must delete its entry. var viaCompatSkipReasons = map[string]string{ "tagged-self": "TAGGED_SELF_PROFILE: SaaS gives a tagged node a profile of its own, headscale the shared tagged-devices one", - - "routes-m1-wildcard-src-multi-range/m-odin1/peers": sameUserPeers, - "routes-m1-wildcard-src-multi-range/m-odin2/peers": sameUserPeers, - "routes-m2-member-partial-subnet/m-odin1/peers": sameUserPeers, - "routes-m2-member-partial-subnet/m-odin2/peers": sameUserPeers, - "routes-m3-subnet-route-as-src/m-odin1/peers": sameUserPeers, - "routes-m3-subnet-route-as-src/m-odin2/peers": sameUserPeers, - "routes-m6-subnet-to-subnet-multi-route/m-odin1/peers": sameUserPeers, - "routes-m6-subnet-to-subnet-multi-route/m-odin2/peers": sameUserPeers, } -const sameUserPeers = "SAME_USER_PEERS: SaaS lists a user's own devices as each other's peers with no rule linking them; headscale peers only what the policy links" - // TestRoutesMapCompat replays routesMapCompatTests through // [runViaMapCompat]. func TestRoutesMapCompat(t *testing.T) { diff --git a/hscontrol/state/reload_policy_test.go b/hscontrol/state/reload_policy_test.go index 6bb5dff5c..9553b65a5 100644 --- a/hscontrol/state/reload_policy_test.go +++ b/hscontrol/state/reload_policy_test.go @@ -25,7 +25,11 @@ func TestReloadPolicyRebuildsPeerMapOnChangeOnly(t *testing.T) { database, err := db.NewHeadscaleDatabase(cfg) require.NoError(t, err) - nodes := database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-user"), 2, "reload-node") + // Two users: a user's own devices stay peers under any policy. + nodes := append( + database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-user"), 1, "reload-node"), + database.CreateRegisteredNodesForTest(database.CreateUserForTest("reload-other"), 1, "reload-other")..., + ) require.NoError(t, database.Close()) s, err := NewState(cfg)