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
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent 59f7c558a6
commit 286d6ea05d
12 files changed
+74040 -72

No files matched your search

+30
View File
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
@@ -307,6 +308,35 @@ func TestAPIV1NodeSetApprovedRoutes(t *testing.T) {
[]byte(`{"routes":[]}`))
assertStatus(t, res, http.StatusBadRequest)
})
// The admin's v2 route calls on routers announcing the tailnet's own
// addresses, replayed against v1: the same routes accepted or refused.
// Test data source: policy/v2/testdata/routes_results/routes-t4-admin.hujson.
t.Run("saas compat", func(t *testing.T) {
c := readRoutesCapture(t, "routes-t4-admin")
h := newAPIV1Harness(t)
devices := seedCaptureRouters(t, h.app, c)
replayRouteCalls(t, c, "v1-", func(a testcapture.Action, alias string) routeCall {
target := "/api/v1/node/" + devices[alias]
if a.Method == http.MethodPost {
target += "/approve_routes"
}
res := h.callHuma(a.Method, target, a.Body)
var body struct {
Node struct {
Available []string `json:"availableRoutes"`
Approved []string `json:"approvedRoutes"`
} `json:"node"`
Detail string `json:"detail"`
}
require.NoErrorf(t, json.Unmarshal(res.body, &body), "body: %s", res.body)
return routeCall{res.status, body.Node.Available, body.Node.Approved, body.Detail}
})
})
}
func TestAPIV1NodeRegister(t *testing.T) {
+223
View File
@@ -2,17 +2,25 @@ package hscontrol
import (
"encoding/json"
"fmt"
"net/http"
"net/netip"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/danielgtaylor/huma/v2/humatest"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"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"
)
// registerAPIV2 builds a humatest API with the v2 operations registered over the
@@ -341,3 +349,218 @@ func containsDeviceID(devs []apiv2.Device, id string) bool {
return false
}
// routesAPICompatSkipReasons lists route API answers where headscale differs
// from SaaS, keyed <test_id>/<step>/<i>/<check>, where <i> is the call's index
// in its step. A listed check that matches fails, so a fix must delete its
// entry.
var routesAPICompatSkipReasons = map[string]string{
// The GET after each refused probe reads back what headscale stored.
"routes-t4-admin/probe/0/status": routeHostBitsAccepted,
"routes-t4-admin/probe/1/enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/2/status": routeHostBitsAccepted,
"routes-t4-admin/probe/3/enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/0/v1-status": routeHostBitsAccepted,
"routes-t4-admin/probe/1/v1-enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/2/v1-status": routeHostBitsAccepted,
"routes-t4-admin/probe/3/v1-enabled": routeHostBitsAccepted,
"routes-t5-oauth-routes-scope/probe/0/status": routeHostBitsAccepted,
"routes-t5-oauth-routes-scope/probe/1/enabled": routeHostBitsAccepted,
}
const routeHostBitsAccepted = "ROUTE_HOST_BITS_ACCEPTED: SaaS refuses a route with host bits set with 400, headscale approves it as sent"
// routeCall is one routes API answer, as the compat replays compare it.
type routeCall struct {
status int
advertised, enabled []string
message string
}
// TestAPIv2Device_SetRoutesCompat replays the admin's route calls on routers
// announcing the tailnet's own addresses and compares each answer with SaaS.
//
// Test data source: policy/v2/testdata/routes_results/routes-t4-admin.hujson,
// shared with TestRoutesCompat* and TestRoutesTailnetOverlapCompat.
func TestAPIv2Device_SetRoutesCompat(t *testing.T) {
c := readRoutesCapture(t, "routes-t4-admin")
app := createTestApp(t)
api := registerAPIV2(t, app)
devices := seedCaptureRouters(t, app, c)
replayRouteCalls(t, c, "", func(a testcapture.Action, alias string) routeCall {
target := "/api/v2/device/" + devices[alias] + "/routes"
if a.Method == http.MethodPost {
resp := api.Post(target, a.Body)
return v2RouteCall(t, resp.Code, resp.Body.Bytes())
}
resp := api.Get(target)
return v2RouteCall(t, resp.Code, resp.Body.Bytes())
})
}
func readRoutesCapture(t *testing.T, id string) *testcapture.Capture {
t.Helper()
c, err := testcapture.Read(filepath.Join("policy", "v2", "testdata", "routes_results", id+".hujson"))
require.NoError(t, err)
return c
}
// seedCaptureRouters puts every topology node that announces routes into the
// NodeStore with its tags, announcing them, and returns the device IDs by
// alias.
func seedCaptureRouters(t *testing.T, app *Headscale, c *testcapture.Capture) map[string]string {
t.Helper()
user := app.state.CreateUserForTest("capture-owner")
devices := map[string]string{}
for alias, topo := range c.Topology.Nodes {
if len(topo.RoutableIPs) == 0 {
continue
}
var announced []netip.Prefix
for _, r := range topo.RoutableIPs {
announced = append(announced, netip.MustParsePrefix(r))
}
node := app.state.CreateRegisteredNodeForTest(user, alias)
node.User = user
node.Tags = topo.Tags
node.Hostinfo = &tailcfg.Hostinfo{Hostname: alias, RoutableIPs: announced}
// Who may set a router's routes can hang on its owner.
nv := app.state.PutNodeInStoreForTest(*node)
require.Equalf(t, len(topo.Tags) > 0, nv.IsTagged(), "%s: tagged as captured", alias)
devices[alias] = nv.StringID()
}
return devices
}
// replayRouteCalls sends each routes call of the capture's steps, in order,
// through call and compares the answer with SaaS's. prefix names the API in
// the check names, so one capture can be replayed against more than one.
func replayRouteCalls(
t *testing.T,
c *testcapture.Capture,
prefix string,
call func(a testcapture.Action, alias string) routeCall,
) {
t.Helper()
replayed := 0
for _, step := range c.Steps {
for i, a := range step.Actions {
alias, isDevice := strings.CutPrefix(a.Path, "/device/{device:")
alias, isRoutes := strings.CutSuffix(alias, "}/routes")
if a.Kind != "api" || !isDevice || !isRoutes {
continue
}
replayed++
key := fmt.Sprintf("%s/%s/%d/%s", c.TestID, step.Name, i, prefix)
t.Run(fmt.Sprintf("%s/%d", step.Name, i), func(t *testing.T) {
want, got := saasRouteCall(t, a), call(a, alias)
compatCheck(t, key+"status", strconv.Itoa(want.status), strconv.Itoa(got.status))
if want.status == http.StatusOK && got.status == http.StatusOK {
compatCheck(t, key+"advertised", sortedStrings(want.advertised), sortedStrings(got.advertised))
compatCheck(t, key+"enabled", sortedStrings(want.enabled), sortedStrings(got.enabled))
} else {
compatChecksNotRun(t, key+"advertised", key+"enabled")
}
if want.status != http.StatusOK && got.status == want.status {
// Headscale may wrap SaaS's text in more context.
msg := got.message
if strings.Contains(msg, want.message) {
msg = want.message
}
compatCheck(t, key+"message", want.message, msg)
} else {
compatChecksNotRun(t, key+"message")
}
})
}
}
require.Positive(t, replayed, "%s: no routes calls in the capture", c.TestID)
}
// saasRouteCall is the answer SaaS gave to a.
func saasRouteCall(t *testing.T, a testcapture.Action) routeCall {
t.Helper()
var body struct {
Advertised []string `json:"advertisedRoutes"`
Enabled []string `json:"enabledRoutes"`
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal(a.Response, &body))
return routeCall{a.Status, body.Advertised, body.Enabled, body.Message}
}
// v2RouteCall reads a v2 routes answer.
func v2RouteCall(t *testing.T, status int, raw []byte) routeCall {
t.Helper()
var body struct {
apiv2.DeviceRoutes
Message string `json:"message"`
}
require.NoErrorf(t, json.Unmarshal(raw, &body), "body: %s", raw)
return routeCall{status, body.Advertised, body.Enabled, body.Message}
}
// compatCheck compares one rendering of a check, honouring
// routesAPICompatSkipReasons.
func compatCheck(t *testing.T, key, want, got string) {
t.Helper()
t.Run(path.Base(key), func(t *testing.T) {
reason, listed := routesAPICompatSkipReasons[key]
switch {
case listed && want == got:
t.Errorf("matches SaaS; delete routesAPICompatSkipReasons[%q] (%s)", key, reason)
case listed:
t.Skip(reason)
default:
assert.Equal(t, want, got)
}
})
}
// compatChecksNotRun fails for a listed check that cannot run, since its
// entry could never go stale.
func compatChecksNotRun(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
if _, listed := routesAPICompatSkipReasons[key]; listed {
t.Errorf("check %q does not run for this answer; delete its skip entry", key)
}
}
}
func sortedStrings(s []string) string {
return fmt.Sprint(slices.Sorted(slices.Values(s)))
}
+68
View File
@@ -3,14 +3,17 @@ package hscontrol
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -509,3 +512,68 @@ func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) {
func stringOf(s string, n int) string {
return strings.Repeat(s, n)
}
// TestAPIv2OAuth_SetDeviceRoutesCompat creates the devices:routes client SaaS
// created, replays the route calls its token made on routers announcing the
// tailnet's own addresses, and compares each answer with SaaS.
//
// Test data source:
// policy/v2/testdata/routes_results/routes-t5-oauth-routes-scope.hujson,
// shared with TestRoutesCompat* and TestRoutesTailnetOverlapCompat.
func TestAPIv2OAuth_SetDeviceRoutesCompat(t *testing.T) {
c := readRoutesCapture(t, "routes-t5-oauth-routes-scope")
app, baseURL, admin := newOAuthTestServer(t)
devices := seedCaptureRouters(t, app, c)
setup := c.Steps[0]
require.Equal(t, "oauth-client", setup.Name)
create, mint := setup.Actions[0], setup.Actions[1]
key := c.TestID + "/" + setup.Name + "/"
status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", admin, create.Body)
compatCheck(t, key+"0/status", strconv.Itoa(create.Status), strconv.Itoa(status))
var client apiv2.Key
require.NoErrorf(t, json.Unmarshal(body, &client), "body: %s", body)
require.NotEmpty(t, client.Key, "client secret")
tokenFields := func(m map[string]any) string {
return fmt.Sprintf("scope %v type %v expires_in %v", m["scope"], m["token_type"], m["expires_in"])
}
var wantToken map[string]any
require.NoError(t, json.Unmarshal(mint.Response, &wantToken))
status, token := mintToken(t, baseURL, client.ID, client.Key, *mint.Scope, false)
compatCheck(t, key+"1/status", strconv.Itoa(mint.Status), strconv.Itoa(status))
compatCheck(t, key+"1/token", tokenFields(wantToken), tokenFields(token))
bearer, _ := token["access_token"].(string)
require.NotEmpty(t, bearer, "access token")
replayRouteCalls(t, c, "", func(a testcapture.Action, alias string) routeCall {
require.Equal(t, "token:"+create.Client, a.Principal)
target := baseURL + "/api/v2/device/" + devices[alias] + "/routes"
if a.Method == http.MethodPost {
status, body := apiPost(t, target, bearer, a.Body)
return v2RouteCall(t, status, body)
}
req, err := http.NewRequestWithContext(t.Context(), a.Method, target, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return v2RouteCall(t, resp.StatusCode, raw)
})
}
@@ -21,6 +21,7 @@
package v2
import (
"path"
"path/filepath"
"strings"
"testing"
@@ -167,3 +168,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"
@@ -170,6 +171,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.
@@ -716,6 +780,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)
@@ -1448,10 +1516,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.Cut(nmPeer.Name(), ".")
@@ -1487,15 +1562,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)))
}
+134 -65
View File
@@ -16,10 +16,12 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -287,7 +289,59 @@ func TestViaGrantMapCompat(t *testing.T) {
func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
t.Helper()
srv := servertest.NewServer(t)
_, 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()
// Setup writes land one NodeStore batch at a time, and the checks hold
// the netmaps they reach, so the default batch wait only adds time.
srv := servertest.NewServer(t, servertest.WithNodeStoreBatchTimeout(state.TestBatchTimeout))
tagUser := srv.CreateUser(t, "tag-user")
users := createCaptureUsers(t, srv)
@@ -385,6 +439,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],
@@ -393,29 +451,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 {
@@ -423,54 +458,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")
// 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")
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
@@ -569,12 +623,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
@@ -591,6 +642,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).
@@ -718,9 +789,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
@@ -796,7 +864,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,
@@ -804,6 +872,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()
@@ -862,9 +931,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