mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 18:54:11 +09:00
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:
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) })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+14331
File diff suppressed because it is too large.
Load diff
+14425
File diff suppressed because it is too large.
Load diff
+14628
File diff suppressed because it is too large.
Load diff
+14894
File diff suppressed because it is too large.
Load diff
+14885
File diff suppressed because it is too large.
Load diff
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user