Files
headscale/hscontrol/servertest/via_compat_test.go
Kristoffer Dalby 30dce30a9d testdata: convert .json to .hujson with header comments
Rename all 594 test data files from .json to .hujson and add
descriptive header comments to each file documenting what policy
rules are under test and what outcome is expected.

Update test loaders in all 5 _test.go files to parse HuJSON via
hujson.Parse/Standardize/Pack before json.Unmarshal.

Add cross-dependency warning to via_compat_test.go documenting
that GRANT-V29/V30/V31/V36 are shared with TestGrantsCompat.

Add .gitignore exemption for testdata HuJSON files.
2026-04-01 14:10:42 +01:00

534 lines
14 KiB
Go

package servertest_test
import (
"context"
"encoding/json"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tailscale/hujson"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// goldenFile represents a golden capture from Tailscale SaaS with full
// netmap data per node.
type goldenFile struct {
TestID string `json:"test_id"`
Error bool `json:"error"`
Input struct {
FullPolicy json.RawMessage `json:"full_policy"`
} `json:"input"`
Topology struct {
Nodes map[string]goldenNode `json:"nodes"`
} `json:"topology"`
Captures map[string]goldenCapture `json:"captures"`
}
type goldenNode struct {
Hostname string `json:"hostname"`
Tags []string `json:"tags"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
AdvertisedRoutes []string `json:"advertised_routes"`
IsExitNode bool `json:"is_exit_node"`
}
type goldenCapture struct {
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
Netmap *goldenNetmap `json:"netmap"`
Whois map[string]goldenWhois `json:"whois"`
}
type goldenNetmap struct {
Peers []goldenPeer `json:"Peers"`
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
}
type goldenPeer struct {
Name string `json:"Name"`
AllowedIPs []string `json:"AllowedIPs"`
PrimaryRoutes []string `json:"PrimaryRoutes"`
Tags []string `json:"Tags"`
}
type goldenWhois struct {
PeerName string `json:"peer_name"`
Response *json.RawMessage `json:"response"`
}
// viaCompatTests lists golden captures that exercise via grant steering.
var viaCompatTests = []struct {
id string
desc string
}{
{"GRANT-V29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
{"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"},
}
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
// compares headscale's MapResponse structure against the captured netmap.
//
// The comparison is IP-independent: it validates peer visibility, route
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP
// addresses which differ between Tailscale SaaS and headscale allocation.
//
// CROSS-DEPENDENCY WARNING:
// This test reads golden files from ../policy/v2/testdata/grant_results/
// (specifically GRANT-V29, V30, V31, V36). 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)
//
// Fields consumed by TestGrantsCompat (but NOT by this test):
// - captures[name].packet_filter_rules (golden filter rule comparison)
// - input.api_response_code/body (error case handling)
func TestViaGrantMapCompat(t *testing.T) {
t.Parallel()
for _, tc := range viaCompatTests {
t.Run(tc.id, func(t *testing.T) {
t.Parallel()
path := filepath.Join(
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
)
data, err := os.ReadFile(path)
require.NoError(t, err, "failed to read golden file %s", path)
ast, err := hujson.Parse(data)
require.NoError(t, err, "failed to parse HuJSON in %s", path)
ast.Standardize()
var gf goldenFile
require.NoError(t, json.Unmarshal(ast.Pack(), &gf))
if gf.Error {
t.Skipf("test %s is an error case", tc.id)
return
}
runViaMapCompat(t, gf)
})
}
}
// taggedNodes are the nodes we create in the servertest.
var taggedNodes = []string{
"exit-a", "exit-b", "exit-node",
"group-a-client", "group-b-client",
"router-a", "router-b",
"subnet-router", "tagged-client",
"tagged-server", "tagged-prod",
"multi-exit-router",
}
func runViaMapCompat(t *testing.T, gf goldenFile) {
t.Helper()
srv := servertest.NewServer(t)
tagUser := srv.CreateUser(t, "tag-user")
policyJSON := convertViaPolicy(gf.Input.FullPolicy)
changed, err := srv.State().SetPolicy(policyJSON)
require.NoError(t, err, "failed to set policy")
if changed {
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
// Create tagged clients matching the golden topology.
clients := map[string]*servertest.TestClient{}
for _, name := range taggedNodes {
topoNode, exists := gf.Topology.Nodes[name]
if !exists || len(topoNode.Tags) == 0 {
continue
}
if _, inCaptures := gf.Captures[name]; !inCaptures {
continue
}
clients[name] = servertest.NewClient(t, srv, name,
servertest.WithUser(tagUser),
servertest.WithTags(topoNode.Tags...),
)
}
require.NotEmpty(t, clients, "no relevant nodes created")
// Determine which routes each node should advertise. If the golden
// topology has explicit advertised_routes, use those. Otherwise infer
// from the policy's autoApprovers.routes: if a node's tags match an
// approver tag for a route prefix, the node should advertise it.
nodeRoutes := inferNodeRoutes(gf)
// Advertise and approve routes FIRST. Via grants depend on routes
// being advertised for compileViaGrant to produce filter rules.
for name, c := range clients {
routes := nodeRoutes[name]
if len(routes) == 0 {
continue
}
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: name,
RoutableIPs: routes,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = c.Direct().SendUpdate(ctx)
cancel()
nodeID := findNodeID(t, srv, name)
_, routeChange, err := srv.State().SetApprovedRoutes(nodeID, routes)
require.NoError(t, err)
srv.App.Change(routeChange)
}
// Wait for peers based on golden netmap expected counts.
for viewerName, c := range clients {
capture := gf.Captures[viewerName]
if capture.Netmap == nil {
continue
}
expected := 0
for _, peer := range capture.Netmap.Peers {
peerName := extractHostname(peer.Name)
if _, isOurs := clients[peerName]; isOurs {
expected++
}
}
if expected > 0 {
c.WaitForPeers(t, expected, 30*time.Second)
}
}
// Ensure all nodes have received at least one MapResponse,
// including nodes with 0 expected peers that skipped WaitForPeers.
for name, c := range clients {
c.WaitForCondition(t, name+" initial netmap", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil
})
}
// Compare each viewer's MapResponse against the golden netmap.
for viewerName, c := range clients {
capture := gf.Captures[viewerName]
if capture.Netmap == nil {
continue
}
t.Run(viewerName, func(t *testing.T) {
nm := c.Netmap()
require.NotNil(t, nm, "netmap is nil")
compareNetmap(t, nm, capture.Netmap, clients)
})
}
}
// compareNetmap compares the headscale MapResponse against the golden
// netmap data in an IP-independent way. It validates:
// - Peer visibility (which peers are present, by hostname)
// - 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)
// - PrimaryRoutes per peer
// - PacketFilter rule count
func compareNetmap(
t *testing.T,
got *netmap.NetworkMap,
want *goldenNetmap,
clients map[string]*servertest.TestClient,
) {
t.Helper()
// Build golden peer map (only peers in our client set).
wantPeers := map[string]goldenPeer{}
for _, p := range want.Peers {
name := extractHostname(p.Name)
if _, isOurs := clients[name]; isOurs {
wantPeers[name] = p
}
}
// Build headscale peer map.
gotPeers := map[string]peerSummary{}
for _, peer := range got.Peers {
name := ""
if peer.Hostinfo().Valid() {
name = peer.Hostinfo().Hostname()
}
if name == "" {
for n := range clients {
if strings.Contains(peer.Name(), n+".") {
name = n
break
}
}
}
if name == "" {
continue
}
// Separate AllowedIPs into Tailscale IPs (node addresses)
// and route prefixes (subnets, exit routes).
var tsIPs []netip.Prefix
var routePrefixes []string
for i := range peer.AllowedIPs().Len() {
prefix := peer.AllowedIPs().At(i)
if isTailscaleIP(prefix) {
tsIPs = append(tsIPs, prefix)
} else {
routePrefixes = append(routePrefixes, prefix.String())
}
}
slices.Sort(routePrefixes)
var proutes []string
for i := range peer.PrimaryRoutes().Len() {
proutes = append(proutes, peer.PrimaryRoutes().At(i).String())
}
slices.Sort(proutes)
gotPeers[name] = peerSummary{
TailscaleIPs: tsIPs,
RoutePrefixes: routePrefixes,
PrimaryRoutes: proutes,
}
}
// Compare peer visibility: golden peers must be present.
for name, wantPeer := range wantPeers {
gotPeer, visible := gotPeers[name]
if !visible {
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
name, wantRoutes)
continue
}
// Compare route prefixes in AllowedIPs (IP-independent).
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
slices.Sort(wantRoutes)
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
"peer %s: route prefixes in AllowedIPs mismatch", name)
// Tailscale IPs: count should match, and they must belong to
// this peer (not some other node's IPs).
wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs)
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
"peer %s: Tailscale IP count mismatch", name)
// Verify the Tailscale IPs are actually this peer's addresses.
if peerClient, ok := clients[name]; ok {
peerNM := peerClient.Netmap()
if peerNM != nil && peerNM.SelfNode.Valid() {
peerAddrs := map[netip.Prefix]bool{}
addrs := peerNM.SelfNode.Addresses()
for i := range addrs.Len() {
peerAddrs[addrs.At(i)] = true
}
for _, tsIP := range gotPeer.TailscaleIPs {
assert.Truef(t, peerAddrs[tsIP],
"peer %s: AllowedIPs contains Tailscale IP %s which is NOT this peer's address (peer has %v)",
name, tsIP, peerAddrs)
}
}
}
// Compare PrimaryRoutes.
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
"peer %s: PrimaryRoutes mismatch", name)
}
// Check for extra peers headscale shows that Tailscale SaaS doesn't.
for name := range gotPeers {
if _, expected := wantPeers[name]; !expected {
t.Errorf("peer %s: visible in headscale but NOT in Tailscale SaaS", name)
}
}
// Compare PacketFilter rule count.
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")
}
type peerSummary struct {
TailscaleIPs []netip.Prefix // Tailscale address entries from AllowedIPs
RoutePrefixes []string // non-Tailscale-IP AllowedIPs (sorted)
PrimaryRoutes []string // sorted
}
// 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 {
addr := prefix.Addr()
if addr.Is4() && prefix.Bits() == 32 {
// CGNAT range 100.64.0.0/10
return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64
}
if addr.Is6() && prefix.Bits() == 128 {
// Tailscale ULA fd7a:115c:a1e0::/48
b := addr.As16()
return b[0] == 0xfd && b[1] == 0x7a && b[2] == 0x11 && b[3] == 0x5c //nolint:gosec // As16 returns [16]byte, indexing [0..3] is safe
}
return false
}
// extractRoutePrefixes returns the non-Tailscale-IP entries from an
// AllowedIPs list (subnet routes, exit routes, etc.).
func extractRoutePrefixes(allowedIPs []string) []string {
var routes []string
for _, aip := range allowedIPs {
prefix, err := netip.ParsePrefix(aip)
if err != nil {
continue
}
if !isTailscaleIP(prefix) {
routes = append(routes, aip)
}
}
return routes
}
// countTailscaleIPs returns the number of Tailscale IP entries in an
// AllowedIPs list.
func countTailscaleIPs(allowedIPs []string) int {
count := 0
for _, aip := range allowedIPs {
prefix, err := netip.ParsePrefix(aip)
if err != nil {
continue
}
if isTailscaleIP(prefix) {
count++
}
}
return count
}
// inferNodeRoutes determines which routes each node should advertise.
// If the golden topology has explicit advertised_routes, those are used.
// Otherwise, routes are inferred from the golden netmap data: if a node
// appears as a peer with route prefixes in AllowedIPs, it should
// advertise those routes.
func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
result := map[string][]netip.Prefix{}
// First use explicit advertised_routes from topology.
for name, node := range gf.Topology.Nodes {
for _, r := range node.AdvertisedRoutes {
result[name] = append(result[name], netip.MustParsePrefix(r))
}
}
// If any node already has routes, the topology is populated — use as-is.
for _, routes := range result {
if len(routes) > 0 {
return result
}
}
// 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
}
for _, peer := range capture.Netmap.Peers {
peerName := extractHostname(peer.Name)
routes := extractRoutePrefixes(peer.AllowedIPs)
for _, r := range routes {
prefix, err := netip.ParsePrefix(r)
if err != nil {
continue
}
if !slices.Contains(result[peerName], prefix) {
result[peerName] = append(result[peerName], prefix)
}
}
}
}
return result
}
// extractHostname extracts the hostname from a Tailscale FQDN like
// "router-a.tail78f774.ts.net.".
func extractHostname(fqdn string) string {
if before, _, ok := strings.Cut(fqdn, "."); ok {
return before
}
return fqdn
}
// convertViaPolicy converts Tailscale SaaS policy emails to headscale format.
func convertViaPolicy(raw json.RawMessage) []byte {
s := string(raw)
s = strings.ReplaceAll(s, "kratail2tid@passkey", "tag-user@")
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "tag-user@")
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "tag-user@")
return []byte(s)
}