Files
headscale/hscontrol/policy/v2/issue_3233_test.go
T
Kristoffer Dalby c700b32eec policy/v2: stop narrow rules from undoing via exit steering
Every address overlaps 0.0.0.0/0, so any regular rule matching the
viewer dropped the exclusion; only a wildcard dst now does.

Fixes #3493
2026-09-30 17:20:46 +02:00

203 lines
6.3 KiB
Go

// A via grant scoping autogroup:internet to a tag must surface only
// the matching exit node to the source — not strip every exit node
// from the source's view.
//
// Spec: https://tailscale.com/docs/features/access-control/grants/grants-via#route-users-through-exit-nodes-based-on-location
package v2
import (
"net/netip"
"slices"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// TestIssue3233ViaInternetExitVisibility loads a policy where alice's
// only access to autogroup:internet is via tag:exit1. Alice sees her
// tag:exit1 exit node as a peer with 0.0.0.0/0 + ::/0 in AllowedIPs,
// and does not see bob's tag:exit2 exit node.
func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{ID: 2, Name: "bob", Email: "bob@headscale.net"},
}
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceLaptop.ID = 1
aliceExit := node("alice-exit", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
aliceExit.ID = 2
aliceExit.Tags = []string{"tag:exit1"}
aliceExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
aliceExit.ApprovedRoutes = exitRoutes
bobExit := node("bob-exit", "100.64.0.21", "fd7a:115c:a1e0::15", users[1])
bobExit.ID = 3
bobExit.Tags = []string{"tag:exit2"}
bobExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
bobExit.ApprovedRoutes = exitRoutes
nodes := types.Nodes{aliceLaptop, aliceExit, bobExit}
policy := `{
"tagOwners": {
"tag:exit1": ["alice@headscale.net"],
"tag:exit2": ["bob@headscale.net"]
},
"grants": [
{
"src": ["alice@headscale.net"],
"dst": ["autogroup:internet"],
"via": ["tag:exit1"],
"ip": ["*"]
}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
t.Run("BuildPeerMap_includes_via_tagged_exit", func(t *testing.T) {
t.Parallel()
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
require.True(t,
slices.Contains(peerMap[aliceLaptop.ID], aliceExit.ID),
"alice must see her tag:exit1 exit node as a peer")
require.False(t,
slices.Contains(peerMap[aliceLaptop.ID], bobExit.ID),
"alice must not see bob's tag:exit2 exit node — via grant scopes to tag:exit1")
})
t.Run("ViaRoutesForPeer_includes_exit_for_matching_tag", func(t *testing.T) {
t.Parallel()
result := pm.ViaRoutesForPeer(aliceLaptop.View(), aliceExit.View())
require.Contains(t, result.Include, tsaddr.AllIPv4(),
"alice viewing tag:exit1 exit must Include 0.0.0.0/0 — drives AllowedIPs in state.RoutesForPeer")
require.Contains(t, result.Include, tsaddr.AllIPv6(),
"alice viewing tag:exit1 exit must Include ::/0 — drives AllowedIPs in state.RoutesForPeer")
})
t.Run("ViaRoutesForPeer_excludes_exit_for_other_tag", func(t *testing.T) {
t.Parallel()
result := pm.ViaRoutesForPeer(aliceLaptop.View(), bobExit.View())
require.Contains(t, result.Exclude, tsaddr.AllIPv4(),
"alice viewing tag:exit2 exit must Exclude 0.0.0.0/0 — strips it from AllowedIPs")
require.Contains(t, result.Exclude, tsaddr.AllIPv6(),
"alice viewing tag:exit2 exit must Exclude ::/0 — strips it from AllowedIPs")
})
}
// TestViaInternetExitSteeringSurvivesUnrelatedRules checks that a
// regular rule matching the viewer only lifts via exit steering when its
// destination actually reaches the internet. Narrow destinations overlap
// 0.0.0.0/0 but must not evict it from Exclude.
func TestViaInternetExitSteeringSurvivesUnrelatedRules(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{ID: 2, Name: "bob", Email: "bob@headscale.net"},
}
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceLaptop.ID = 1
exitA := node("exit-a", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
exitA.ID = 2
exitA.Tags = []string{"tag:exit-a"}
exitA.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
exitA.ApprovedRoutes = exitRoutes
exitB := node("exit-b", "100.64.0.12", "fd7a:115c:a1e0::c", users[0])
exitB.ID = 3
exitB.Tags = []string{"tag:exit-b"}
exitB.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
exitB.ApprovedRoutes = exitRoutes
nodes := types.Nodes{aliceLaptop, exitA, exitB}
tests := []struct {
name string
extra string
// wantExcluded: exit-b's exit routes stay hidden from alice.
wantExcluded bool
}{
{
name: "via-grant-only",
wantExcluded: true,
},
{
name: "acl-autogroup-self",
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]}],`,
wantExcluded: true,
},
{
name: "acl-single-host",
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["100.64.0.15:53"]}],`,
wantExcluded: true,
},
{
name: "acl-private-subnet",
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["10.0.0.0/8:*"]}],`,
wantExcluded: true,
},
{
name: "acl-other-exit-node",
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["tag:exit-b:*"]}],`,
wantExcluded: true,
},
{
name: "acl-wildcard",
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]}],`,
wantExcluded: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
policy := `{
"tagOwners": {
"tag:exit-a": ["alice@headscale.net"],
"tag:exit-b": ["alice@headscale.net"]
},
` + tt.extra + `
"grants": [{
"src": ["autogroup:member"],
"dst": ["autogroup:internet"],
"ip": ["*"],
"via": ["tag:exit-a"]
}]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
viaPeer := pm.ViaRoutesForPeer(aliceLaptop.View(), exitA.View())
require.Contains(t, viaPeer.Include, tsaddr.AllIPv4())
other := pm.ViaRoutesForPeer(aliceLaptop.View(), exitB.View())
for _, p := range exitRoutes {
require.Equal(t, tt.wantExcluded, slices.Contains(other.Exclude, p),
"exit-b %s in Exclude: got %v", p, other.Exclude)
}
})
}
}