policy/v2, servertest: add multi-route router captures

A seven-route router and an exit node under six policies; replayed
through policy, peer map and map stream. Peer map now checked against
SaaS peers.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 16:09:00 +01:00
1 parent 80472006c4
commit 1449dc8c4f
8 files changed
+44310 -17

No files matched your search

@@ -14,7 +14,7 @@
// + ReduceFilterRules) against golden file captures.
//
// - TestRoutesCompatPeerVisibility: validates peer visibility (CanAccess)
// for the subnet-to-subnet scenarios (f10–f15). These tests
// for the subnet-to-subnet scenarios (f10–f15, m6). These tests
// derive expected peer relationships from the golden file captures: if
// Tailscale SaaS delivers filter rules to a node, then the subnet routers
// referenced in those rules must be visible as peers. This exercises the
@@ -174,7 +174,27 @@ func buildRoutesUsersAndNodes(
// routesSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<node>/<check>. A listed check that matches fails, so a fix must
// delete its entry.
var routesSkipReasons = map[string]string{}
var routesSkipReasons = map[string]string{
// SaaS lists contiguous source addresses one by one; headscale merges
// them into ranges.
"routes-m2-member-partial-subnet/r-exit/filter": filterAddressForm,
"routes-m2-member-partial-subnet/r-multi/filter": filterAddressForm,
"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/srv/peer-allowed-ips": exitRoutesOnOverlap,
"routes-m6-subnet-to-subnet-multi-route/r-multi/peer-allowed-ips": exitRoutesOnOverlap,
"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
// there is in doubt, so it is no reference.
@@ -210,6 +230,37 @@ var routesNotCompared = []string{
"routes-t5-oauth-routes-scope/ovl-r-mapped/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-via/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-ctrl/peers",
"routes-t1-auto-v4-default/ovl-r-host4/peers",
"routes-t1-auto-v4-default/ovl-r-host6/peers",
"routes-t1-auto-v4-default/ovl-r-mapped/peers",
"routes-t1-auto-v4-default/ovl-r-narrow/peers",
"routes-t1-auto-v4-default/ovl-r-via/peers",
"routes-t1-auto-v4-default/ovl-r-wide-in/peers",
"routes-t2-auto-v6-default/ovl-r-ctrl/peers",
"routes-t2-auto-v6-default/ovl-r-host4/peers",
"routes-t2-auto-v6-default/ovl-r-host6/peers",
"routes-t2-auto-v6-default/ovl-r-mapped/peers",
"routes-t2-auto-v6-default/ovl-r-narrow/peers",
"routes-t2-auto-v6-default/ovl-r-via/peers",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peers",
"routes-t3-auto-narrow-keys/ovl-r-host4/peers",
"routes-t3-auto-narrow-keys/ovl-r-host6/peers",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peers",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peers",
"routes-t3-auto-narrow-keys/ovl-r-via/peers",
"routes-t4-admin/ovl-r-ctrl/peers",
"routes-t4-admin/ovl-r-host4/peers",
"routes-t4-admin/ovl-r-host6/peers",
"routes-t4-admin/ovl-r-mapped/peers",
"routes-t4-admin/ovl-r-narrow/peers",
"routes-t4-admin/ovl-r-via/peers",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peers",
"routes-t5-oauth-routes-scope/ovl-r-host4/peers",
"routes-t5-oauth-routes-scope/ovl-r-host6/peers",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peers",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peers",
"routes-t5-oauth-routes-scope/ovl-r-via/peers",
}
// notCompared reports whether key falls under one of prefixes.
@@ -219,6 +270,12 @@ func notCompared(prefixes []string, key string) bool {
})
}
const filterAddressForm = "FILTER_ADDRESS_FORM: same addresses, different wire form"
const exitRoutesOnOverlap = "EXIT_ROUTES_ON_OVERLAP: headscale offers a peer's exit routes to a viewer whose rules reach only nodes or subnets; SaaS leaves them out"
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 {
@@ -244,6 +301,7 @@ var subnetToSubnetFiles = []string{
"routes-f13-subnet-to-subnet-disjoint",
"routes-f14-subnet-to-subnet-overlapping-one-router",
"routes-f15-subnet-to-subnet-cross-routers",
"routes-m6-subnet-to-subnet-multi-route",
}
// TestRoutesCompat is a data-driven test that loads all routes-*.hujson test
@@ -316,18 +374,10 @@ func TestRoutesCompat(t *testing.T) {
cmpOptions(),
cmpopts.EquateEmpty(),
)
if diff := cmp.Diff(
wantRules,
gotRules,
opts...,
); diff != "" {
t.Errorf(
"%s/%s: filter rules mismatch (-want +got):\n%s",
tf.TestID,
nodeName,
diff,
)
}
compatCheck(t, routesSkipReasons, tf.TestID+"/"+nodeName+"/filter", func() string {
return cmp.Diff(wantRules, gotRules, opts...)
})
})
}
})
@@ -1579,6 +1629,78 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
)
}
// TestRoutesCompatPeerMap checks that BuildPeerMap gives each captured node
// exactly the topology nodes SaaS listed as its peers.
func TestRoutesCompatPeerMap(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "routes-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(t, files)
for _, file := range files {
tf := loadRoutesTestFile(t, file)
if tf.Error {
continue
}
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
require.NoError(t, err, "%s: failed to create policy manager", tf.TestID)
names := make(map[types.NodeID]string, len(nodes))
for _, n := range nodes {
names[n.ID] = n.GivenName
}
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
for viewerName, capture := range tf.Captures {
if capture.Netmap == nil {
continue
}
viewer := findNodeByGivenName(nodes, viewerName)
require.NotNilf(t, viewer, "golden node %s not found in topology", viewerName)
key := tf.TestID + "/" + viewerName + "/peers"
if notCompared(routesNotCompared, key) {
continue
}
t.Run(viewerName, func(t *testing.T) {
want := []string{}
for _, p := range capture.Netmap.Peers {
name, _, _ := strings.Cut(p.Name(), ".")
if findNodeByGivenName(nodes, name) != nil {
want = append(want, name)
}
}
got := []string{}
for _, id := range peerMap[viewer.ID] {
got = append(got, names[id])
}
slices.Sort(want)
slices.Sort(got)
compatCheck(t, routesSkipReasons, key,
func() string { return cmp.Diff(want, got) })
})
}
})
}
}
// TestRoutesCompatPeerCapMap validates the peer-view CapMap against SaaS
// for route scenarios, whose policies carry no nodeAttrs. SaaS stamps
// suggest-exit-node on approved exit peers regardless of policy; the
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+53 -3
View File
@@ -92,6 +92,12 @@ var routesMapCompatTests = []struct {
{"routes-f3-wildcard-src-specific-dst", "ACL to one port of a subnet"},
{"routes-h1-wildcard-srcips-format", "wildcard source to tagged routers"},
{"routes-pa1-partial-route-approval", "router with an unapproved route"},
{"routes-m1-wildcard-src-multi-range", "wildcard source to a tagged port, multi-route router"},
{"routes-m2-member-partial-subnet", "members to one route of a multi-route router"},
{"routes-m3-subnet-route-as-src", "one route of a multi-route router as source"},
{"routes-m4-member-self", "members to autogroup:self beside a multi-route router"},
{"routes-m5-wildcard-all", "allow-all with a multi-route router and an exit node"},
{"routes-m6-subnet-to-subnet-multi-route", "subnet of a multi-route router to a subnet of an exit node"},
}
// viaCompatSkipReasons lists checks where headscale differs from SaaS, keyed
@@ -151,6 +157,16 @@ var viaCompatSkipReasons = map[string]string{
"routes-h1-wildcard-srcips-format/butterfree/self-routes": selfRoutesReduced,
"routes-h1-wildcard-srcips-format/metapod/self-routes": selfRoutesReduced,
"routes-pa1-partial-route-approval/butterfree/self-routes": selfRoutesReduced,
"routes-m1-wildcard-src-multi-range/r-exit/self-routes": selfRoutesReduced,
"routes-m1-wildcard-src-multi-range/r-multi/self-routes": selfRoutesReduced,
"routes-m2-member-partial-subnet/r-exit/self-routes": selfRoutesReduced,
"routes-m2-member-partial-subnet/r-multi/self-routes": selfRoutesReduced,
"routes-m3-subnet-route-as-src/r-exit/self-routes": selfRoutesReduced,
"routes-m3-subnet-route-as-src/r-multi/self-routes": selfRoutesReduced,
"routes-m4-member-self/r-exit/self-routes": selfRoutesReduced,
"routes-m4-member-self/r-multi/self-routes": selfRoutesReduced,
"routes-m6-subnet-to-subnet-multi-route/r-exit/self-routes": selfRoutesReduced,
"routes-m6-subnet-to-subnet-multi-route/r-multi/self-routes": selfRoutesReduced,
"via-grant-v14/blastoise/self-routes": selfRoutesReduced,
"via-grant-v14/fearow/self-routes": selfRoutesReduced,
@@ -232,10 +248,26 @@ var viaCompatSkipReasons = map[string]string{
"via-grant-v66/squirtle/self-routes": selfRoutesReduced,
"via-grant-v67/blastoise/self-routes": selfRoutesReduced,
"via-grant-v67/squirtle/self-routes": selfRoutesReduced,
"routes-m1-wildcard-src-multi-range/srv/peers": exitRoutesOnOverlap,
"routes-m6-subnet-to-subnet-multi-route/r-multi/peers": exitRoutesOnOverlap,
"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 viaSourceLossNotSent = "VIA_SOURCE_LOSS_NOT_SENT: a user change that leaves a via grant without sources does not reach open streams"
const exitRoutesOnOverlap = "EXIT_ROUTES_ON_OVERLAP: headscale offers a peer's exit routes to a viewer whose rules reach only nodes or subnets; SaaS leaves them out"
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"
const selfRoutesReduced = "SELF_ROUTES_REDUCED: headscale sends a router only the primary routes its policy lets it reach"
// TestRoutesMapCompat replays routesMapCompatTests through
@@ -407,12 +439,17 @@ func startViaMapCompat(
srv, clients := startCaptureClients(t, c, true)
// Wait for peers based on golden [netmap.NetworkMap] expected counts.
// A viewer whose peers are a listed difference never reaches them.
for viewerName, cl := range clients {
capture := c.Captures[viewerName]
if capture.Netmap == nil {
continue
}
if _, listed := viaCompatSkipReasons[c.TestID+"/"+viewerName+"/peers"]; listed {
continue
}
expected := 0
for _, peer := range capture.Netmap.Peers {
@@ -744,7 +781,15 @@ func compareNetmap(
h.Helper()
}
comparePeers(t, got, want, clients)
peers := func(tt require.TestingT) { comparePeers(tt, got, want, clients) }
if reason, listed := viaCompatSkipReasons[key+"/peers"]; listed {
if passesOnce(peers) {
t.Errorf("peers match SaaS; delete viaCompatSkipReasons[%q] (%s)", key+"/peers", reason)
}
} else {
peers(t)
}
comparePacketFilter(t, got, want, clients, saasAddrs, key+"/packet-filter", viaCompatSkipReasons)
compareUserProfiles(t, got, want.Netmap, clients)
}
@@ -894,8 +939,9 @@ func comparePeers(
}
// compareUserProfiles compares the profile the viewer holds for itself and
// for each peer in clients. Profile IDs differ between SaaS and headscale, so
// a profile is its login, with SaaS emails mapped to user names, and its
// for each peer in clients that both sides list; a peer on one side only is
// the peers check's. Profile IDs differ between SaaS and headscale, so a
// profile is its login, with SaaS emails mapped to user names, and its
// display name.
func compareUserProfiles(
t require.TestingT,
@@ -909,6 +955,10 @@ func compareUserProfiles(
gotProfiles := nodeProfiles(got, clients)
for name, wantProfile := range nodeProfiles(want, clients) {
if _, ok := gotProfiles[name]; !ok {
continue
}
check := "peer"
if name == "" {
check = "self"