policy/v2, servertest, hscontrol: add tailnet overlap route captures

Routers announcing tailnet addresses, approved by policy, admin key and
devices:routes token; replayed through policy, map stream and v1/v2
APIs.

Updates ANT-2026-MLP8ZR5T

(cherry picked from commit 286d6ea05d, adapted: no v1/v2 API tests on 0.29)
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 10:33:17 +01:00
1 parent 7a51c8f30a
commit 6e0d520cb7
9 files changed
+73715 -69

No files matched your search

@@ -21,6 +21,7 @@
package v2
import (
"path"
"path/filepath"
"strings"
"testing"
@@ -168,3 +169,25 @@ func TestPolicyTesterCompat(t *testing.T) {
})
}
}
// compatCheck runs one check of a capture as a subtest named after the last
// element of key; mismatch returns "" when headscale matches SaaS. A key in
// skips is a known difference that must still differ, so the fix that makes
// it match has to delete the entry.
func compatCheck(t *testing.T, skips map[string]string, key string, mismatch func() string) {
t.Helper()
t.Run(path.Base(key), func(t *testing.T) {
diff := mismatch()
reason, listed := skips[key]
switch {
case listed && diff == "":
t.Errorf("matches SaaS; delete the skip entry %q (%s)", key, reason)
case listed:
t.Skip(reason)
case diff != "":
t.Error(diff)
}
})
}
@@ -27,6 +27,7 @@ package v2
import (
"fmt"
"net/http"
"net/netip"
"path/filepath"
"slices"
@@ -171,6 +172,69 @@ func buildRoutesUsersAndNodes(
return users, nodes
}
// routesSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<node>/<check>. A listed check that matches fails, so a fix must
// delete its entry.
var routesSkipReasons = map[string]string{}
// routesNotCompared lists, by key prefix, checks left out: SaaS's answer
// there is in doubt, so it is no reference.
var routesNotCompared = []string{
"routes-t1-auto-v4-default/ovl-r-ctrl/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-host4/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-host6/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-mapped/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-narrow/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-via/peer-allowed-ips",
"routes-t1-auto-v4-default/ovl-r-wide-in/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-ctrl/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-host4/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-host6/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-mapped/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-narrow/peer-allowed-ips",
"routes-t2-auto-v6-default/ovl-r-via/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-host4/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-host6/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peer-allowed-ips",
"routes-t3-auto-narrow-keys/ovl-r-via/peer-allowed-ips",
"routes-t4-admin/ovl-r-ctrl/peer-allowed-ips",
"routes-t4-admin/ovl-r-host4/peer-allowed-ips",
"routes-t4-admin/ovl-r-host6/peer-allowed-ips",
"routes-t4-admin/ovl-r-mapped/peer-allowed-ips",
"routes-t4-admin/ovl-r-narrow/peer-allowed-ips",
"routes-t4-admin/ovl-r-via/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-host4/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-host6/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peer-allowed-ips",
"routes-t5-oauth-routes-scope/ovl-r-via/peer-allowed-ips",
}
// notCompared reports whether key falls under one of prefixes.
func notCompared(prefixes []string, key string) bool {
return slices.ContainsFunc(prefixes, func(p string) bool {
return strings.HasPrefix(key, p)
})
}
// routesSetByAPI reports whether the capture's routes were approved through
// the routes API rather than by the policy.
func routesSetByAPI(c *testcapture.Capture) bool {
for _, step := range c.Steps {
for _, a := range step.Actions {
if a.Kind == "api" && a.Method == http.MethodPost &&
strings.HasSuffix(a.Path, "/routes") && a.Status == http.StatusOK {
return true
}
}
}
return false
}
// subnetToSubnetFiles lists the golden files that test subnet-to-subnet
// ACL scenarios. These are the scenarios where the fix for issue #3157
// (CanAccess considering subnet routes as source identity) is critical.
@@ -717,6 +781,10 @@ func TestRoutesCompatAutoApproval(t *testing.T) {
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
if routesSetByAPI(tf) {
t.Skip("routes approved through the routes API, not by autoApprovers")
}
// Build topology from JSON.
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
@@ -1449,10 +1517,17 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
continue
}
key := tf.TestID + "/" + viewerName + "/peer-allowed-ips"
if notCompared(routesNotCompared, key) {
continue
}
t.Run(viewerName, func(t *testing.T) {
matchers, err := pm.MatchersForNode(viewer.View())
require.NoError(t, err)
var want, got []string
for _, nmPeer := range capture.Netmap.Peers {
// Extract the short name from the FQDN.
peerName := strings.Split(nmPeer.Name(), ".")[0]
@@ -1488,15 +1563,13 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
slices.SortFunc(gotAllowedIPs, netip.Prefix.Compare)
wantAllowedIPs := nmPeer.AllowedIPs().AsSlice()
slices.SortFunc(wantAllowedIPs, netip.Prefix.Compare)
assert.Equalf(t,
prefixStrings(wantAllowedIPs),
prefixStrings(gotAllowedIPs),
"%s/%s: peer %s AllowedIPs mismatch",
tf.TestID, viewerName, peerName,
)
want = append(want, fmt.Sprintf("%s: %v", peerName, prefixStrings(wantAllowedIPs)))
got = append(got, fmt.Sprintf("%s: %v", peerName, prefixStrings(gotAllowedIPs)))
}
compatCheck(t, routesSkipReasons, key,
func() string { return cmp.Diff(want, got) })
})
}
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+319
View File
@@ -2,17 +2,26 @@ package servertest_test
import (
"context"
"fmt"
"maps"
"net/http"
"net/netip"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
)
// TestRoutes verifies that route advertisements and approvals
@@ -346,3 +355,313 @@ func TestRoutes(t *testing.T) {
}
// [findNodeID] is defined in issues_test.go.
// routeOverlapCompatTests lists captures whose routers announce addresses
// and prefixes of the tailnet's own range, approved by the policy's
// autoApprovers (t1–t3), an admin key (t4) or a devices:routes token (t5).
var routeOverlapCompatTests = []string{
"routes-t1-auto-v4-default",
"routes-t2-auto-v6-default",
"routes-t3-auto-narrow-keys",
"routes-t4-admin",
"routes-t5-oauth-routes-scope",
}
// routeOverlapSkipReasons lists checks where headscale differs from SaaS,
// keyed <test_id>/<node>/<check>. A listed check that matches fails, so a fix
// must delete its entry.
var routeOverlapSkipReasons = map[string]string{
"routes-t1-auto-v4-default/ovl-db/peer-routes": tailnetSingleServed,
"routes-t1-auto-v4-default/ovl-observer/peer-routes": tailnetSingleServed,
"routes-t1-auto-v4-default/ovl-r-wide-in/peer-routes": tailnetSingleServed,
"routes-t1-auto-v4-default/ovl-r-wide-out/peer-routes": tailnetSingleServed,
"routes-t1-auto-v4-default/ovl-viewer/peer-routes": tailnetSingleServed,
"routes-t2-auto-v6-default/ovl-db/peer-routes": tailnetSingleServed,
"routes-t2-auto-v6-default/ovl-observer/peer-routes": tailnetSingleServed,
"routes-t2-auto-v6-default/ovl-r-wide-in/peer-routes": tailnetSingleServed,
"routes-t2-auto-v6-default/ovl-r-wide-out/peer-routes": tailnetSingleServed,
"routes-t2-auto-v6-default/ovl-viewer/peer-routes": tailnetSingleServed,
"routes-t3-auto-narrow-keys/ovl-db/peer-routes": tailnetSingleServed,
"routes-t3-auto-narrow-keys/ovl-observer/peer-routes": tailnetSingleServed,
"routes-t3-auto-narrow-keys/ovl-r-wide-in/peer-routes": tailnetSingleServed,
"routes-t3-auto-narrow-keys/ovl-r-wide-out/peer-routes": tailnetSingleServed,
"routes-t3-auto-narrow-keys/ovl-viewer/peer-routes": tailnetSingleServed,
"routes-t4-admin/ovl-db/peer-routes": tailnetSingleServed,
"routes-t4-admin/ovl-observer/peer-routes": tailnetSingleServed,
"routes-t4-admin/ovl-r-wide-in/peer-routes": tailnetSingleServed,
"routes-t4-admin/ovl-r-wide-out/peer-routes": tailnetSingleServed,
"routes-t4-admin/ovl-viewer/peer-routes": tailnetSingleServed,
"routes-t5-oauth-routes-scope/ovl-db/peer-routes": tailnetSingleServed,
"routes-t5-oauth-routes-scope/ovl-observer/peer-routes": tailnetSingleServed,
"routes-t5-oauth-routes-scope/ovl-r-wide-in/peer-routes": tailnetSingleServed,
"routes-t5-oauth-routes-scope/ovl-r-wide-out/peer-routes": tailnetSingleServed,
"routes-t5-oauth-routes-scope/ovl-viewer/peer-routes": tailnetSingleServed,
// Once a router is sent its own routes, its self-primary also needs the
// approved singles in the tailnet range left unserved.
"routes-t1-auto-v4-default/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t1-auto-v4-default/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t2-auto-v6-default/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t3-auto-narrow-keys/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t4-admin/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t4-admin/ovl-r-via/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-ctrl/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-ctrl/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-host4/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-host6/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-mapped/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-mapped/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-narrow/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-narrow/self-primary": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-via/self-allowed": selfRoutesReduced,
"routes-t5-oauth-routes-scope/ovl-r-via/self-primary": selfRoutesReduced,
}
// routeOverlapNotCompared lists, by key prefix, checks left out: SaaS's
// answer there is in doubt, so it is no reference.
var routeOverlapNotCompared = []string{
"routes-t1-auto-v4-default/ovl-r-ctrl/peers",
"routes-t1-auto-v4-default/ovl-r-host4/peers",
"routes-t1-auto-v4-default/ovl-r-host6/peers",
"routes-t1-auto-v4-default/ovl-r-mapped/peers",
"routes-t1-auto-v4-default/ovl-r-narrow/peers",
"routes-t1-auto-v4-default/ovl-r-via/peers",
"routes-t1-auto-v4-default/ovl-r-wide-in/peers",
"routes-t2-auto-v6-default/ovl-r-ctrl/peers",
"routes-t2-auto-v6-default/ovl-r-host4/peers",
"routes-t2-auto-v6-default/ovl-r-host6/peers",
"routes-t2-auto-v6-default/ovl-r-mapped/peers",
"routes-t2-auto-v6-default/ovl-r-narrow/peers",
"routes-t2-auto-v6-default/ovl-r-via/peers",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-ctrl/peers",
"routes-t3-auto-narrow-keys/ovl-r-host4/peers",
"routes-t3-auto-narrow-keys/ovl-r-host6/peers",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-mapped/peers",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-narrow/peers",
"routes-t3-auto-narrow-keys/ovl-r-via/peer-routes",
"routes-t3-auto-narrow-keys/ovl-r-via/peers",
"routes-t4-admin/ovl-r-ctrl/peer-routes",
"routes-t4-admin/ovl-r-ctrl/peers",
"routes-t4-admin/ovl-r-host4/peers",
"routes-t4-admin/ovl-r-host6/peers",
"routes-t4-admin/ovl-r-mapped/peer-routes",
"routes-t4-admin/ovl-r-mapped/peers",
"routes-t4-admin/ovl-r-narrow/peer-routes",
"routes-t4-admin/ovl-r-narrow/peers",
"routes-t4-admin/ovl-r-via/peer-routes",
"routes-t4-admin/ovl-r-via/peers",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-ctrl/peers",
"routes-t5-oauth-routes-scope/ovl-r-host4/peers",
"routes-t5-oauth-routes-scope/ovl-r-host6/peers",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-mapped/peers",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-narrow/peers",
"routes-t5-oauth-routes-scope/ovl-r-via/peer-routes",
"routes-t5-oauth-routes-scope/ovl-r-via/peers",
}
const tailnetSingleServed = "TAILNET_SINGLE_SERVED: headscale serves an approved single address inside the tailnet range, which SaaS approves but never serves"
// TestRoutesTailnetOverlapCompat replays routers whose routes overlap the
// tailnet's addresses and compares approvals, served routes, peers and
// filters with SaaS. Headscale must hand out SaaS's addresses, so a route
// naming a node's address names the same node here and AllowedIPs compare
// literally.
//
// Test data source: ../policy/v2/testdata/routes_results/, shared with
// TestRoutesCompat* in policy/v2.
func TestRoutesTailnetOverlapCompat(t *testing.T) {
t.Parallel()
for _, id := range routeOverlapCompatTests {
t.Run(id, func(t *testing.T) {
t.Parallel()
c, err := testcapture.Read(filepath.Join(
"..", "policy", "v2", "testdata", "routes_results", id+".hujson",
))
require.NoError(t, err)
srv, clients := startCaptureClients(t, c, routesSetByAPI(c))
names := slices.Sorted(maps.Keys(clients))
for _, name := range names {
want, got := c.Captures[name].Netmap.SelfNode, clients[name].Netmap().SelfNode
require.Equalf(t, prefixList(want.Addresses()), prefixList(got.Addresses()),
"%s: addresses differ from SaaS, so routes would name other nodes", name)
}
saasAddrs := saasAddrsByNode(c, clients)
// Each node's checks read only its own netmap and node, so the
// holds run at once.
var wg sync.WaitGroup
for _, name := range names {
wg.Go(func() {
t.Run(name, func(t *testing.T) {
key := id + "/" + name + "/"
want := c.Captures[name].Netmap
topo := c.Topology.Nodes[name]
nodeID := findNodeID(t, srv, name)
// Approvals and routes reach clients in batches; retry
// the comparisons until they hold.
requireNetmapHolds(t, func(t require.TestingT) {
nm := clients[name].Netmap()
if len(topo.RoutableIPs) > 0 {
node, ok := srv.State().GetNodeByID(nodeID)
require.True(t, ok)
overlapCheck(t, key+"approved",
sortedStrings(topo.ApprovedRoutes),
sortedStrings(util.PrefixesToString(node.ApprovedRoutes().AsSlice())))
}
wantPeers, gotPeers := peerRoutes(want, clients), peerRoutes(nm, clients)
overlapCheck(t, key+"peers",
strings.Join(slices.Sorted(maps.Keys(wantPeers)), "\n"),
strings.Join(slices.Sorted(maps.Keys(gotPeers)), "\n"))
// Peers on one side only are the peers check's; here
// only what both sides show is compared.
var wantRoutes, gotRoutes []string
for peer, routes := range wantPeers {
if got, ok := gotPeers[peer]; ok {
wantRoutes = append(wantRoutes, peer+": "+routes)
gotRoutes = append(gotRoutes, peer+": "+got)
}
}
overlapCheck(t, key+"peer-routes",
strings.Join(slices.Sorted(slices.Values(wantRoutes)), "\n"),
strings.Join(slices.Sorted(slices.Values(gotRoutes)), "\n"))
comparePacketFilter(t, nm, c.Captures[name], clients, saasAddrs,
key+"packet-filter", routeOverlapSkipReasons)
overlapCheck(t, key+"self-primary",
prefixList(want.SelfNode.PrimaryRoutes()), prefixList(nm.SelfNode.PrimaryRoutes()))
overlapCheck(t, key+"self-allowed",
selfAllowed(want.SelfNode), selfAllowed(nm.SelfNode))
})
})
})
}
wg.Wait()
})
}
}
// routesSetByAPI reports whether the capture's routes were approved through
// the routes API rather than by the policy.
func routesSetByAPI(c *testcapture.Capture) bool {
for _, step := range c.Steps {
for _, a := range step.Actions {
if a.Kind == "api" && a.Method == http.MethodPost &&
strings.HasSuffix(a.Path, "/routes") && a.Status == http.StatusOK {
return true
}
}
}
return false
}
// overlapCheck compares one rendering of a check, honouring
// routeOverlapSkipReasons: a listed check must keep differing.
func overlapCheck(t require.TestingT, key, want, got string) {
if notCompared(routeOverlapNotCompared, key) {
return
}
reason, listed := routeOverlapSkipReasons[key]
switch {
case listed && want == got:
t.Errorf("matches SaaS; delete routeOverlapSkipReasons[%q] (%s)", key, reason)
case !listed:
assert.Equalf(t, want, got, "%s", key)
}
}
// notCompared reports whether key falls under one of prefixes.
func notCompared(prefixes []string, key string) bool {
return slices.ContainsFunc(prefixes, func(p string) bool {
return strings.HasPrefix(key, p)
})
}
// peerRoutes maps each peer in clients to its AllowedIPs and PrimaryRoutes.
func peerRoutes(
nm *netmap.NetworkMap,
clients map[string]*servertest.TestClient,
) map[string]string {
out := map[string]string{}
for _, p := range nm.Peers {
name := extractHostname(p.Name())
if hi := p.Hostinfo(); hi.Valid() && hi.Hostname() != "" {
name = hi.Hostname()
}
if _, ok := clients[name]; ok {
out[name] = "allowed " + prefixList(p.AllowedIPs()) + " primary " + prefixList(p.PrimaryRoutes())
}
}
return out
}
// selfAllowed is the node's AllowedIPs without its own addresses.
func selfAllowed(self tailcfg.NodeView) string {
var out []netip.Prefix
for _, pfx := range self.AllowedIPs().All() {
if !views.SliceContains(self.Addresses(), pfx) {
out = append(out, pfx)
}
}
return prefixList(views.SliceOf(out))
}
func prefixList(pfxs views.Slice[netip.Prefix]) string {
return sortedStrings(util.PrefixesToString(pfxs.AsSlice()))
}
func sortedStrings(s []string) string {
return fmt.Sprint(slices.Sorted(slices.Values(s)))
}
+130 -62
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -286,6 +287,56 @@ func TestViaGrantMapCompat(t *testing.T) {
func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
t.Helper()
_, clients := startViaMapCompat(t, c)
compareViaMapCompat(t, c, clients)
}
// startViaMapCompat starts a server holding the capture's policy and a
// client per captured node, with the capture's routes approved, and waits
// for every client's first netmap and the peers SaaS gave it.
func startViaMapCompat(
t *testing.T,
c *testcapture.Capture,
) (*servertest.TestServer, map[string]*servertest.TestClient) {
t.Helper()
srv, clients := startCaptureClients(t, c, true)
// Wait for peers based on golden [netmap.NetworkMap] expected counts.
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)
}
}
return srv, clients
}
// startCaptureClients starts a server holding the capture's policy and a
// client per captured node, advertises the capture's routes and waits for
// every client's first netmap. With approve set, each node's approved_routes
// are approved as an admin would; otherwise only the policy approves them.
func startCaptureClients(
t *testing.T,
c *testcapture.Capture,
approve bool,
) (*servertest.TestServer, map[string]*servertest.TestClient) {
t.Helper()
srv := servertest.NewServer(t)
tagUser := srv.CreateUser(t, "tag-user")
users := createCaptureUsers(t, srv)
@@ -384,6 +435,10 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
cancel()
if !approve {
continue
}
nodeID := findNodeID(t, srv, name)
_, routeChange, err := srv.State().SetApprovedRoutes(
nodeID, nodeApproved[name],
@@ -392,29 +447,6 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
srv.App.Change(routeChange)
}
// Wait for peers based on golden [netmap.NetworkMap] expected counts.
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 received at least one [tailcfg.MapResponse],
// including nodes with 0 expected peers that skipped [TestClient.WaitForPeers].
for name, cl := range clients {
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
@@ -422,52 +454,73 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
})
}
return srv, clients
}
// compareViaMapCompat compares every viewer's netmap and own routes with
// the capture.
func compareViaMapCompat(
t *testing.T,
c *testcapture.Capture,
clients map[string]*servertest.TestClient,
) {
t.Helper()
saasAddrs := saasAddrsByNode(c, clients)
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
// Route approvals reach clients asynchronously, so the peer-count wait
// above says nothing about routes, primaries or filters: retry the
// whole comparison until the viewer's netmap has caught up.
// whole comparison until the viewer's netmap has caught up. Each viewer
// reads only its own netmap, so the holds run at once.
var wg sync.WaitGroup
for viewerName, cl := range clients {
capture := c.Captures[viewerName]
if capture.Netmap == nil {
continue
}
t.Run(viewerName, func(t *testing.T) {
requireNetmapHolds(t, func(tt require.TestingT) {
nm := cl.Netmap()
require.NotNil(tt, nm, "netmap is nil")
require.Equal(tt, selfOffersExit(capture.Netmap), selfOffersExit(nm),
"SelfNode exit routes should match SaaS")
compareNetmap(tt, nm, capture, clients, saasAddrs, c.TestID+"/"+viewerName)
})
t.Run("self-routes", func(t *testing.T) {
key := c.TestID + "/" + viewerName + "/self-routes"
reason, listed := viaCompatSkipReasons[key]
wg.Go(func() {
t.Run(viewerName, func(t *testing.T) {
requireNetmapHolds(t, func(tt require.TestingT) {
want, got := selfRoutes(capture.Netmap), selfRoutes(cl.Netmap())
if !listed {
assert.Equal(tt, want, got, "own routes in AllowedIPs and PrimaryRoutes")
nm := cl.Netmap()
require.NotNil(tt, nm, "netmap is nil")
return
}
// Routes were approved on live sessions: verify that approval
// reached SelfNode throughout the stable comparison window.
require.Equal(tt, selfOffersExit(capture.Netmap), selfOffersExit(nm),
"SelfNode exit routes should match SaaS")
if got == want {
tt.Errorf("own routes match SaaS; delete viaCompatSkipReasons[%q]", key)
}
compareNetmap(tt, nm, capture, clients, saasAddrs, c.TestID+"/"+viewerName)
})
if listed {
t.Skip(reason)
}
t.Run("self-routes", func(t *testing.T) {
key := c.TestID + "/" + viewerName + "/self-routes"
reason, listed := viaCompatSkipReasons[key]
requireNetmapHolds(t, func(tt require.TestingT) {
want, got := selfRoutes(capture.Netmap), selfRoutes(cl.Netmap())
if !listed {
assert.Equal(tt, want, got, "own routes in AllowedIPs and PrimaryRoutes")
return
}
if got == want {
tt.Errorf("own routes match SaaS; delete viaCompatSkipReasons[%q]", key)
}
})
if listed {
t.Skip(reason)
}
})
})
})
}
wg.Wait()
}
// selfRoutes is the node's AllowedIPs minus its own addresses, and its
@@ -566,12 +619,9 @@ func (r *failRecorder) FailNow() {
}
// compareNetmap compares the headscale [tailcfg.MapResponse] against the
// captured [netmap.NetworkMap] 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 (source, destination, ports) triples
// captured [netmap.NetworkMap] data in an IP-independent way: the peers (see
// [comparePeers]), the PacketFilter (source, destination, ports) triples and
// the user profiles.
//
// key is <test_id>/<node>, the prefix of the viewer's viaCompatSkipReasons
// entries. It takes [require.TestingT] so it can run inside
@@ -588,6 +638,26 @@ func compareNetmap(
h.Helper()
}
comparePeers(t, got, want, clients)
comparePacketFilter(t, got, want, clients, saasAddrs, key+"/packet-filter", viaCompatSkipReasons)
compareUserProfiles(t, got, want.Netmap, clients)
}
// comparePeers validates, for the peers in clients:
// - 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
func comparePeers(
t require.TestingT,
got *netmap.NetworkMap,
want testcapture.Node,
clients map[string]*servertest.TestClient,
) {
if h, ok := t.(interface{ Helper() }); ok {
h.Helper()
}
require.NotNil(t, want.Netmap, "golden Netmap is nil")
// Build golden peer map (only peers in our client set).
@@ -715,9 +785,6 @@ func compareNetmap(
t.Errorf("peer %s: visible in headscale but NOT in Tailscale SaaS", name)
}
}
comparePacketFilter(t, got, want, clients, saasAddrs, key+"/packet-filter")
compareUserProfiles(t, got, want.Netmap, clients)
}
// compareUserProfiles compares the profile the viewer holds for itself and
@@ -793,7 +860,7 @@ func nodeProfiles(
// that share sources while SaaS keeps one rule per policy entry, so rule
// count and order differ without changing what the filter allows.
// Tailscale IPs differ between SaaS and headscale allocation, so both
// sides are re-keyed by peer identity.
// sides are re-keyed by peer identity. A key listed in skips must differ.
func comparePacketFilter(
t require.TestingT,
got *netmap.NetworkMap,
@@ -801,6 +868,7 @@ func comparePacketFilter(
clients map[string]*servertest.TestClient,
saasAddrs map[netip.Addr]string,
key string,
skips map[string]string,
) {
if h, ok := t.(interface{ Helper() }); ok {
h.Helper()
@@ -859,9 +927,9 @@ func comparePacketFilter(
wantList, gotList := sortedKeys(wantTriples), sortedKeys(gotTriples)
if reason, listed := viaCompatSkipReasons[key]; listed {
if reason, listed := skips[key]; listed {
if slices.Equal(wantList, gotList) {
t.Errorf("PacketFilter matches SaaS; delete viaCompatSkipReasons[%q] (%s)", key, reason)
t.Errorf("PacketFilter matches SaaS; delete the skip entry %q (%s)", key, reason)
}
return