From 64160bdd59d941209c250bf6fbd554d5d6da51bc Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 09:50:27 +0000 Subject: [PATCH] servertest: compare open streams after an admin role change SaaS sends a role change to no open stream: a node sees it only on its next stream, so peers disagree and a demotion keeps access. Headscale pushes the change to every connected node at once. --- hscontrol/servertest/mapstream_compat_test.go | 57 ++++++++----------- hscontrol/servertest/routes_test.go | 7 --- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/hscontrol/servertest/mapstream_compat_test.go b/hscontrol/servertest/mapstream_compat_test.go index 74b561bb6..fbd9d2e8d 100644 --- a/hscontrol/servertest/mapstream_compat_test.go +++ b/hscontrol/servertest/mapstream_compat_test.go @@ -110,6 +110,27 @@ var mapStreamSkipReasons = map[string]string{ "policy-generation/G7/rt-plain/filter": deletedDeviceInRules, "policy-generation/G7/srv/filter": deletedDeviceInRules, "policy-generation/G7/srv/ssh": deletedDeviceInRules, + + "user-role/S1/own-a/frames": userChangePushed, + "user-role/S1/own-b/frames": userChangePushed, + "user-role/S1/own-b/peers": userChangePushed, + "user-role/S1/tag-a/frames": userChangePushed, + "user-role/S1/tag-a/peers": userChangePushed, + "user-role/S1/tag-a/filter": userChangePushed, + "user-role/S1/tag-b/frames": userChangePushed, + "user-role/S1/tag-b/peers": userChangePushed, + "user-role/S1/tag-b/filter": userChangePushed, + + "user-role/S1-reconnect/own-b/peers": userChangePushed, + "user-role/S1-reconnect/tag-b/peers": userChangePushed, + "user-role/S1-reconnect/tag-b/filter": userChangePushed, + + "user-role/S2/own-a/frames": userChangePushed, + "user-role/S2/own-b/frames": userChangePushed, + "user-role/S2/tag-a/frames": userChangePushed, + "user-role/S2/tag-a/peers": userChangePushed, + "user-role/S2/tag-a/filter": userChangePushed, + "user-role/S2/tag-b/frames": userChangePushed, } const ( @@ -117,38 +138,9 @@ const ( restartEndpointsKept = "RESTART_ENDPOINTS_KEPT: headscale keeps a restarted node's last endpoints at its peers until it reports new ones; SaaS sends peers the node without endpoints until then" announcePeerResent = "ANNOUNCE_PEER_RESENT: headscale sends a router's peers its entry again whenever its announced routes change; SaaS sends the unchanged entry only after some announcements" deletedDeviceInRules = "DELETED_DEVICE_IN_RULES: SaaS keeps a deleted device's addresses in other nodes' filter and SSH rules until the next policy change; headscale drops them with the device" + userChangePushed = "USER_CHANGE_PUSHED: headscale sends every connected node a policy response as soon as a user change alters what the policy resolves; SaaS sends an admin role change to no open stream, so a node gets it only on its next new stream" ) -// mapStreamNotCompared lists, by key prefix, checks left out: SaaS's answer -// there is in doubt, so it is no reference. -var mapStreamNotCompared = []string{ - "user-role/S1/own-a/frames", - "user-role/S1/own-b/frames", - "user-role/S1/own-b/peers", - "user-role/S1/tag-a/frames", - "user-role/S1/tag-a/peers", - "user-role/S1/tag-a/filter", - "user-role/S1/tag-b/frames", - "user-role/S1/tag-b/peers", - "user-role/S1/tag-b/filter", - "user-role/S1-reconnect/own-b/peers", - "user-role/S1-reconnect/tag-b/peers", - "user-role/S1-reconnect/tag-b/filter", - "user-role/S2/own-a/frames", - "user-role/S2/own-b/frames", - "user-role/S2/tag-a/frames", - "user-role/S2/tag-a/peers", - "user-role/S2/tag-a/filter", - "user-role/S2/tag-b/frames", -} - -// compared drops the checks mapStreamNotCompared leaves out. -func compared(prefix string, checks []stepCheck) []stepCheck { - return slices.DeleteFunc(checks, func(ch stepCheck) bool { - return notCompared(mapStreamNotCompared, prefix+ch.name) - }) -} - // TestMapDeliveryCompat replays a device retagged through the API, a running // peer that picks new DERP homes, rotates its disco key, renames itself and // restarts on another port, down and up gaps of a node and a router, a rule @@ -1328,9 +1320,8 @@ func TestUserChangeDispatchCompat(t *testing.T) { r.applyUserRole(t, step) t.Run(step.Name, func(t *testing.T) { - prefix := userRoleID + "/" + step.Name + "/" - holdChecks(t, mapStreamSkipReasons, prefix, - compared(prefix, userRoleChecks(t, r, step, saasNames, updates)), evaluated) + holdChecks(t, mapStreamSkipReasons, userRoleID+"/"+step.Name+"/", + userRoleChecks(t, r, step, saasNames, updates), evaluated) }) } diff --git a/hscontrol/servertest/routes_test.go b/hscontrol/servertest/routes_test.go index 4ac26ab9b..cf943c056 100644 --- a/hscontrol/servertest/routes_test.go +++ b/hscontrol/servertest/routes_test.go @@ -482,13 +482,6 @@ func overlapCheck(t require.TestingT, key, want, got string) { } } -// notCompared reports whether key falls under one of prefixes. -func notCompared(prefixes []string, key string) bool { - return slices.ContainsFunc(prefixes, func(p string) bool { - return strings.HasPrefix(key, p) - }) -} - // peerRoutes maps each peer in clients to its AllowedIPs and PrimaryRoutes. func peerRoutes( nm *netmap.NetworkMap,