servertest: compare via capture filters as rule sets

Headscale merges rules sharing sources; SaaS does not. Compare
(src, dst, ports) triples, keyed by every captured node's addresses.
This commit is contained in:
Kristoffer Dalby
2026-09-25 12:51:29 +00:00
parent 7f5fdbe5d2
commit adbf4ba0f0
+97 -70
View File
@@ -9,6 +9,8 @@ package servertest_test
import ( import (
"context" "context"
"fmt"
"maps"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -22,6 +24,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go4.org/netipx" "go4.org/netipx"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
) )
@@ -224,6 +227,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
}) })
} }
saasAddrs := saasAddrsByNode(c, clients)
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap]. // Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
for viewerName, cl := range clients { for viewerName, cl := range clients {
capture := c.Captures[viewerName] capture := c.Captures[viewerName]
@@ -235,7 +240,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
nm := cl.Netmap() nm := cl.Netmap()
require.NotNil(t, nm, "netmap is nil") require.NotNil(t, nm, "netmap is nil")
compareNetmap(t, nm, capture, clients) compareNetmap(t, nm, capture, clients, saasAddrs)
}) })
} }
} }
@@ -246,12 +251,13 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16) // - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6) // - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
// - PrimaryRoutes per peer // - PrimaryRoutes per peer
// - PacketFilter rule count and non-Tailscale dst prefixes // - PacketFilter (source, destination, ports) triples
func compareNetmap( func compareNetmap(
t *testing.T, t *testing.T,
got *netmap.NetworkMap, got *netmap.NetworkMap,
want testcapture.Node, want testcapture.Node,
clients map[string]*servertest.TestClient, clients map[string]*servertest.TestClient,
saasAddrs map[netip.Addr]string,
) { ) {
t.Helper() t.Helper()
@@ -383,110 +389,131 @@ func compareNetmap(
} }
} }
// Compare PacketFilter rules (IP-independent). comparePacketFilter(t, got, want, clients, saasAddrs)
wantFilterRules := want.PacketFilterRules }
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules), // comparePacketFilter compares PacketFilter rules as a set of (source,
"PacketFilter rule count mismatch") { // destination, ports or capability) triples. Headscale merges rules
return // that share sources while SaaS keeps one rule per policy entry, so rule
} // count and order differ without changing what the filter allows.
// Tailscale IPs differ between SaaS and headscale allocation, so both
// sides are re-keyed by peer identity.
func comparePacketFilter(
t *testing.T,
got *netmap.NetworkMap,
want testcapture.Node,
clients map[string]*servertest.TestClient,
saasAddrs map[netip.Addr]string,
) {
t.Helper()
// Resolve SaaS IPs → peer name and HS IPs → peer name so we can
// compare rule sources structurally. Tailscale IPs in SaaS vs HS
// allocations never match literally, but each IP belongs to a
// peer with a stable hostname.
saasAddrs := saasAddrsByPeer(want, clients)
hsAddrs := hsAddrsByPeer(clients) hsAddrs := hsAddrsByPeer(clients)
// Compare destination prefixes per rule — subnet CIDRs like wantTriples := map[string]struct{}{}
// 10.44.0.0/16 are stable between Tailscale SaaS and headscale.
// Source IPs are re-keyed per peer identity before comparison.
for i := range wantFilterRules {
wantRule := wantFilterRules[i]
gotMatch := got.PacketFilter[i]
wantSrcIdents := canonicaliseSrcStrings(t, wantRule.SrcIPs, saasAddrs, i) for i, rule := range want.PacketFilterRules {
gotSrcIdents := canonicaliseSrcPrefixes(t, gotMatch.Srcs, hsAddrs, i) srcs := strings.Join(canonicaliseSrcStrings(t, rule.SrcIPs, saasAddrs, i), ",")
assert.Equalf(t, wantSrcIdents, gotSrcIdents, for _, dp := range rule.DstPorts {
"PacketFilter[%d]: source peer identities mismatch", i)
// Destination prefixes: extract non-Tailscale-IP CIDRs
// from both golden and headscale rules and compare.
var wantDstPrefixes []string
for _, dp := range wantRule.DstPorts {
pfxs, err := parseDstPrefixes(dp.IP) pfxs, err := parseDstPrefixes(dp.IP)
require.NoErrorf(t, err, require.NoErrorf(t, err,
"golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP) "golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP)
for _, pfx := range pfxs { for _, pfx := range pfxs {
if !isTailscaleIP(pfx) { for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
wantDstPrefixes = append(wantDstPrefixes, pfx.String()) wantTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
} }
} }
} }
var gotDstPrefixes []string for _, cg := range rule.CapGrant {
caps := slices.Collect(maps.Keys(cg.CapMap))
caps = append(caps, cg.Caps...)
for _, dst := range gotMatch.Dsts { for _, pfx := range cg.Dsts {
pfx := dst.Net for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
if !isTailscaleIP(pfx) { for _, c := range caps {
gotDstPrefixes = append(gotDstPrefixes, pfx.String()) wantTriples[capTriple(srcs, dst, c)] = struct{}{}
}
}
}
}
}
gotTriples := map[string]struct{}{}
for i, match := range got.PacketFilter {
srcs := strings.Join(canonicaliseSrcPrefixes(t, match.Srcs, hsAddrs, i), ",")
for _, dp := range match.Dsts {
for _, dst := range peerIdents(t, dp.Net, hsAddrs, i) {
gotTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
} }
} }
slices.Sort(wantDstPrefixes) for _, cm := range match.Caps {
slices.Sort(gotDstPrefixes) for _, dst := range peerIdents(t, cm.Dst, hsAddrs, i) {
gotTriples[capTriple(srcs, dst, cm.Cap)] = struct{}{}
assert.Equalf(t, wantDstPrefixes, gotDstPrefixes, }
"PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i) }
} }
assert.ElementsMatchf(t, sortedKeys(wantTriples), sortedKeys(gotTriples),
"PacketFilter (source, destination, ports) mismatch")
} }
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer func filterTriple(srcs, dst string, first, last uint16) string {
// hostname using each capture's [tailcfg.NodeView.Addresses]. Peers not in return fmt.Sprintf("%s => %s:%d-%d", srcs, dst, first, last)
// clients are skipped. }
func saasAddrsByPeer(
want testcapture.Node, func capTriple(srcs, dst string, c peercap.Cap) string {
return fmt.Sprintf("%s => %s cap %s", srcs, dst, c)
}
// peerIdents resolves a prefix into sorted canonical identity tokens,
// see [addIdentsForSrc].
func peerIdents(
t *testing.T,
pfx netip.Prefix,
addrToPeer map[netip.Addr]string,
ruleIndex int,
) []string {
t.Helper()
seen := map[string]struct{}{}
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
return sortedKeys(seen)
}
// saasAddrsByNode maps each SaaS Tailscale address to its node's
// hostname using every captured node's own addresses. A rule may name
// sources that are not the viewer's peers, so peer lists are not
// enough. Nodes not in clients are skipped.
func saasAddrsByNode(
c *testcapture.Capture,
clients map[string]*servertest.TestClient, clients map[string]*servertest.TestClient,
) map[netip.Addr]string { ) map[netip.Addr]string {
out := map[netip.Addr]string{} out := map[netip.Addr]string{}
if want.Netmap == nil { for name, node := range c.Captures {
return out
}
// Walk peers listed in this [netmap.NetworkMap].
for _, peer := range want.Netmap.Peers {
name := extractHostname(peer.Name())
if _, isOurs := clients[name]; !isOurs { if _, isOurs := clients[name]; !isOurs {
continue continue
} }
for i := range peer.Addresses().Len() { if node.Netmap == nil || !node.Netmap.SelfNode.Valid() {
pfx := peer.Addresses().At(i) continue
}
addrs := node.Netmap.SelfNode.Addresses()
for i := range addrs.Len() {
pfx := addrs.At(i)
if isTailscaleIP(pfx) { if isTailscaleIP(pfx) {
out[pfx.Addr()] = name out[pfx.Addr()] = name
} }
} }
} }
// The viewer's own [tailcfg.NodeView] addresses also appear as possible src.
if want.Netmap.SelfNode.Valid() {
name := extractHostname(want.Netmap.SelfNode.Name())
if _, isOurs := clients[name]; isOurs {
addrs := want.Netmap.SelfNode.Addresses()
for i := range addrs.Len() {
pfx := addrs.At(i)
if isTailscaleIP(pfx) {
out[pfx.Addr()] = name
}
}
}
}
return out return out
} }