From ea6a526fa705daf6a41df8465a9665a3c9be0f7b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 09:49:52 +0000 Subject: [PATCH] policy/v2, servertest: compare rules after a device is deleted SaaS keeps a deleted device's addresses in other nodes' filter and SSH rules until a later policy push. Headscale drops them with the device. --- .../v2/tailscale_routes_data_compat_test.go | 7 ------ .../v2/tailscale_sequence_compat_test.go | 25 ++++++++----------- hscontrol/servertest/mapstream_compat_test.go | 14 +++++------ 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go index 492f50de2..a3281d9d8 100644 --- a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go @@ -257,13 +257,6 @@ var routesSkipReasons = map[string]string{ "routes-m6-subnet-to-subnet-multi-route/m-odin2/peers": sameUserPeers, } -// 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) - }) -} - 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" diff --git a/hscontrol/policy/v2/tailscale_sequence_compat_test.go b/hscontrol/policy/v2/tailscale_sequence_compat_test.go index 6d6ac6227..8960aa0a5 100644 --- a/hscontrol/policy/v2/tailscale_sequence_compat_test.go +++ b/hscontrol/policy/v2/tailscale_sequence_compat_test.go @@ -41,19 +41,18 @@ var policySequenceSkipReasons = map[string]string{ "policy-generation/G3/rt-b/filter": viaRuleMerged, "policy-generation/G4/rt-b/filter": viaRuleMerged, "policy-generation/G5/rt-b/filter": viaRuleMerged, + + "policy-generation/G7/rt-plain/filter": deletedDeviceInRules, + "policy-generation/G7/srv/ssh-rules": deletedDeviceInRules, + "policy-generation/G7/srv/uid-zero": deletedDeviceInRules, + "policy-generation/G7/srv/filter": deletedDeviceInRules, + "policy-generation/G7/rt-b/filter": "VIA_RULE_MERGED+DELETED_DEVICE_IN_RULES: the via grant's rule apart from the ACL rule, and the deleted device's addresses kept", } -const viaRuleMerged = "VIA_RULE_MERGED: on a via router headscale merges the via grant's filter rule with an ACL rule of the same sources; SaaS sends the two apart" - -// policySequenceNotCompared lists, by key prefix, checks left out: SaaS's -// answer there is in doubt, so it is no reference. -var policySequenceNotCompared = []string{ - "policy-generation/G7/rt-plain/filter", - "policy-generation/G7/srv/ssh-rules", - "policy-generation/G7/srv/uid-zero", - "policy-generation/G7/srv/filter", - "policy-generation/G7/rt-b/filter", -} +const ( + viaRuleMerged = "VIA_RULE_MERGED: on a via router headscale merges the via grant's filter rule with an ACL rule of the same sources; SaaS sends the two apart" + 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" +) // TestPolicySequenceCompat follows the capture with one PolicyManager, as // headscale does: SetNodes after each node change, SetPolicy for the edit, @@ -144,10 +143,6 @@ func runPolicySequenceCompat(t *testing.T, id string) { t.Run(alias, func(t *testing.T) { check := func(name string, mismatch func() string) { - if notCompared(policySequenceNotCompared, key+name) { - return - } - evaluated[key+name] = true compatCheck(t, policySequenceSkipReasons, key+name, mismatch) } diff --git a/hscontrol/servertest/mapstream_compat_test.go b/hscontrol/servertest/mapstream_compat_test.go index 3d7b5c273..74b561bb6 100644 --- a/hscontrol/servertest/mapstream_compat_test.go +++ b/hscontrol/servertest/mapstream_compat_test.go @@ -105,22 +105,23 @@ var mapStreamSkipReasons = map[string]string{ "policy-generation/G3/thor-2/peer-owners": sameUserPeers, "policy-generation/G4/thor-1/peer-owners": sameUserPeers, "policy-generation/G4/thor-2/peer-owners": sameUserPeers, + + "policy-generation/G7/rt-b/filter": deletedDeviceInRules, + "policy-generation/G7/rt-plain/filter": deletedDeviceInRules, + "policy-generation/G7/srv/filter": deletedDeviceInRules, + "policy-generation/G7/srv/ssh": deletedDeviceInRules, } const ( policyResponseToEveryNode = "POLICY_RESPONSE_TO_EVERY_NODE: headscale sends every connected node a policy response when the policy, a node's tags or approved routes, or the set of nodes change; SaaS sends only the nodes whose peers or filter change" 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" ) // mapStreamNotCompared lists, by key prefix, checks left out: SaaS's answer // there is in doubt, so it is no reference. var mapStreamNotCompared = []string{ - "policy-generation/G7/rt-b/filter", - "policy-generation/G7/rt-plain/filter", - "policy-generation/G7/srv/filter", - "policy-generation/G7/srv/ssh", - "user-role/S1/own-a/frames", "user-role/S1/own-b/frames", "user-role/S1/own-b/peers", @@ -1179,8 +1180,7 @@ func replayPolicySequence(t *testing.T, id string) { } t.Run(step.Name, func(t *testing.T) { - prefix := id + "/" + step.Name + "/" - holdChecks(t, mapStreamSkipReasons, prefix, compared(prefix, sequenceChecks(t, r, step, saasNames, names)), evaluated) + holdChecks(t, mapStreamSkipReasons, id+"/"+step.Name+"/", sequenceChecks(t, r, step, saasNames, names), evaluated) }) }