From 782c367ee47b05111e9d14fe0e139c387444cdb8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 3 Apr 2026 09:22:22 +0000 Subject: [PATCH] servertest: improve via compat test infrastructure Add big-router to the taggedNodes list for via compat tests. This node has tags [tag:router, tag:exit] and is needed for future V-series golden file tests. Existing tests are unaffected. Enhance compareNetmap to compare PacketFilter rule content (destination prefixes, source non-emptiness), not just rule count. Six golden files have full SrcIPs/DstPorts data that was previously unvalidated. Add Tier 3 route inference to inferNodeRoutes: scan each node's own packet_filter_rules DstPorts for non-Tailscale-IP route prefixes. This catches secondary HA routers whose routes don't appear in AllowedIPs but do receive filter rules. Updates #3157 Updates #3169 --- hscontrol/servertest/via_compat_test.go | 111 ++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index b1a7972f..dd130134 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -74,6 +74,11 @@ var viaCompatTests = []struct { {"GRANT-V30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"}, {"GRANT-V31", "peer connectivity + via exit A/B steering"}, {"GRANT-V36", "full complex: peer connectivity + crossed subnet + crossed exit"}, + // TODO: V33 and V35 are not yet compatible due to: + // - V33: PrimaryRoutes election differs (node ID ordering between SaaS and headscale) + // - V35: Extra peer visibility (autoApprovers create different routing topology) + // The inferNodeRoutes Tier 3 fix enables route inference for these files, + // but the above issues need resolution first. } // TestViaGrantMapCompat loads golden captures from Tailscale SaaS and @@ -128,6 +133,7 @@ func TestViaGrantMapCompat(t *testing.T) { // taggedNodes are the nodes we create in the servertest. var taggedNodes = []string{ + "big-router", "exit-a", "exit-b", "exit-node", "group-a-client", "group-b-client", "router-a", "router-b", @@ -387,15 +393,68 @@ func compareNetmap( } } - // Compare PacketFilter rule count. + // Compare PacketFilter rules (IP-independent). + // Golden file SrcIPs contain literal Tailscale SaaS CGNAT addresses + // that differ from headscale-allocated IPs, so we compare structural + // properties rather than literal SrcIP values. var wantFilterRules []tailcfg.FilterRule if len(want.PacketFilterRules) > 0 && string(want.PacketFilterRules) != "null" { _ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules) } - assert.Lenf(t, got.PacketFilter, len(wantFilterRules), - "PacketFilter rule count mismatch") + if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules), + "PacketFilter rule count mismatch") { + return + } + + // Compare destination prefixes per rule — subnet CIDRs like + // 10.44.0.0/16 are stable between Tailscale SaaS and headscale. + // Source IPs differ entirely (different CGNAT allocation), so + // we only validate that sources exist (non-empty), not their + // exact count or values. + for i := range wantFilterRules { + wantRule := wantFilterRules[i] + gotMatch := got.PacketFilter[i] + + // Verify both rules have sources (don't compare exact + // count — IP allocation differs between SaaS and headscale). + if len(wantRule.SrcIPs) > 0 { + assert.NotEmptyf(t, gotMatch.Srcs, + "PacketFilter[%d]: golden has %d SrcIPs but headscale has none", + i, len(wantRule.SrcIPs)) + } + + // Destination prefixes: extract non-Tailscale-IP CIDRs + // from both golden and headscale rules and compare. + var wantDstPrefixes []string + + for _, dp := range wantRule.DstPorts { + pfx, err := netip.ParsePrefix(dp.IP) + if err != nil { + continue + } + + if !isTailscaleIP(pfx) { + wantDstPrefixes = append(wantDstPrefixes, pfx.String()) + } + } + + var gotDstPrefixes []string + + for _, dst := range gotMatch.Dsts { + pfx := dst.Net + if !isTailscaleIP(pfx) { + gotDstPrefixes = append(gotDstPrefixes, pfx.String()) + } + } + + slices.Sort(wantDstPrefixes) + slices.Sort(gotDstPrefixes) + + assert.Equalf(t, wantDstPrefixes, gotDstPrefixes, + "PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i) + } } type peerSummary struct { @@ -484,9 +543,10 @@ func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix { } } - // Infer from the golden netmap: scan all captures for peers with - // route prefixes in AllowedIPs. If node X appears as a peer with - // route prefix 10.44.0.0/16, then X should advertise that route. + // Tier 2: Infer from the golden netmap — scan all captures for + // peers with route prefixes in AllowedIPs. If node X appears as + // a peer with route prefix 10.44.0.0/16, then X should advertise + // that route. for _, capture := range gf.Captures { if capture.Netmap == nil { continue @@ -509,6 +569,45 @@ func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix { } } + // Tier 3: Infer from packet_filter_rules DstPorts — if a node + // receives filter rules with non-Tailscale-IP destination + // prefixes, it must be advertising those routes. This catches + // secondary HA routers whose routes don't appear in AllowedIPs + // (only the primary gets the route in AllowedIPs) but DO receive + // filter rules for those routes. + for nodeName, capture := range gf.Captures { + if len(capture.PacketFilterRules) == 0 || + string(capture.PacketFilterRules) == "null" { + continue + } + + var rules []tailcfg.FilterRule + + err := json.Unmarshal(capture.PacketFilterRules, &rules) + if err != nil { + continue + } + + for _, rule := range rules { + for _, dp := range rule.DstPorts { + prefix, err := netip.ParsePrefix(dp.IP) + if err != nil { + continue + } + + if isTailscaleIP(prefix) { + continue + } + + if !slices.Contains(result[nodeName], prefix) { + result[nodeName] = append( + result[nodeName], prefix, + ) + } + } + } + } + return result }