headscale/hscontrol/policy/v2/issue_3212_test.go
Kristoffer Dalby c7a0ca709f 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
2026-04-29 11:24:33 +01:00

173 lines
5.0 KiB
Go

// Tests pinned against tscap captures for juanfont/headscale#3212.
//
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
// tailnet. They reproduce the literal #3212 setup: an ACL granting
// access to autogroup:internet:* combined with autoApprovers.exitNode
// approving exit routes on tagged exit nodes. SaaS surfaces those exit
// nodes as peers in the ACL source's netmap with 0.0.0.0/0 and ::/0 in
// AllowedIPs. Headscale must do the same — that is the user-visible UX
// driving `tailscale exit-node list`.
//
// Captures live under testdata/issue_3212/ rather than testdata/
// routes_results/ so the broader TestRoutesCompat / *PeerAllowedIPs /
// *ReduceRoutes machinery does not pull them in. Those tests assume a
// PacketFilterRules wire format (CIDR prefix per dest entry) that
// differs from what SaaS emits for autogroup:internet (range form per
// IPSet range — e.g. "0.0.0.0-9.255.255.255"). Aligning that wire
// format is tracked separately; the #3212 fix is about peer
// visibility, not packet-filter encoding.
package v2
import (
"path/filepath"
"slices"
"strings"
"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/net/tsaddr"
)
// TestIssue3212AutogroupInternetExitVisibility loads the b17/b18
// SaaS captures and asserts headscale's BuildPeerMap surfaces every
// exit-route advertiser to every ACL-source node — matching the peer
// list in the captured netmap.
//
// The bug fixed by this PR (#3212) was that headscale skipped
// autogroup:internet during FilterRule compilation, which silently
// dropped the matchers that Node.CanAccess reads via DestsIsTheInternet.
// The captures pin the SaaS-equivalent expectation as a regression
// guard so the same skip cannot sneak back in unnoticed.
func TestIssue3212AutogroupInternetExitVisibility(t *testing.T) {
t.Parallel()
files := []string{
"routes-b17-autogroup-internet-with-exit-autoapprover",
"routes-b18-autogroup-internet-wildcard-src-with-exit-autoapprover",
}
for _, testID := range files {
path := filepath.Join(
"testdata", "issue_3212", testID+".hujson",
)
tf := loadRoutesTestFile(t, path)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoErrorf(t, err,
"%s: failed to create PolicyManager", tf.TestID,
)
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
expected := expectedExitPeerVisibility(t, tf, nodes)
require.NotEmptyf(t, expected,
"%s: capture exposes no source→exit relationships — "+
"the test is meaningless if SaaS itself never "+
"surfaced an exit node to a source",
tf.TestID,
)
for srcName, exitNames := range expected {
srcNode := findNodeByGivenName(nodes, srcName)
require.NotNilf(t, srcNode,
"%s: src node %q missing from topology",
tf.TestID, srcName,
)
peerIDs := make(
map[types.NodeID]struct{},
len(peerMap[srcNode.ID]),
)
for _, p := range peerMap[srcNode.ID] {
peerIDs[p.ID()] = struct{}{}
}
for _, exitName := range exitNames {
exitNode := findNodeByGivenName(nodes, exitName)
require.NotNilf(t, exitNode,
"%s: exit node %q missing from topology",
tf.TestID, exitName,
)
_, found := peerIDs[exitNode.ID]
assert.Truef(t, found,
"%s: source %q must see exit node %q "+
"as a peer via the autogroup:internet "+
"ACL — Tailscale SaaS does (#3212)",
tf.TestID, srcName, exitName,
)
}
}
})
}
}
// expectedExitPeerVisibility extracts (source-node, exit-node) pairs
// the capture's netmaps witness. A node is treated as an exit-route
// advertiser when its ApprovedRoutes contain 0.0.0.0/0 or ::/0;
// a (source, exit) pair is recorded when the source's captured netmap
// lists the advertiser as a peer with 0.0.0.0/0 or ::/0 in AllowedIPs.
func expectedExitPeerVisibility(
t *testing.T,
tf *testcapture.Capture,
nodes types.Nodes,
) map[string][]string {
t.Helper()
v4Exit := tsaddr.AllIPv4()
v6Exit := tsaddr.AllIPv6()
exitAdvertisers := make(map[string]bool)
for _, n := range nodes {
if slices.Contains(n.ApprovedRoutes, v4Exit) ||
slices.Contains(n.ApprovedRoutes, v6Exit) {
exitAdvertisers[n.GivenName] = true
}
}
expected := make(map[string][]string)
for srcName, capture := range tf.Captures {
if capture.Netmap == nil {
continue
}
var seen []string
for _, peer := range capture.Netmap.Peers {
peerName := strings.Split(peer.Name(), ".")[0]
if !exitAdvertisers[peerName] {
continue
}
peerAllowed := peer.AllowedIPs().AsSlice()
if !slices.Contains(peerAllowed, v4Exit) &&
!slices.Contains(peerAllowed, v6Exit) {
continue
}
seen = append(seen, peerName)
}
if len(seen) > 0 {
expected[srcName] = seen
}
}
return expected
}