mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-06 06:40:06 +09:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user