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:
parent
bca6e6334d
commit
6a55f7d731
19 changed files with 241115 additions and 89 deletions
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue