mirror of
https://github.com/juanfont/headscale
synced 2026-04-25 17:15:33 +02:00
servertest: add TestViaGrantHACompat for via+HA compat tests
Data-driven tests for via grants combined with HA primary routes: crossed via tags on same prefix, mixed via+regular across HA pairs, four-way HA, and the kitchen-sink scenario. Each case uses an inline topology captured from SaaS. Updates #3157
This commit is contained in:
411
hscontrol/servertest/via_ha_compat_test.go
Normal file
411
hscontrol/servertest/via_ha_compat_test.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// This file implements data-driven via+HA compatibility tests using
|
||||
// golden data captured from Tailscale SaaS (v37-v46). These scenarios
|
||||
// exercise the interaction between via grant steering and HA primary
|
||||
// route election with varying combinations of shared/unique via tags,
|
||||
// regular grants, and multiple HA pairs.
|
||||
//
|
||||
// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{37..46}.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// viaHACompatTests lists golden captures that exercise via+HA interactions.
|
||||
var viaHACompatTests = []struct {
|
||||
id string
|
||||
desc string
|
||||
}{
|
||||
{"via-grant-v37", "crossed same prefix, different via tags"},
|
||||
{"via-grant-v38", "HA baseline, no via grants"},
|
||||
{"via-grant-v39", "crossed via same prefix, different HA members"},
|
||||
{"via-grant-v40", "one client via, one client regular"},
|
||||
{"via-grant-v41", "via HA pair + non-via router same prefix"},
|
||||
{"via-grant-v42", "crossed via+regular across two HA pairs"},
|
||||
{"via-grant-v43", "partial via, partial HA, cross pairs"},
|
||||
{"via-grant-v44", "four-way HA, mixed via steering"},
|
||||
{"via-grant-v45", "via+regular overlap, 4-way HA"},
|
||||
{"via-grant-v46", "kitchen sink: mixed via+regular+HA"},
|
||||
}
|
||||
|
||||
// TestViaGrantHACompat loads golden captures from Tailscale SaaS that
|
||||
// test via grant steering combined with HA primary route election.
|
||||
// Each capture uses an inline topology with 4-6 nodes (instead of the
|
||||
// shared 15-node grant topology used by TestViaGrantMapCompat).
|
||||
func TestViaGrantHACompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range viaHACompatTests {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(
|
||||
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
||||
)
|
||||
|
||||
c, err := testcapture.Read(path)
|
||||
require.NoError(t, err, "failed to read %s", path)
|
||||
|
||||
if c.Error {
|
||||
t.Skipf("test %s is an error case", tc.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
runViaHACompat(t, c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runViaHACompat(t *testing.T, c *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
tagUser := srv.CreateUser(t, "tag-user")
|
||||
|
||||
policyJSON := convertCapturePolicy(t, c)
|
||||
|
||||
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 nodes in SaaS node ID order so headscale assigns
|
||||
// sequential DB IDs in the same relative order.
|
||||
clients := map[string]*servertest.TestClient{}
|
||||
order := captureNodeOrder(t, c)
|
||||
|
||||
for _, name := range order {
|
||||
topoNode, exists := c.Topology.Nodes[name]
|
||||
if !exists || len(topoNode.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, inCaptures := c.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")
|
||||
|
||||
// Advertise and approve routes in SaaS node ID order.
|
||||
for _, name := range order {
|
||||
cl, ok := clients[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
topoNode := c.Topology.Nodes[name]
|
||||
|
||||
var routes []netip.Prefix
|
||||
for _, r := range topoNode.RoutableIPs {
|
||||
routes = append(routes, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
if len(routes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cl.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-" + name,
|
||||
Hostname: name,
|
||||
RoutableIPs: routes,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
require.NoError(t, cl.Direct().SendUpdate(ctx),
|
||||
"route advertisement for %s should succeed", name)
|
||||
|
||||
cancel()
|
||||
|
||||
var approved []netip.Prefix
|
||||
for _, r := range topoNode.ApprovedRoutes {
|
||||
approved = append(approved, netip.MustParsePrefix(r))
|
||||
}
|
||||
|
||||
nodeID := findNodeID(t, srv, name)
|
||||
|
||||
_, routeChange, err := srv.State().SetApprovedRoutes(nodeID, approved)
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(routeChange)
|
||||
}
|
||||
|
||||
// Wait for peers.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.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 {
|
||||
cl.WaitForPeers(t, expected, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have an initial netmap.
|
||||
for name, cl := range clients {
|
||||
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
return nm != nil
|
||||
})
|
||||
}
|
||||
|
||||
// Compare each viewer's MapResponse against golden netmap.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(viewerName, func(t *testing.T) {
|
||||
compareCaptureNetmap(t, cl, capture, clients)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// compareCaptureNetmap compares headscale's MapResponse against a
|
||||
// testcapture.Node's netmap data. Same logic as compareNetmap but
|
||||
// reads from typed testcapture fields instead of goldenFile strings.
|
||||
func compareCaptureNetmap(
|
||||
t *testing.T,
|
||||
viewer *servertest.TestClient,
|
||||
want testcapture.Node,
|
||||
clients map[string]*servertest.TestClient,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
nm := viewer.Netmap()
|
||||
require.NotNil(t, nm, "viewer has no netmap")
|
||||
|
||||
// Build peer summaries from golden data.
|
||||
wantPeers := map[string]capturePeerSummary{}
|
||||
|
||||
for _, peer := range want.Netmap.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[peerName]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
tsIPs []netip.Prefix
|
||||
routePrefixes []string
|
||||
)
|
||||
|
||||
for i := range peer.AllowedIPs().Len() {
|
||||
pfx := peer.AllowedIPs().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
tsIPs = append(tsIPs, pfx)
|
||||
} else {
|
||||
routePrefixes = append(routePrefixes, pfx.String())
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(routePrefixes)
|
||||
|
||||
var primaryRoutes []string
|
||||
|
||||
for i := range peer.PrimaryRoutes().Len() {
|
||||
primaryRoutes = append(primaryRoutes, peer.PrimaryRoutes().At(i).String())
|
||||
}
|
||||
|
||||
slices.Sort(primaryRoutes)
|
||||
|
||||
wantPeers[peerName] = capturePeerSummary{
|
||||
TailscaleIPs: tsIPs,
|
||||
RoutePrefixes: routePrefixes,
|
||||
PrimaryRoutes: primaryRoutes,
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer summaries from headscale MapResponse.
|
||||
gotPeers := map[string]capturePeerSummary{}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
peerName := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[peerName]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
tsIPs []netip.Prefix
|
||||
routePrefixes []string
|
||||
)
|
||||
|
||||
for i := range peer.AllowedIPs().Len() {
|
||||
pfx := peer.AllowedIPs().At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
tsIPs = append(tsIPs, pfx)
|
||||
} else {
|
||||
routePrefixes = append(routePrefixes, pfx.String())
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(routePrefixes)
|
||||
|
||||
var primaryRoutes []string
|
||||
|
||||
for i := range peer.PrimaryRoutes().Len() {
|
||||
primaryRoutes = append(primaryRoutes, peer.PrimaryRoutes().At(i).String())
|
||||
}
|
||||
|
||||
slices.Sort(primaryRoutes)
|
||||
|
||||
gotPeers[peerName] = capturePeerSummary{
|
||||
TailscaleIPs: tsIPs,
|
||||
RoutePrefixes: routePrefixes,
|
||||
PrimaryRoutes: primaryRoutes,
|
||||
}
|
||||
}
|
||||
|
||||
// Compare peer visibility.
|
||||
for name, wantPeer := range wantPeers {
|
||||
gotPeer, visible := gotPeers[name]
|
||||
if !visible {
|
||||
t.Errorf("peer %s: visible in SaaS, missing in headscale (routes=%v)",
|
||||
name, wantPeer.RoutePrefixes)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equalf(t, wantPeer.RoutePrefixes, gotPeer.RoutePrefixes,
|
||||
"peer %s: route prefixes in AllowedIPs mismatch", name)
|
||||
|
||||
assert.Lenf(t, gotPeer.TailscaleIPs, len(wantPeer.TailscaleIPs),
|
||||
"peer %s: Tailscale IP count mismatch", name)
|
||||
|
||||
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
||||
"peer %s: PrimaryRoutes mismatch", name)
|
||||
}
|
||||
|
||||
// Check for extra peers.
|
||||
for name := range gotPeers {
|
||||
if _, expected := wantPeers[name]; !expected {
|
||||
t.Errorf("peer %s: visible in headscale but NOT in SaaS", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Baseline PacketFilter sanity: count rules. Full per-rule dst-prefix
|
||||
// comparison is done by the tailscale_routes_data compat test; here
|
||||
// we only catch gross drift.
|
||||
if len(want.PacketFilterRules) > 0 {
|
||||
gotLen := nm.PacketFilterRules.Len()
|
||||
assert.Equalf(t, len(want.PacketFilterRules), gotLen,
|
||||
"PacketFilter rule count mismatch (SaaS=%d, headscale=%d)",
|
||||
len(want.PacketFilterRules), gotLen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type capturePeerSummary struct {
|
||||
TailscaleIPs []netip.Prefix
|
||||
RoutePrefixes []string
|
||||
PrimaryRoutes []string
|
||||
}
|
||||
|
||||
// captureNodeOrder returns node names from a testcapture.Capture
|
||||
// sorted by SaaS node creation time, for deterministic DB ID assignment.
|
||||
// SaaS elects HA primaries by registration order (first registered wins),
|
||||
// which correlates with Created timestamp, not with the random snowflake
|
||||
// node ID.
|
||||
func captureNodeOrder(t *testing.T, c *testcapture.Capture) []string {
|
||||
t.Helper()
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
created time.Time
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
|
||||
for name, node := range c.Captures {
|
||||
if node.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
created := node.Netmap.SelfNode.Created()
|
||||
if created.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, entry{name: name, created: created})
|
||||
}
|
||||
|
||||
require.NotEmpty(t, entries, "no captures with SelfNode.Created found")
|
||||
|
||||
slices.SortFunc(entries, func(a, b entry) int {
|
||||
return a.created.Compare(b.created)
|
||||
})
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// convertCapturePolicy converts a testcapture'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.
|
||||
func convertCapturePolicy(t *testing.T, c *testcapture.Capture) []byte {
|
||||
t.Helper()
|
||||
|
||||
s := c.Input.FullPolicy
|
||||
|
||||
substituted := false
|
||||
|
||||
for _, email := range []string{
|
||||
"odin@example.com",
|
||||
"thor@example.org",
|
||||
"freya@example.com",
|
||||
} {
|
||||
if strings.Contains(s, email) {
|
||||
substituted = true
|
||||
s = strings.ReplaceAll(s, email, "tag-user@")
|
||||
}
|
||||
}
|
||||
|
||||
require.True(
|
||||
t,
|
||||
substituted,
|
||||
"%s: no known SaaS tag-owner email found in policy; update convertCapturePolicy",
|
||||
c.TestID,
|
||||
)
|
||||
|
||||
return []byte(s)
|
||||
}
|
||||
Reference in New Issue
Block a user