mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-19 15:21:35 +02:00
Add golden test data for via exit route steering and fix via exit grant compilation to match Tailscale SaaS behavior. Includes MapResponse golden tests for via grant route steering verification. Updates #2180
381 lines
9.7 KiB
Go
381 lines
9.7 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"
|
|
"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 {
|
|
SelfNode json.RawMessage `json:"SelfNode"`
|
|
Peers []goldenPeer `json:"Peers"`
|
|
PacketFilter json.RawMessage `json:"PacketFilter"`
|
|
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
|
DNS json.RawMessage `json:"DNS"`
|
|
SSHPolicy json.RawMessage `json:"SSHPolicy"`
|
|
Domain string `json:"Domain"`
|
|
UserProfiles json.RawMessage `json:"UserProfiles"`
|
|
}
|
|
|
|
type goldenPeer struct {
|
|
Name string `json:"Name"`
|
|
Addresses []string `json:"Addresses"`
|
|
AllowedIPs []string `json:"AllowedIPs"`
|
|
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
|
Tags []string `json:"Tags"`
|
|
ExitNodeOption *bool `json:"ExitNodeOption"`
|
|
Online *bool `json:"Online"`
|
|
Cap int `json:"Cap"`
|
|
}
|
|
|
|
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 full MapResponse against the captured netmap.
|
|
//
|
|
// For each viewing node, it compares:
|
|
// - Peer set (which peers are visible)
|
|
// - Per-peer AllowedIPs (via steering changes which routes appear on which peer)
|
|
// - Per-peer PrimaryRoutes (which node is primary for a subnet)
|
|
// - PacketFilter rule count
|
|
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+".json",
|
|
)
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err, "failed to read golden file %s", path)
|
|
|
|
var gf goldenFile
|
|
require.NoError(t, json.Unmarshal(data, &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. User-owned nodes
|
|
// are excluded because the servertest uses a single user for all tagged
|
|
// nodes, which doesn't map to the multi-user Tailscale topology.
|
|
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")
|
|
|
|
// Compute expected peer counts from golden netmap.
|
|
expectedPeerCounts := map[string]int{}
|
|
|
|
for viewerName := range clients {
|
|
capture := gf.Captures[viewerName]
|
|
if capture.Netmap != nil {
|
|
// Count peers from golden netmap that are in our client set.
|
|
count := 0
|
|
|
|
for _, peer := range capture.Netmap.Peers {
|
|
peerName := extractHostname(peer.Name)
|
|
if _, isOurs := clients[peerName]; isOurs {
|
|
count++
|
|
}
|
|
}
|
|
|
|
expectedPeerCounts[viewerName] = count
|
|
}
|
|
}
|
|
|
|
// Wait for expected peers.
|
|
for name, c := range clients {
|
|
expected := expectedPeerCounts[name]
|
|
if expected > 0 {
|
|
c.WaitForPeers(t, expected, 30*time.Second)
|
|
}
|
|
}
|
|
|
|
// Advertise and approve routes.
|
|
for name, c := range clients {
|
|
topoNode := gf.Topology.Nodes[name]
|
|
if len(topoNode.AdvertisedRoutes) == 0 {
|
|
continue
|
|
}
|
|
|
|
var routes []netip.Prefix
|
|
for _, r := range topoNode.AdvertisedRoutes {
|
|
routes = append(routes, netip.MustParsePrefix(r))
|
|
}
|
|
|
|
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 route propagation.
|
|
for _, c := range clients {
|
|
c.WaitForCondition(t, "routes settled", 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, viewerName, nm, capture.Netmap, clients)
|
|
})
|
|
}
|
|
}
|
|
|
|
func compareNetmap(
|
|
t *testing.T,
|
|
_ string, // viewerName unused but kept for signature clarity
|
|
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
|
|
}
|
|
|
|
var aips []string
|
|
for i := range peer.AllowedIPs().Len() {
|
|
aips = append(aips, peer.AllowedIPs().At(i).String())
|
|
}
|
|
|
|
slices.Sort(aips)
|
|
|
|
var proutes []string
|
|
for i := range peer.PrimaryRoutes().Len() {
|
|
proutes = append(proutes, peer.PrimaryRoutes().At(i).String())
|
|
}
|
|
|
|
slices.Sort(proutes)
|
|
|
|
gotPeers[name] = peerSummary{
|
|
AllowedIPs: aips,
|
|
PrimaryRoutes: proutes,
|
|
}
|
|
}
|
|
|
|
// Compare peer visibility.
|
|
for name, wantPeer := range wantPeers {
|
|
gotPeer, visible := gotPeers[name]
|
|
if !visible {
|
|
t.Errorf("peer %s: visible in Tailscale SaaS (AllowedIPs=%v), missing in headscale",
|
|
name, wantPeer.AllowedIPs)
|
|
|
|
continue
|
|
}
|
|
|
|
// Compare AllowedIPs.
|
|
wantAIPs := make([]string, len(wantPeer.AllowedIPs))
|
|
copy(wantAIPs, wantPeer.AllowedIPs)
|
|
slices.Sort(wantAIPs)
|
|
|
|
assert.Equalf(t, wantAIPs, gotPeer.AllowedIPs,
|
|
"peer %s: AllowedIPs mismatch", name)
|
|
|
|
// 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 {
|
|
AllowedIPs []string
|
|
PrimaryRoutes []string
|
|
}
|
|
|
|
// 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)
|
|
}
|