From a7f7e6a4016b3c1e8877e60a0eae2277811105b2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 10 Sep 2026 13:54:20 +0000 Subject: [PATCH] servertest: compare user-owned nodes in via compat tests Updates #3408 (cherry picked from commit 23f7eedac6b6e7941c5b93d1ceda236f22c5f3fd) --- hscontrol/servertest/via_compat_test.go | 69 ++++++++++++++++++---- hscontrol/servertest/via_ha_compat_test.go | 41 +++++++++---- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index 859e5fd4..6cbe54fd 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -1,9 +1,9 @@ // This file implements data-driven via grant compatibility tests using -// golden data captured from Tailscale SaaS (v29, v30, v31, v33, v35, -// v36). These scenarios exercise via grant steering with peer -// connectivity and cross-subnet forwarding. +// golden data captured from Tailscale SaaS. These scenarios exercise via +// grant steering with peer connectivity, cross-subnet forwarding and +// exit nodes. // -// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{29,30,31,33,35,36}.hujson +// Test data source: ../policy/v2/testdata/grant_results/, see viaCompatTests // Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package servertest_test @@ -20,6 +20,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/types/netmap" ) @@ -46,14 +47,14 @@ var viaCompatTests = []struct { // // CROSS-DEPENDENCY WARNING: // This test reads golden files from ../policy/v2/testdata/grant_results/ -// (specifically via-grant-v29, v30, v31, v33, v35, v36). These files are shared +// (the ones listed in viaCompatTests). These files are shared // with TestGrantsCompat in the policy/v2 package. Any changes to the // file format, field structure, or naming must be coordinated with // BOTH tests. // // Fields consumed by this test (but NOT by TestGrantsCompat): // - captures[name].netmap (Peers, AllowedIPs, PrimaryRoutes, PacketFilterRules) -// - topology.nodes[name].tags (used for servertest node creation) +// - topology.nodes[name].tags and .user (used for servertest node creation) // // Fields consumed by TestGrantsCompat (but NOT by this test): // - captures[name].packet_filter_rules (golden filter rule comparison) @@ -88,6 +89,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) { srv := servertest.NewServer(t) tagUser := srv.CreateUser(t, "tag-user") + users := createCaptureUsers(t, srv) policyJSON := convertCapturePolicy(t, c) @@ -100,7 +102,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) { srv.App.Change(changes...) } - // Create tagged clients matching the golden topology. + // Create clients matching the golden topology: tagged nodes under + // tag-user, user-owned nodes under their capture user. // Nodes are created in SaaS registration order so headscale assigns // sequential DB IDs in the same relative order. This matters for // PrimaryRoutes election which uses lowest-node-ID-wins — the @@ -110,7 +113,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) { for _, name := range order { topoNode, exists := c.Topology.Nodes[name] - if !exists || len(topoNode.Tags) == 0 { + if !exists { continue } @@ -118,8 +121,17 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) { continue } + owner := tagUser + + if len(topoNode.Tags) == 0 { + require.Containsf(t, users, topoNode.User, + "node %s: owner %q is not a capture user", name, topoNode.User) + + owner = users[topoNode.User] + } + clients[name] = servertest.NewClient(t, srv, name, - servertest.WithUser(tagUser), + servertest.WithUser(owner), servertest.WithTags(topoNode.Tags...), ) } @@ -403,12 +415,14 @@ func compareNetmap( var wantDstPrefixes []string for _, dp := range wantRule.DstPorts { - pfx, err := parsePrefixOrAddr(dp.IP) + pfxs, err := parseDstPrefixes(dp.IP) require.NoErrorf(t, err, - "golden DstPorts[%d].IP %q should parse as prefix or addr", i, dp.IP) + "golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP) - if !isTailscaleIP(pfx) { - wantDstPrefixes = append(wantDstPrefixes, pfx.String()) + for _, pfx := range pfxs { + if !isTailscaleIP(pfx) { + wantDstPrefixes = append(wantDstPrefixes, pfx.String()) + } } } @@ -645,6 +659,35 @@ func parsePrefixOrAddr(s string) (netip.Prefix, error) { return netip.PrefixFrom(addr, addr.BitLen()), nil } +// parseDstPrefixes parses a golden DstPorts.IP into the prefixes a client +// derives from it. Besides a prefix or bare address, SaaS writes "*" for +// every address and an address range for autogroup:internet +// ("0.0.0.0-9.255.255.255"). +func parseDstPrefixes(s string) ([]netip.Prefix, error) { + if s == "*" { + return []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("::/0"), + }, nil + } + + if strings.Contains(s, "-") { + r, err := netipx.ParseIPRange(s) + if err != nil { + return nil, err + } + + return r.Prefixes(), nil + } + + pfx, err := parsePrefixOrAddr(s) + if err != nil { + return nil, err + } + + return []netip.Prefix{pfx}, nil +} + // isTailscaleIP returns true if the prefix is a single-host Tailscale // address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA). func isTailscaleIP(prefix netip.Prefix) bool { diff --git a/hscontrol/servertest/via_ha_compat_test.go b/hscontrol/servertest/via_ha_compat_test.go index 0d166500..b9e95ec7 100644 --- a/hscontrol/servertest/via_ha_compat_test.go +++ b/hscontrol/servertest/via_ha_compat_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -77,6 +78,7 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) { srv := servertest.NewServer(t) tagUser := srv.CreateUser(t, "tag-user") + createCaptureUsers(t, srv) policyJSON := convertCapturePolicy(t, c) @@ -377,11 +379,32 @@ func captureNodeOrder(t *testing.T, c *testcapture.Capture) []string { return names } +// captureUserNames maps the SaaS emails in capture policies to the user +// names capture topologies give their user-owned nodes. +var captureUserNames = map[string]string{ + "odin@example.com": "odin", + "thor@example.org": "thor", + "freya@example.com": "freya", +} + +// createCaptureUsers creates a headscale user for each capture identity, +// keyed by name. +func createCaptureUsers(t *testing.T, srv *servertest.TestServer) map[string]*types.User { + t.Helper() + + users := make(map[string]*types.User, len(captureUserNames)) + for _, name := range captureUserNames { + users[name] = srv.CreateUser(t, name) + } + + return users +} + // convertCapturePolicy converts a [testcapture.Capture]'s policy for headscale, -// replacing SaaS emails with headscale user format. Fails the test if -// none of the known SaaS emails are present: that would mean the -// capture was regenerated with a new tag-owner identity and this -// function needs updating. +// replacing each SaaS email with the "@" form that resolves to the +// users [createCaptureUsers] creates. Fails the test if none of the known +// SaaS emails are present: that would mean the capture was regenerated +// with a new identity and captureUserNames needs updating. func convertCapturePolicy(t *testing.T, c *testcapture.Capture) []byte { t.Helper() @@ -389,21 +412,17 @@ func convertCapturePolicy(t *testing.T, c *testcapture.Capture) []byte { substituted := false - for _, email := range []string{ - "odin@example.com", - "thor@example.org", - "freya@example.com", - } { + for email, name := range captureUserNames { if strings.Contains(s, email) { substituted = true - s = strings.ReplaceAll(s, email, "tag-user@") + s = strings.ReplaceAll(s, email, name+"@") } } require.True( t, substituted, - "%s: no known SaaS tag-owner email found in policy; update convertCapturePolicy", + "%s: no known SaaS email found in policy; update captureUserNames", c.TestID, )