policy/v2: send exit nodes every user's autogroup:self rules

Same as other rules: exit routes contain every self destination.

Updates #3493
This commit is contained in:
Kristoffer Dalby
2026-09-25 12:49:56 +00:00
parent 61d1599393
commit 7f5fdbe5d2
4 changed files with 137 additions and 5 deletions
+43 -3
View File
@@ -2,6 +2,7 @@ package v2
import (
"fmt"
"maps"
"net/netip"
"slices"
@@ -704,15 +705,54 @@ func compileAutogroupSelf(
node types.NodeView,
userIdx userNodeIndex,
) []tailcfg.FilterRule {
if node.IsTagged() || cg.self == nil {
if node.IsTagged() || cg.self == nil || !node.User().Valid() {
return nil
}
if !node.User().Valid() {
return compileSelfForUser(cg, userIdx[node.User().ID()])
}
// exitNodeSelfRules returns the autogroup:self rules of every user
// other than the node's own for an exit node, whose exit routes contain
// every self destination. Only the packet filter needs them: they never
// make the exit node a peer, and expanding every user for peer matching
// would cost O(users) per exit node on each peer map build.
func exitNodeSelfRules(
grants []compiledGrant,
node types.NodeView,
userIdx userNodeIndex,
) []tailcfg.FilterRule {
if !node.IsExitNode() {
return nil
}
sameUserNodes := userIdx[node.User().ID()]
var rules []tailcfg.FilterRule
for i := range grants {
cg := &grants[i]
if cg.self == nil {
continue
}
for _, uid := range slices.Sorted(maps.Keys(userIdx)) {
// compileAutogroupSelf already covers an untagged node's own user.
if !node.IsTagged() && node.User().Valid() && node.User().ID() == uid {
continue
}
rules = append(rules, compileSelfForUser(cg, userIdx[uid])...)
}
}
return rules
}
// compileSelfForUser produces the autogroup:self rules for one user's
// untagged devices.
func compileSelfForUser(
cg *compiledGrant,
sameUserNodes []types.NodeView,
) []tailcfg.FilterRule {
if len(sameUserNodes) == 0 {
return nil
}
+4 -1
View File
@@ -203,7 +203,10 @@ func (pol *Policy) compileFilterRulesForNode(
grants := pol.compileGrants(users, nodes)
userIdx := buildUserNodeIndex(nodes)
return filterRulesForNode(grants, node, userIdx)
return append(
filterRulesForNode(grants, node, userIdx),
exitNodeSelfRules(grants, node, userIdx)...,
)
}
var sshAccept = tailcfg.SSHAction{
+4 -1
View File
@@ -738,7 +738,10 @@ func (pm *PolicyManager) filterForNodeLocked(
if !pm.needsPerNodeFilter {
unreduced = pm.filter
} else {
unreduced = pm.filterRulesForNodeLocked(node)
unreduced = append(
pm.filterRulesForNodeLocked(node),
exitNodeSelfRules(pm.compiledGrants, node, pm.userNodeIdx)...,
)
}
reduced := policyutil.ReduceFilterRules(node, unreduced)
+86
View File
@@ -1445,6 +1445,92 @@ func TestAutogroupSelfCombinedWithTags(t *testing.T) {
"web server should see admin phone (symmetric)")
}
// TestAutogroupSelfRulesReachExitNodes checks that an approved exit
// node receives every user's autogroup:self rules: its exit routes
// contain every destination, and Tailscale SaaS delivers them.
func TestAutogroupSelfRulesReachExitNodes(t *testing.T) {
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@example.com"},
{ID: 2, Name: "bob", Email: "bob@example.com"},
}
alice := &types.Node{
ID: 1,
Hostname: "alice",
User: new(users[0]),
UserID: new(users[0].ID),
IPv4: ap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{},
}
bob := &types.Node{
ID: 2,
Hostname: "bob",
User: new(users[1]),
UserID: new(users[1].ID),
IPv4: ap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{},
}
exit := &types.Node{
ID: 3,
Hostname: "exit",
User: new(users[0]),
UserID: new(users[0].ID),
IPv4: ap("100.64.0.3"),
Tags: []string{"tag:exit"},
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
ApprovedRoutes: tsaddr.ExitRoutes(),
}
server := &types.Node{
ID: 4,
Hostname: "server",
User: new(users[0]),
UserID: new(users[0].ID),
IPv4: ap("100.64.0.4"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodes := types.Nodes{alice, bob, exit, server}
policy := `{
"tagOwners": {
"tag:exit": ["alice@example.com"],
"tag:server": ["alice@example.com"]
},
"acls": [
{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
dsts := func(n *types.Node) []string {
rules, err := pm.FilterForNode(n.View())
require.NoError(t, err)
var out []string
for _, r := range rules {
for _, dp := range r.DstPorts {
out = append(out, dp.IP)
}
}
return out
}
require.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, dsts(exit),
"exit node must get every user's self rule")
require.Empty(t, dsts(server),
"tagged node without exit routes gets no self rules")
require.ElementsMatch(t, []string{"100.64.0.1"}, dsts(alice),
"user device gets only its own user's self rule")
}
// TestIssue2990SameUserTaggedDevice reproduces the exact scenario from issue #2990:
// - One user (user1) who is in group:admin
// - node1: user device (not tagged), belongs to user1