diff --git a/hscontrol/policy/v2/issue_3233_test.go b/hscontrol/policy/v2/issue_3233_test.go index 934ef3785..6ddfe90d7 100644 --- a/hscontrol/policy/v2/issue_3233_test.go +++ b/hscontrol/policy/v2/issue_3233_test.go @@ -99,3 +99,104 @@ func TestIssue3233ViaInternetExitVisibility(t *testing.T) { "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) + } + }) + } +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 0d45d9459..7a6cf30df 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -1418,7 +1418,17 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via } } - result.Exclude = slices.DeleteFunc(result.Exclude, dstPrefix.Overlaps) + // Every address overlaps an exit route, so overlap + // only decides for subnet routes. + result.Exclude = slices.DeleteFunc(result.Exclude, func(p netip.Prefix) bool { + return !tsaddr.IsExitRoute(p) && dstPrefix.Overlaps(p) + }) + } + + // A regular grant to the internet lets the viewer use any + // exit node, not just the via-tagged ones. + if grantReachesInternet(grant) { + result.Exclude = slices.DeleteFunc(result.Exclude, tsaddr.IsExitRoute) } } } @@ -1426,6 +1436,17 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via return result } +// grantReachesInternet reports whether a grant's destinations include +// the internet. The wildcard resolves to tailnet ranges only, but in a +// destination it also covers the internet. +func grantReachesInternet(grant Grant) bool { + return slices.ContainsFunc(grant.Destinations, func(d Alias) bool { + _, ok := d.(Asterix) + + return ok + }) +} + func (pm *PolicyManager) Version() int { return 2 }