mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-07 21:47:46 +09:00
policy/v2: add via exit steering golden captures and tests
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
This commit is contained in:
@@ -363,9 +363,11 @@ func (pol *Policy) compileViaGrant(
|
||||
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
|
||||
}
|
||||
case *AutoGroup:
|
||||
if d.Is(AutoGroupInternet) && len(nodeExitRoutes) > 0 {
|
||||
viaDstPrefixes = append(viaDstPrefixes, nodeExitRoutes...)
|
||||
}
|
||||
// autogroup:internet via grants do NOT produce PacketFilter rules
|
||||
// on the exit node. Tailscale SaaS handles exit traffic forwarding
|
||||
// through the client's exit node selection mechanism (AllowedIPs +
|
||||
// ExitNodeOption), not through PacketFilter rules. Verified by
|
||||
// golden captures GRANT-V14 through GRANT-V36.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3710,7 +3710,11 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autogroup:internet with exit routes produces rules",
|
||||
// autogroup:internet via grants do NOT produce PacketFilter rules
|
||||
// on exit nodes. Tailscale SaaS handles exit traffic forwarding
|
||||
// through the client's exit node mechanism, not PacketFilter.
|
||||
// Verified by golden captures GRANT-V14 through GRANT-V36.
|
||||
name: "autogroup:internet with exit routes produces no rules",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("testuser@")},
|
||||
Destinations: Aliases{agp(string(AutoGroupInternet))},
|
||||
@@ -3720,15 +3724,7 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
node: exitNode,
|
||||
nodes: types.Nodes{exitNode, srcNode},
|
||||
pol: &Policy{},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.10"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny},
|
||||
{IP: "::/0", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "autogroup:internet without exit routes returns nil",
|
||||
|
||||
@@ -149,9 +149,118 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
IPv4: ptrAddr("100.85.66.106"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
|
||||
Tags: []string{"tag:exit"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}
|
||||
|
||||
// --- New nodes for expanded via grant topology ---
|
||||
|
||||
nodeExitA := &types.Node{
|
||||
ID: 9,
|
||||
GivenName: "exit-a",
|
||||
IPv4: ptrAddr("100.124.195.93"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::7837:c35d"),
|
||||
Tags: []string{"tag:exit-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}
|
||||
|
||||
nodeExitB := &types.Node{
|
||||
ID: 10,
|
||||
GivenName: "exit-b",
|
||||
IPv4: ptrAddr("100.116.18.24"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::ff37:1218"),
|
||||
Tags: []string{"tag:exit-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}
|
||||
|
||||
nodeGroupA := &types.Node{
|
||||
ID: 11,
|
||||
GivenName: "group-a-client",
|
||||
IPv4: ptrAddr("100.107.162.14"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::a237:a20e"),
|
||||
Tags: []string{"tag:group-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeGroupB := &types.Node{
|
||||
ID: 12,
|
||||
GivenName: "group-b-client",
|
||||
IPv4: ptrAddr("100.77.135.18"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::4b37:8712"),
|
||||
Tags: []string{"tag:group-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodeRouterA := &types.Node{
|
||||
ID: 13,
|
||||
GivenName: "router-a",
|
||||
IPv4: ptrAddr("100.109.43.124"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::a537:2b7c"),
|
||||
Tags: []string{"tag:router-a"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
|
||||
}
|
||||
|
||||
nodeRouterB := &types.Node{
|
||||
ID: 14,
|
||||
GivenName: "router-b",
|
||||
IPv4: ptrAddr("100.65.172.123"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5a37:ac7c"),
|
||||
Tags: []string{"tag:router-b"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
|
||||
}
|
||||
|
||||
nodeMultiExitRouter := &types.Node{
|
||||
ID: 15,
|
||||
GivenName: "multi-exit-router",
|
||||
IPv4: ptrAddr("100.105.127.107"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::9537:7f6b"),
|
||||
Tags: []string{"tag:exit", "tag:router"},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}
|
||||
|
||||
return types.Nodes{
|
||||
nodeUser1,
|
||||
nodeUserKris,
|
||||
@@ -161,6 +270,13 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
||||
nodeTaggedClient,
|
||||
nodeSubnetRouter,
|
||||
nodeExitNode,
|
||||
nodeExitA,
|
||||
nodeExitB,
|
||||
nodeGroupA,
|
||||
nodeGroupB,
|
||||
nodeRouterA,
|
||||
nodeRouterB,
|
||||
nodeMultiExitRouter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +380,7 @@ func TestGrantsCompat(t *testing.T) {
|
||||
t.Logf("Loaded %d grant test files", len(files))
|
||||
|
||||
users := setupGrantsCompatUsers()
|
||||
nodes := setupGrantsCompatNodes(users)
|
||||
allNodes := setupGrantsCompatNodes(users)
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadGrantTestFile(t, file)
|
||||
@@ -278,6 +394,16 @@ func TestGrantsCompat(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which node set to use based on the test's topology.
|
||||
// Tests captured with the expanded 15-node topology (V26+) have
|
||||
// nodes like exit-a, group-a-client, etc. Tests from the original
|
||||
// 8-node topology should only use the first 8 nodes to avoid
|
||||
// resolving extra IPs from nodes that weren't present during capture.
|
||||
nodes := allNodes
|
||||
if _, hasNewNodes := tf.Captures["exit-a"]; !hasNewNodes {
|
||||
nodes = allNodes[:8]
|
||||
}
|
||||
|
||||
// Convert Tailscale user emails to headscale @example.com format
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
|
||||
16828
hscontrol/policy/v2/testdata/grant_results/GRANT-V14.json
vendored
16828
hscontrol/policy/v2/testdata/grant_results/GRANT-V14.json
vendored
File diff suppressed because it is too large
Load Diff
16826
hscontrol/policy/v2/testdata/grant_results/GRANT-V15.json
vendored
16826
hscontrol/policy/v2/testdata/grant_results/GRANT-V15.json
vendored
File diff suppressed because it is too large
Load Diff
16830
hscontrol/policy/v2/testdata/grant_results/GRANT-V16.json
vendored
16830
hscontrol/policy/v2/testdata/grant_results/GRANT-V16.json
vendored
File diff suppressed because it is too large
Load Diff
16853
hscontrol/policy/v2/testdata/grant_results/GRANT-V26.json
vendored
Normal file
16853
hscontrol/policy/v2/testdata/grant_results/GRANT-V26.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16855
hscontrol/policy/v2/testdata/grant_results/GRANT-V27.json
vendored
Normal file
16855
hscontrol/policy/v2/testdata/grant_results/GRANT-V27.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16862
hscontrol/policy/v2/testdata/grant_results/GRANT-V28.json
vendored
Normal file
16862
hscontrol/policy/v2/testdata/grant_results/GRANT-V28.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17158
hscontrol/policy/v2/testdata/grant_results/GRANT-V29.json
vendored
Normal file
17158
hscontrol/policy/v2/testdata/grant_results/GRANT-V29.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17172
hscontrol/policy/v2/testdata/grant_results/GRANT-V30.json
vendored
Normal file
17172
hscontrol/policy/v2/testdata/grant_results/GRANT-V30.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17848
hscontrol/policy/v2/testdata/grant_results/GRANT-V31.json
vendored
Normal file
17848
hscontrol/policy/v2/testdata/grant_results/GRANT-V31.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17295
hscontrol/policy/v2/testdata/grant_results/GRANT-V32.json
vendored
Normal file
17295
hscontrol/policy/v2/testdata/grant_results/GRANT-V32.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17188
hscontrol/policy/v2/testdata/grant_results/GRANT-V33.json
vendored
Normal file
17188
hscontrol/policy/v2/testdata/grant_results/GRANT-V33.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16855
hscontrol/policy/v2/testdata/grant_results/GRANT-V34.json
vendored
Normal file
16855
hscontrol/policy/v2/testdata/grant_results/GRANT-V34.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16893
hscontrol/policy/v2/testdata/grant_results/GRANT-V35.json
vendored
Normal file
16893
hscontrol/policy/v2/testdata/grant_results/GRANT-V35.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
19156
hscontrol/policy/v2/testdata/grant_results/GRANT-V36.json
vendored
Normal file
19156
hscontrol/policy/v2/testdata/grant_results/GRANT-V36.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -772,9 +772,12 @@ func TestGrantViaSubnetFilterRules(t *testing.T) {
|
||||
"without per-node filter compilation for via grants, these rules are missing")
|
||||
}
|
||||
|
||||
// TestGrantViaExitNodeFilterRules verifies that exit nodes with via grants
|
||||
// receive PacketFilter rules for exit traffic (0.0.0.0/0, ::/0).
|
||||
func TestGrantViaExitNodeFilterRules(t *testing.T) {
|
||||
// TestGrantViaExitNodeNoFilterRules verifies that exit nodes with via grants
|
||||
// for autogroup:internet do NOT receive PacketFilter rules for exit traffic.
|
||||
// Tailscale SaaS handles exit traffic forwarding through the client's exit
|
||||
// node selection mechanism, not through PacketFilter rules. Verified by
|
||||
// golden captures GRANT-V14 through GRANT-V36.
|
||||
func TestGrantViaExitNodeNoFilterRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
@@ -842,51 +845,25 @@ func TestGrantViaExitNodeFilterRules(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(routeChange)
|
||||
|
||||
// Wait for clientA to see the exit routes in AllowedIPs.
|
||||
clientA.WaitForCondition(t, "clientA sees exit routes via exit-a",
|
||||
// Wait for routes to propagate.
|
||||
exitA.WaitForCondition(t, "exit-a routes approved",
|
||||
15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
for _, p := range nm.Peers {
|
||||
hi := p.Hostinfo()
|
||||
if hi.Valid() && hi.Hostname() == "exit-a" {
|
||||
for i := range p.AllowedIPs().Len() {
|
||||
if p.AllowedIPs().At(i) == exitRouteV4 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return nm != nil
|
||||
})
|
||||
|
||||
// Critical: exit node's PacketFilter must contain rules for
|
||||
// exit traffic (0.0.0.0/0 or ::/0) from the via grant.
|
||||
// The exit node's PacketFilter must NOT contain rules for exit traffic.
|
||||
// The only rules should be from the peer connectivity grant (tag:exit-a
|
||||
// and tag:group-a can talk to each other at their Tailscale IPs).
|
||||
exitNM := exitA.Netmap()
|
||||
require.NotNil(t, exitNM)
|
||||
require.NotNil(t, exitNM.PacketFilter,
|
||||
"exit node PacketFilter should not be nil")
|
||||
|
||||
var foundExitDst bool
|
||||
|
||||
for _, m := range exitNM.PacketFilter {
|
||||
for _, dst := range m.Dsts {
|
||||
dstPrefix := netip.PrefixFrom(dst.Net.Addr(), dst.Net.Bits())
|
||||
if dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6 {
|
||||
foundExitDst = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundExitDst,
|
||||
"exit node PacketFilter should contain destination rules for exit routes (0.0.0.0/0 or ::/0); "+
|
||||
"via grant filter rules for exit traffic are missing")
|
||||
|
||||
// Log the actual PacketFilter for debugging.
|
||||
if !foundExitDst {
|
||||
for i, m := range exitNM.PacketFilter {
|
||||
t.Logf("PacketFilter[%d]: Srcs=%v, Dsts=%v, Caps=%d",
|
||||
i, m.Srcs, m.Dsts, len(m.Caps))
|
||||
assert.Falsef(t, dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6,
|
||||
"exit node PacketFilter should NOT contain exit route destinations (0.0.0.0/0 or ::/0); "+
|
||||
"autogroup:internet via grants do not produce filter rules on exit nodes (verified against Tailscale SaaS)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
380
hscontrol/servertest/via_compat_test.go
Normal file
380
hscontrol/servertest/via_compat_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user