mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-30 18:24:06 +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
This commit is contained in:
1 parent
59f7c558a6
commit
286d6ea05d
12 files changed
+74040
-72
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+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,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
|
||||
|
||||
Reference in new issue
Block a user