policy: surface exit nodes via autogroup:internet (#3212)

compileFilterRules skipped autogroup:internet destinations to keep them
out of the wire-format PacketFilter, but those same compiled rules are
the source of pm.matchers — and Node.CanAccess relies on a matcher whose
DestsIsTheInternet covers the public internet to surface exit-node peers
to ACL sources. With the skip in place no such matcher existed, exit
nodes silently dropped out of the source's peer list, and the docs'
exit-node walkthrough stopped working: `tailscale exit-node list`
returned "no exit nodes found" and `tailscale set --exit-node=<ip>`
returned "no node found in netmap with IP".

Drop the compile-time skip so autogroup:internet flows through normal
matcher derivation, and teach ReduceFilterRules to keep the resulting
client packet-filter rule on exit-route advertisers — Tailscale SaaS
sends those rules to exit nodes so the kernel filter accepts traffic
forwarded by autogroup:internet sources.

Verified against a live tailnet on 2026-04-28 via tscap; the b17/b18
captures land under testdata/issue_3212/ as a regression guard. The
captures are isolated from testdata/routes_results/ because the broader
TestRoutesCompat machinery assumes a CIDR-prefix wire format that
differs from the IPSet-range form SaaS emits for autogroup:internet —
aligning that wire format is tracked separately.

Fixes #3212
This commit is contained in:
Kristoffer Dalby 2026-04-28 11:30:58 +00:00
parent a7d405a255
commit c7a0ca709f
8 changed files with 22985 additions and 11 deletions

View file

@ -10,6 +10,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
@ -1806,3 +1807,50 @@ func TestViaRoutesForPeer(t *testing.T) {
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
})
}
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces
// juanfont/headscale#3212. An ACL that grants access only via
// `autogroup:internet` must keep the exit node visible to the source
// in BuildPeerMap so the Tailscale client surfaces it in
// `tailscale exit-node list`. Authoritative SaaS captures
// (tscap routes-b17/b18, 2026-04-28) confirm SaaS includes the exit
// node in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
t.Parallel()
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
}
aliceNode := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceNode.ID = 1
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
exitNode := node("alice-exit", "100.64.0.1", "fd7a:115c:a1e0::1", users[0])
exitNode.ID = 2
exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
exitNode.ApprovedRoutes = exitRoutes
nodes := types.Nodes{aliceNode, exitNode}
policy := `{
"acls": [
{"action": "accept", "src": ["alice@headscale.net"], "dst": ["autogroup:internet:*"]}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
require.True(t,
slices.ContainsFunc(peerMap[aliceNode.ID], func(n types.NodeView) bool {
return n.ID() == exitNode.ID
}),
"alice should see the exit node as a peer when an ACL grants autogroup:internet (#3212)")
_, matchers := pm.Filter()
require.True(t, aliceNode.View().CanAccess(matchers, exitNode.View()),
"alice.CanAccess(exit) should be true via DestsIsTheInternet()+IsExitNode() (#3212)")
}