Files
headscale/hscontrol/servertest/nodeattrs_test.go
Kristoffer Dalby 078b9e308f policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.

tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.

testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.

The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-13 14:22:30 +02:00

374 lines
11 KiB
Go

package servertest_test
import (
"context"
"net/netip"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// reloadPolicy applies pol via SetPolicy and runs ReloadPolicy so that the
// state machine emits the changes the mapper consumes — same shape as every
// other servertest that exercises a policy edit.
func reloadPolicy(t *testing.T, srv *servertest.TestServer, pol string) {
t.Helper()
changed, err := srv.State().SetPolicy([]byte(pol))
require.NoError(t, err)
if !changed {
return
}
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
// hasCap reports whether the given netmap's self CapMap contains want.
func hasCap(nm *netmap.NetworkMap, want tailcfg.NodeCapability) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
return nm.SelfNode.CapMap().Contains(want)
}
// peerCapMapsAllEmpty reports whether every peer in nm has an empty
// [tailcfg.Node.CapMap]. The Tailscale-hosted control plane omits the
// peer-side CapMap unless the peer satisfies a peer-cap emission
// condition (e.g. suggest-exit-node on a peer with approved exit
// routes — see [policyv2.PeerCapMap]). The scenarios that call this
// helper do not advertise exit routes, so peer CapMaps stay empty;
// the test asserts that property to lock in the wire shape.
func peerCapMapsAllEmpty(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.CapMap().Len() > 0 {
return false
}
}
return true
}
func TestNodeAttrsDeliverToSelfAndPeer(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "na-user")
c1 := servertest.NewClient(t, srv, "na-node1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "na-node2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port"]
}]
}`)
c1.WaitForCondition(t, "self randomize-client-port cap on c1", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
c2.WaitForCondition(t, "self randomize-client-port cap on c2", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// randomize-client-port is not in the peer-consumed allowlist and
// these nodes don't advertise exit routes, so peer CapMaps stay
// empty. Each client reads its own caps from SelfNode.
assert.True(t, peerCapMapsAllEmpty(c1.Netmap()),
"c1 peer CapMaps must be empty after policy edit")
assert.True(t, peerCapMapsAllEmpty(c2.Netmap()),
"c2 peer CapMaps must be empty after policy edit")
}
func TestNodeAttrsUserTargetIsolated(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
alice := srv.CreateUser(t, "alice")
bob := srv.CreateUser(t, "bob")
a := servertest.NewClient(t, srv, "alice-laptop", servertest.WithUser(alice))
b := servertest.NewClient(t, srv, "bob-laptop", servertest.WithUser(bob))
a.WaitForPeers(t, 0, 5*time.Second)
b.WaitForPeers(t, 0, 5*time.Second)
reloadPolicy(t, srv, `{
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}],
"nodeAttrs": [{
"target": ["alice@"],
"attr": ["randomize-client-port"]
}]
}`)
a.WaitForCondition(t, "alice gains randomize-client-port", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// bob must remain free of the cap; check after alice has converged so we
// know the policy is propagated.
b.WaitForPeers(t, 1, 10*time.Second)
nmB := b.Netmap()
require.NotNil(t, nmB)
assert.False(t, hasCap(nmB, tailcfg.NodeAttrRandomizeClientPort),
"bob is not in the target set; must not receive the cap")
}
func TestNodeAttrsRevokesWhenRemoved(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "revoke-user")
c := servertest.NewClient(t, srv, "revoke-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["disable-captive-portal-detection"]
}]
}`)
c.WaitForCondition(t, "captive cap appears", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection)
})
reloadPolicy(t, srv, `{}`)
c.WaitForCondition(t, "captive cap disappears", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return !hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection)
})
}
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps
// (Admin, SSH, FileSharing, Taildrive share/access) are emitted on every
// node regardless of whether the policy mentions them. Tailscale clients
// expect these to be present, and Tailscale SaaS emits them
// unconditionally; headscale matches that shape.
func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "baseline-user")
c := servertest.NewClient(t, srv, "baseline-node", servertest.WithUser(user))
c.WaitForCondition(t, "baseline caps present without policy", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
for _, w := range []tailcfg.NodeCapability{
tailcfg.CapabilityAdmin,
tailcfg.CapabilitySSH,
tailcfg.CapabilityFileSharing,
tailcfg.NodeAttrsTaildriveShare,
tailcfg.NodeAttrsTaildriveAccess,
} {
if !hasCap(nm, w) {
return false
}
}
return true
})
}
// TestNodeAttrsAddsToBaseline verifies that policy nodeAttrs caps land on
// nodes alongside the always-on baseline. The baseline caps remain
// regardless of policy contents.
func TestNodeAttrsAddsToBaseline(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "addon-user")
c := servertest.NewClient(t, srv, "addon-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port", "disable-captive-portal-detection"]
}]
}`)
c.WaitForCondition(t, "policy adds caps on top of baseline", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort) &&
hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection) &&
hasCap(nm, tailcfg.CapabilitySSH)
})
}
func TestNodeAttrsReloadingSamePolicyDoesNotChurnSelf(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "churn-user")
c := servertest.NewClient(t, srv, "churn-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
const pol = `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port"]
}]
}`
reloadPolicy(t, srv, pol)
c.WaitForCondition(t, "policy cap arrives", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// Reload identical bytes. Per-node CapMap diff produces an empty
// changed set, so SetPolicy returns no SelfUpdate IDs. The
// broadcast PolicyChange still fires because filter rules are
// recomputed on every reload — that's expected. The check below
// is on the wire shape: cap must still be present.
reloadPolicy(t, srv, pol)
c.WaitForCondition(t, "cap persists after no-op reload", 5*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
}
// TestNodeAttrsSuggestExitNodeOnPeerCapMap covers the runtime peer-cap
// path: when a peer advertises exit routes, has them approved, and
// the policy targets it with `suggest-exit-node`, the cap lands on
// [tailcfg.Node.CapMap] of the *peer view* — not just on the exit
// node's own SelfNode.CapMap.
//
// The compat test in policy/v2 covers the wire shape; this test
// proves the runtime delivery path through the live mapper.
func TestNodeAttrsSuggestExitNodeOnPeerCapMap(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "see-user")
exit := servertest.NewClient(t, srv, "see-exit", servertest.WithUser(user))
viewer := servertest.NewClient(t, srv, "see-viewer", servertest.WithUser(user))
// Wait for peer visibility before advertising routes; otherwise the
// hostinfo update can race with initial registration and the
// approval below sees no advertised route to approve.
exit.WaitForPeers(t, 1, 10*time.Second)
viewer.WaitForPeers(t, 1, 10*time.Second)
exitRoutes := []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
}
// Advertise the exit routes via the live noise channel.
exit.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-see-exit",
Hostname: "see-exit",
RoutableIPs: exitRoutes,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
require.NoError(t, exit.Direct().SendUpdate(ctx))
cancel()
// Approve the routes on the control plane and fan the resulting
// change out so peers re-render.
exitID := findNodeID(t, srv, "see-exit")
_, ch, err := srv.State().SetApprovedRoutes(exitID, exitRoutes)
require.NoError(t, err)
srv.App.Change(ch)
// Stamp suggest-exit-node on every node — the peer-cap rule then
// gates the actual peer-view emission on whether the peer is an
// exit node (advertised + approved).
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["suggest-exit-node"]
}]
}`)
// Self-side: the exit node sees the cap on its own SelfNode (the
// usual stamp; nothing special about exit nodes here).
exit.WaitForCondition(t, "self suggest-exit-node on exit", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrSuggestExitNode)
})
// Peer-side: the viewer sees the exit node in its Peers list with
// the cap on the peer entry. This is the property the new
// PeerCapMap rule guards.
viewer.WaitForCondition(t, "peer suggest-exit-node on exit's peer entry", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.ComputedName() != "see-exit" {
continue
}
return peer.CapMap().Contains(tailcfg.NodeAttrSuggestExitNode)
}
return false
})
// Negative side: the viewer's peer view of itself (i.e. the exit's
// peer view of the viewer) must NOT carry suggest-exit-node — only
// the actual exit-node peer view does.
exit.WaitForCondition(t, "viewer's peer entry does not carry suggest-exit-node", 5*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.ComputedName() != "see-viewer" {
continue
}
return !peer.CapMap().Contains(tailcfg.NodeAttrSuggestExitNode)
}
return false
})
}