mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-31 13:07:46 +09:00 
			
		
		
		
	feat: add autogroup:self (#2789)
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/test-integration.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/test-integration.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -23,6 +23,7 @@ jobs: | ||||
|           - TestPolicyUpdateWhileRunningWithCLIInDatabase | ||||
|           - TestACLAutogroupMember | ||||
|           - TestACLAutogroupTagged | ||||
|           - TestACLAutogroupSelf | ||||
|           - TestAuthKeyLogoutAndReloginSameUser | ||||
|           - TestAuthKeyLogoutAndReloginNewUser | ||||
|           - TestAuthKeyLogoutAndReloginSameUserExpiredKey | ||||
| @@ -82,6 +83,7 @@ jobs: | ||||
|           - TestSSHNoSSHConfigured | ||||
|           - TestSSHIsBlockedInACL | ||||
|           - TestSSHUserOnlyIsolation | ||||
|           - TestSSHAutogroupSelf | ||||
|     uses: ./.github/workflows/integration-test-template.yml | ||||
|     with: | ||||
|       test: ${{ matrix.test }} | ||||
|   | ||||
| @@ -95,6 +95,8 @@ upstream is changed. | ||||
|   [#2764](https://github.com/juanfont/headscale/pull/2764) | ||||
| - Add FAQ entry on how to recover from an invalid policy in the database | ||||
|   [#2776](https://github.com/juanfont/headscale/pull/2776) | ||||
| - EXPERIMENTAL: Add support for `autogroup:self`  | ||||
|   [#2789](https://github.com/juanfont/headscale/pull/2789) | ||||
|  | ||||
| ## 0.26.1 (2025-06-06) | ||||
|  | ||||
| @@ -252,6 +254,7 @@ working in v1 and not tested might be broken in v2 (and vice versa). | ||||
| - Add documentation for routes | ||||
|   [#2496](https://github.com/juanfont/headscale/pull/2496) | ||||
|  | ||||
|  | ||||
| ## 0.25.1 (2025-02-25) | ||||
|  | ||||
| ### Changes | ||||
|   | ||||
| @@ -23,7 +23,7 @@ provides on overview of Headscale's feature and compatibility with the Tailscale | ||||
| - [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D)) | ||||
|     - [x] ACL management via API | ||||
|     - [x] Some [Autogroups](https://tailscale.com/kb/1396/targets#autogroups), currently: `autogroup:internet`, | ||||
|       `autogroup:nonroot`, `autogroup:member`, `autogroup:tagged` | ||||
|       `autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`, `autogroup:self` | ||||
|     - [x] [Auto approvers](https://tailscale.com/kb/1337/acl-syntax#auto-approvers) for [subnet | ||||
|       routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit | ||||
|       nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers) | ||||
|   | ||||
| @@ -194,13 +194,93 @@ Here are the ACL's to implement the same permissions as above: | ||||
|       "dst": ["tag:dev-app-servers:80,443"] | ||||
|     }, | ||||
|  | ||||
|     // We still have to allow internal users communications since nothing guarantees that each user have | ||||
|     // their own users. | ||||
|     { "action": "accept", "src": ["boss@"], "dst": ["boss@:*"] }, | ||||
|     { "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] }, | ||||
|     { "action": "accept", "src": ["dev2@"], "dst": ["dev2@:*"] }, | ||||
|     { "action": "accept", "src": ["admin1@"], "dst": ["admin1@:*"] }, | ||||
|     { "action": "accept", "src": ["intern1@"], "dst": ["intern1@:*"] } | ||||
|     // Allow users to access their own devices using autogroup:self (see below for more details about performance impact) | ||||
|     { | ||||
|       "action": "accept", | ||||
|       "src": ["autogroup:member"], | ||||
|       "dst": ["autogroup:self:*"] | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Autogroups | ||||
|  | ||||
| Headscale supports several autogroups that automatically include users, destinations, or devices with specific properties. Autogroups provide a convenient way to write ACL rules without manually listing individual users or devices. | ||||
|  | ||||
| ### `autogroup:internet` | ||||
|  | ||||
| Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in ACL destinations.   | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "action": "accept", | ||||
|   "src": ["group:users"], | ||||
|   "dst": ["autogroup:internet:*"] | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### `autogroup:member` | ||||
|  | ||||
| Includes all users who are direct members of the tailnet. Does not include users from shared devices. | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "action": "accept", | ||||
|   "src": ["autogroup:member"], | ||||
|   "dst": ["tag:prod-app-servers:80,443"] | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### `autogroup:tagged` | ||||
|  | ||||
| Includes all devices that have at least one tag. | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "action": "accept", | ||||
|   "src": ["autogroup:tagged"], | ||||
|   "dst": ["tag:monitoring:9090"] | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### `autogroup:self`  | ||||
| **(EXPERIMENTAL)** | ||||
|  | ||||
| !!! warning "The current implementation of `autogroup:self` is inefficient"   | ||||
|  | ||||
| Includes devices where the same user is authenticated on both the source and destination. Does not include tagged devices. Can only be used in ACL destinations. | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "action": "accept", | ||||
|   "src": ["autogroup:member"], | ||||
|   "dst": ["autogroup:self:*"] | ||||
| } | ||||
| ``` | ||||
| *Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments, as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.* | ||||
|  | ||||
| If you experience performance issues, consider using more specific ACL rules or limiting the use of `autogroup:self`.   | ||||
| ```json     | ||||
| { | ||||
| // To allow internal users communications to their own nodes we can do following rules to allow access in case autogroup:self is causing performance issues. | ||||
| { "action": "accept", "src": ["boss@"], "dst": ["boss@:"] }, | ||||
| { "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] }, | ||||
| { "action": "accept", "src": ["dev2@"], "dst": ["dev2@:"] }, | ||||
| { "action": "accept", "src": ["admin1@"], "dst": ["admin1@:"] }, | ||||
| { "action": "accept", "src": ["intern1@"], "dst": ["intern1@:"] } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### `autogroup:nonroot` | ||||
|  | ||||
| Used in Tailscale SSH rules to allow access to any user except root. Can only be used in the `users` field of SSH rules. | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "action": "accept", | ||||
|   "src": ["autogroup:member"], | ||||
|   "dst": ["autogroup:self"], | ||||
|   "users": ["autogroup:nonroot"] | ||||
| } | ||||
| ``` | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/juanfont/headscale/hscontrol/policy" | ||||
| 	"github.com/juanfont/headscale/hscontrol/policy/matcher" | ||||
| 	"github.com/juanfont/headscale/hscontrol/types" | ||||
| 	"tailscale.com/tailcfg" | ||||
| 	"tailscale.com/types/views" | ||||
| @@ -180,7 +181,11 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder { | ||||
| 		return b | ||||
| 	} | ||||
|  | ||||
| 	filter, _ := b.mapper.state.Filter() | ||||
| 	filter, err := b.mapper.state.FilterForNode(node) | ||||
| 	if err != nil { | ||||
| 		b.addError(err) | ||||
| 		return b | ||||
| 	} | ||||
|  | ||||
| 	// CapVer 81: 2023-11-17: MapResponse.PacketFilters (incremental packet filter updates) | ||||
| 	// Currently, we do not send incremental package filters, however using the | ||||
| @@ -226,7 +231,13 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( | ||||
| 		return nil, errors.New("node not found") | ||||
| 	} | ||||
|  | ||||
| 	filter, matchers := b.mapper.state.Filter() | ||||
| 	// Use per-node filter to handle autogroup:self | ||||
| 	filter, err := b.mapper.state.FilterForNode(node) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	matchers := matcher.MatchesFromFilterRules(filter) | ||||
|  | ||||
| 	// If there are filter rules present, see if there are any nodes that cannot | ||||
| 	// access each-other at all and remove them from the peers. | ||||
|   | ||||
| @@ -13,6 +13,8 @@ import ( | ||||
| type PolicyManager interface { | ||||
| 	// Filter returns the current filter rules for the entire tailnet and the associated matchers. | ||||
| 	Filter() ([]tailcfg.FilterRule, []matcher.Match) | ||||
| 	// FilterForNode returns filter rules for a specific node, handling autogroup:self | ||||
| 	FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) | ||||
| 	SSHPolicy(types.NodeView) (*tailcfg.SSHPolicy, error) | ||||
| 	SetPolicy([]byte) (bool, error) | ||||
| 	SetUsers(users []types.User) (bool, error) | ||||
|   | ||||
| @@ -82,6 +82,159 @@ func (pol *Policy) compileFilterRules( | ||||
| 	return rules, nil | ||||
| } | ||||
|  | ||||
| // compileFilterRulesForNode compiles filter rules for a specific node. | ||||
| func (pol *Policy) compileFilterRulesForNode( | ||||
| 	users types.Users, | ||||
| 	node types.NodeView, | ||||
| 	nodes views.Slice[types.NodeView], | ||||
| ) ([]tailcfg.FilterRule, error) { | ||||
| 	if pol == nil { | ||||
| 		return tailcfg.FilterAllowAll, nil | ||||
| 	} | ||||
|  | ||||
| 	var rules []tailcfg.FilterRule | ||||
|  | ||||
| 	for _, acl := range pol.ACLs { | ||||
| 		if acl.Action != ActionAccept { | ||||
| 			return nil, ErrInvalidAction | ||||
| 		} | ||||
|  | ||||
| 		rule, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes) | ||||
| 		if err != nil { | ||||
| 			log.Trace().Err(err).Msgf("compiling ACL") | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if rule != nil { | ||||
| 			rules = append(rules, *rule) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return rules, nil | ||||
| } | ||||
|  | ||||
| // compileACLWithAutogroupSelf compiles a single ACL rule, handling | ||||
| // autogroup:self per-node while supporting all other alias types normally. | ||||
| func (pol *Policy) compileACLWithAutogroupSelf( | ||||
| 	acl ACL, | ||||
| 	users types.Users, | ||||
| 	node types.NodeView, | ||||
| 	nodes views.Slice[types.NodeView], | ||||
| ) (*tailcfg.FilterRule, error) { | ||||
| 	// Check if any destination uses autogroup:self | ||||
| 	hasAutogroupSelfInDst := false | ||||
|  | ||||
| 	for _, dest := range acl.Destinations { | ||||
| 		if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 			hasAutogroupSelfInDst = true | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	var srcIPs netipx.IPSetBuilder | ||||
|  | ||||
| 	// Resolve sources to only include devices from the same user as the target node. | ||||
| 	for _, src := range acl.Sources { | ||||
| 		// autogroup:self is not allowed in sources | ||||
| 		if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 			return nil, fmt.Errorf("autogroup:self cannot be used in sources") | ||||
| 		} | ||||
|  | ||||
| 		ips, err := src.Resolve(pol, users, nodes) | ||||
| 		if err != nil { | ||||
| 			log.Trace().Err(err).Msgf("resolving source ips") | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if ips != nil { | ||||
| 			if hasAutogroupSelfInDst { | ||||
| 				// Instead of iterating all addresses (which could be millions), | ||||
| 				// check each node's IPs against the source set | ||||
| 				for _, n := range nodes.All() { | ||||
| 					if n.User().ID == node.User().ID && !n.IsTagged() { | ||||
| 						// Check if any of this node's IPs are in the source set | ||||
| 						for _, nodeIP := range n.IPs() { | ||||
| 							if ips.Contains(nodeIP) { | ||||
| 								n.AppendToIPSet(&srcIPs) | ||||
| 								break // Found this node, move to next | ||||
| 							} | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} else { | ||||
| 				// No autogroup:self in destination, use all resolved sources | ||||
| 				srcIPs.AddSet(ips) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	srcSet, err := srcIPs.IPSet() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if srcSet == nil || len(srcSet.Prefixes()) == 0 { | ||||
| 		// No sources resolved, skip this rule | ||||
| 		return nil, nil //nolint:nilnil | ||||
| 	} | ||||
|  | ||||
| 	protocols, _ := acl.Protocol.parseProtocol() | ||||
|  | ||||
| 	var destPorts []tailcfg.NetPortRange | ||||
|  | ||||
| 	for _, dest := range acl.Destinations { | ||||
| 		if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 			for _, n := range nodes.All() { | ||||
| 				if n.User().ID == node.User().ID && !n.IsTagged() { | ||||
| 					for _, port := range dest.Ports { | ||||
| 						for _, ip := range n.IPs() { | ||||
| 							pr := tailcfg.NetPortRange{ | ||||
| 								IP:    ip.String(), | ||||
| 								Ports: port, | ||||
| 							} | ||||
| 							destPorts = append(destPorts, pr) | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} else { | ||||
| 			ips, err := dest.Resolve(pol, users, nodes) | ||||
| 			if err != nil { | ||||
| 				log.Trace().Err(err).Msgf("resolving destination ips") | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			if ips == nil { | ||||
| 				log.Debug().Msgf("destination resolved to nil ips: %v", dest) | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			prefixes := ips.Prefixes() | ||||
|  | ||||
| 			for _, pref := range prefixes { | ||||
| 				for _, port := range dest.Ports { | ||||
| 					pr := tailcfg.NetPortRange{ | ||||
| 						IP:    pref.String(), | ||||
| 						Ports: port, | ||||
| 					} | ||||
| 					destPorts = append(destPorts, pr) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(destPorts) == 0 { | ||||
| 		// No destinations resolved, skip this rule | ||||
| 		return nil, nil //nolint:nilnil | ||||
| 	} | ||||
|  | ||||
| 	return &tailcfg.FilterRule{ | ||||
| 		SrcIPs:   ipSetToPrefixStringList(srcSet), | ||||
| 		DstPorts: destPorts, | ||||
| 		IPProto:  protocols, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func sshAction(accept bool, duration time.Duration) tailcfg.SSHAction { | ||||
| 	return tailcfg.SSHAction{ | ||||
| 		Reject:                    !accept, | ||||
| @@ -107,14 +260,39 @@ func (pol *Policy) compileSSHPolicy( | ||||
| 	var rules []*tailcfg.SSHRule | ||||
|  | ||||
| 	for index, rule := range pol.SSHs { | ||||
| 		// Check if any destination uses autogroup:self | ||||
| 		hasAutogroupSelfInDst := false | ||||
| 		for _, dst := range rule.Destinations { | ||||
| 			if ag, ok := dst.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				hasAutogroupSelfInDst = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// If autogroup:self is used, skip tagged nodes | ||||
| 		if hasAutogroupSelfInDst && node.IsTagged() { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		var dest netipx.IPSetBuilder | ||||
| 		for _, src := range rule.Destinations { | ||||
| 			// Handle autogroup:self specially | ||||
| 			if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				// For autogroup:self, only include the target user's untagged devices | ||||
| 				for _, n := range nodes.All() { | ||||
| 					if n.User().ID == node.User().ID && !n.IsTagged() { | ||||
| 						n.AppendToIPSet(&dest) | ||||
| 					} | ||||
| 				} | ||||
| 			} else { | ||||
| 				ips, err := src.Resolve(pol, users, nodes) | ||||
| 				if err != nil { | ||||
| 					log.Trace().Caller().Err(err).Msgf("resolving destination ips") | ||||
| 					continue | ||||
| 				} | ||||
| 				dest.AddSet(ips) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		destSet, err := dest.IPSet() | ||||
| 		if err != nil { | ||||
| @@ -142,6 +320,33 @@ func (pol *Policy) compileSSHPolicy( | ||||
| 			continue // Skip this rule if we can't resolve sources | ||||
| 		} | ||||
|  | ||||
| 		// If autogroup:self is in destinations, filter sources to same user only | ||||
| 		if hasAutogroupSelfInDst { | ||||
| 			var filteredSrcIPs netipx.IPSetBuilder | ||||
| 			// Instead of iterating all addresses, check each node's IPs | ||||
| 			for _, n := range nodes.All() { | ||||
| 				if n.User().ID == node.User().ID && !n.IsTagged() { | ||||
| 					// Check if any of this node's IPs are in the source set | ||||
| 					for _, nodeIP := range n.IPs() { | ||||
| 						if srcIPs.Contains(nodeIP) { | ||||
| 							n.AppendToIPSet(&filteredSrcIPs) | ||||
| 							break // Found this node, move to next | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			srcIPs, err = filteredSrcIPs.IPSet() | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 			} | ||||
|  | ||||
| 			if srcIPs == nil || len(srcIPs.Prefixes()) == 0 { | ||||
| 				// No valid sources after filtering, skip this rule | ||||
| 				continue | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		for addr := range util.IPSetAddrIter(srcIPs) { | ||||
| 			principals = append(principals, &tailcfg.SSHPrincipal{ | ||||
| 				NodeIP: addr.String(), | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package v2 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"net/netip" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| @@ -15,6 +16,14 @@ import ( | ||||
| 	"tailscale.com/tailcfg" | ||||
| ) | ||||
|  | ||||
| // aliasWithPorts creates an AliasWithPorts structure from an alias and ports. | ||||
| func aliasWithPorts(alias Alias, ports ...tailcfg.PortRange) AliasWithPorts { | ||||
| 	return AliasWithPorts{ | ||||
| 		Alias: alias, | ||||
| 		Ports: ports, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestParsing(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "testuser"}, | ||||
| @@ -786,8 +795,548 @@ func TestSSHJSONSerialization(t *testing.T) { | ||||
| 	assert.NotContains(t, string(jsonData), `"sshUsers": null`, "SSH users should not be null") | ||||
| } | ||||
|  | ||||
| func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{ | ||||
| 			User: users[0], | ||||
| 			IPv4: ap("100.64.0.1"), | ||||
| 		}, | ||||
| 		{ | ||||
| 			User: users[0], | ||||
| 			IPv4: ap("100.64.0.2"), | ||||
| 		}, | ||||
| 		{ | ||||
| 			User: users[1], | ||||
| 			IPv4: ap("100.64.0.3"), | ||||
| 		}, | ||||
| 		{ | ||||
| 			User: users[1], | ||||
| 			IPv4: ap("100.64.0.4"), | ||||
| 		}, | ||||
| 		// Tagged device for user1 | ||||
| 		{ | ||||
| 			User:       users[0], | ||||
| 			IPv4:       ap("100.64.0.5"), | ||||
| 			ForcedTags: []string{"tag:test"}, | ||||
| 		}, | ||||
| 		// Tagged device for user2 | ||||
| 		{ | ||||
| 			User:       users[1], | ||||
| 			IPv4:       ap("100.64.0.6"), | ||||
| 			ForcedTags: []string{"tag:test"}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	// Test: Tailscale intended usage pattern (autogroup:member + autogroup:self) | ||||
| 	policy2 := &Policy{ | ||||
| 		ACLs: []ACL{ | ||||
| 			{ | ||||
| 				Action:  "accept", | ||||
| 				Sources: []Alias{agp("autogroup:member")}, | ||||
| 				Destinations: []AliasWithPorts{ | ||||
| 					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy2.validate() | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("policy validation failed: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	// Test compilation for user1's first node | ||||
| 	node1 := nodes[0].View() | ||||
|  | ||||
| 	rules, err := policy2.compileFilterRulesForNode(users, node1, nodes.ViewSlice()) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("unexpected error: %v", err) | ||||
| 	} | ||||
| 	if len(rules) != 1 { | ||||
| 		t.Fatalf("expected 1 rule, got %d", len(rules)) | ||||
| 	} | ||||
|  | ||||
| 	// Check that the rule includes: | ||||
| 	// - Sources: only user1's untagged devices (filtered by autogroup:self semantics) | ||||
| 	// - Destinations: only user1's untagged devices (autogroup:self) | ||||
| 	rule := rules[0] | ||||
|  | ||||
| 	// Sources should ONLY include user1's untagged devices (100.64.0.1, 100.64.0.2) | ||||
| 	expectedSourceIPs := []string{"100.64.0.1", "100.64.0.2"} | ||||
|  | ||||
| 	for _, expectedIP := range expectedSourceIPs { | ||||
| 		found := false | ||||
|  | ||||
| 		addr := netip.MustParseAddr(expectedIP) | ||||
| 		for _, prefix := range rule.SrcIPs { | ||||
| 			pref := netip.MustParsePrefix(prefix) | ||||
| 			if pref.Contains(addr) { | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if !found { | ||||
| 			t.Errorf("expected source IP %s to be covered by generated prefixes %v", expectedIP, rule.SrcIPs) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Verify that other users' devices and tagged devices are not included in sources | ||||
| 	excludedSourceIPs := []string{"100.64.0.3", "100.64.0.4", "100.64.0.5", "100.64.0.6"} | ||||
| 	for _, excludedIP := range excludedSourceIPs { | ||||
| 		addr := netip.MustParseAddr(excludedIP) | ||||
| 		for _, prefix := range rule.SrcIPs { | ||||
| 			pref := netip.MustParsePrefix(prefix) | ||||
| 			if pref.Contains(addr) { | ||||
| 				t.Errorf("SECURITY VIOLATION: source IP %s should not be included but found in prefix %s", excludedIP, prefix) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	expectedDestIPs := []string{"100.64.0.1", "100.64.0.2"} | ||||
|  | ||||
| 	actualDestIPs := make([]string, 0, len(rule.DstPorts)) | ||||
| 	for _, dst := range rule.DstPorts { | ||||
| 		actualDestIPs = append(actualDestIPs, dst.IP) | ||||
| 	} | ||||
|  | ||||
| 	for _, expectedIP := range expectedDestIPs { | ||||
| 		found := false | ||||
|  | ||||
| 		for _, actualIP := range actualDestIPs { | ||||
| 			if actualIP == expectedIP { | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if !found { | ||||
| 			t.Errorf("expected destination IP %s to be included, got: %v", expectedIP, actualDestIPs) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Verify that other users' devices and tagged devices are not in destinations | ||||
| 	excludedDestIPs := []string{"100.64.0.3", "100.64.0.4", "100.64.0.5", "100.64.0.6"} | ||||
| 	for _, excludedIP := range excludedDestIPs { | ||||
| 		for _, actualIP := range actualDestIPs { | ||||
| 			if actualIP == excludedIP { | ||||
| 				t.Errorf("SECURITY: destination IP %s should not be included but found in destinations", excludedIP) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestAutogroupSelfInSourceIsRejected(t *testing.T) { | ||||
| 	// Test that autogroup:self cannot be used in sources (per Tailscale spec) | ||||
| 	policy := &Policy{ | ||||
| 		ACLs: []ACL{ | ||||
| 			{ | ||||
| 				Action:  "accept", | ||||
| 				Sources: []Alias{agp("autogroup:self")}, | ||||
| 				Destinations: []AliasWithPorts{ | ||||
| 					aliasWithPorts(agp("autogroup:member"), tailcfg.PortRangeAny), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	if err == nil { | ||||
| 		t.Error("expected validation error when using autogroup:self in sources") | ||||
| 	} | ||||
|  | ||||
| 	if !strings.Contains(err.Error(), "autogroup:self") { | ||||
| 		t.Errorf("expected error message to mention autogroup:self, got: %v", err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestAutogroupSelfWithSpecificUserSource verifies that when autogroup:self is in | ||||
| // the destination and a specific user is in the source, only that user's devices | ||||
| // are allowed (and only if they match the target user). | ||||
| func TestAutogroupSelfWithSpecificUserSource(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1")}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.3")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.4")}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		ACLs: []ACL{ | ||||
| 			{ | ||||
| 				Action:  "accept", | ||||
| 				Sources: []Alias{up("user1@")}, | ||||
| 				Destinations: []AliasWithPorts{ | ||||
| 					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// For user1's node: sources should be user1's devices | ||||
| 	node1 := nodes[0].View() | ||||
| 	rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.Len(t, rules, 1) | ||||
|  | ||||
| 	expectedSourceIPs := []string{"100.64.0.1", "100.64.0.2"} | ||||
| 	for _, expectedIP := range expectedSourceIPs { | ||||
| 		found := false | ||||
| 		addr := netip.MustParseAddr(expectedIP) | ||||
|  | ||||
| 		for _, prefix := range rules[0].SrcIPs { | ||||
| 			pref := netip.MustParsePrefix(prefix) | ||||
| 			if pref.Contains(addr) { | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		assert.True(t, found, "expected source IP %s to be present", expectedIP) | ||||
| 	} | ||||
|  | ||||
| 	actualDestIPs := make([]string, 0, len(rules[0].DstPorts)) | ||||
| 	for _, dst := range rules[0].DstPorts { | ||||
| 		actualDestIPs = append(actualDestIPs, dst.IP) | ||||
| 	} | ||||
|  | ||||
| 	assert.ElementsMatch(t, expectedSourceIPs, actualDestIPs) | ||||
|  | ||||
| 	node2 := nodes[2].View() | ||||
| 	rules2, err := policy.compileFilterRulesForNode(users, node2, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	assert.Empty(t, rules2, "user2's node should have no rules (user1@ devices can't match user2's self)") | ||||
| } | ||||
|  | ||||
| // TestAutogroupSelfWithGroupSource verifies that when a group is used as source | ||||
| // and autogroup:self as destination, only group members who are the same user | ||||
| // as the target are allowed. | ||||
| func TestAutogroupSelfWithGroupSource(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 		{Model: gorm.Model{ID: 3}, Name: "user3"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1")}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.3")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.4")}, | ||||
| 		{User: users[2], IPv4: ap("100.64.0.5")}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		Groups: Groups{ | ||||
| 			Group("group:admins"): []Username{Username("user1@"), Username("user2@")}, | ||||
| 		}, | ||||
| 		ACLs: []ACL{ | ||||
| 			{ | ||||
| 				Action:  "accept", | ||||
| 				Sources: []Alias{gp("group:admins")}, | ||||
| 				Destinations: []AliasWithPorts{ | ||||
| 					aliasWithPorts(agp("autogroup:self"), tailcfg.PortRangeAny), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// (group:admins has user1+user2, but autogroup:self filters to same user) | ||||
| 	node1 := nodes[0].View() | ||||
| 	rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.Len(t, rules, 1) | ||||
|  | ||||
| 	expectedSrcIPs := []string{"100.64.0.1", "100.64.0.2"} | ||||
| 	for _, expectedIP := range expectedSrcIPs { | ||||
| 		found := false | ||||
| 		addr := netip.MustParseAddr(expectedIP) | ||||
|  | ||||
| 		for _, prefix := range rules[0].SrcIPs { | ||||
| 			pref := netip.MustParsePrefix(prefix) | ||||
| 			if pref.Contains(addr) { | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		assert.True(t, found, "expected source IP %s for user1", expectedIP) | ||||
| 	} | ||||
|  | ||||
| 	node3 := nodes[4].View() | ||||
| 	rules3, err := policy.compileFilterRulesForNode(users, node3, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	assert.Empty(t, rules3, "user3 should have no rules") | ||||
| } | ||||
|  | ||||
| // Helper function to create IP addresses for testing | ||||
| func createAddr(ip string) *netip.Addr { | ||||
| 	addr, _ := netip.ParseAddr(ip) | ||||
| 	return &addr | ||||
| } | ||||
|  | ||||
| // TestSSHWithAutogroupSelfInDestination verifies that SSH policies work correctly | ||||
| // with autogroup:self in destinations | ||||
| func TestSSHWithAutogroupSelfInDestination(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		// User1's nodes | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1"), Hostname: "user1-node1"}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2"), Hostname: "user1-node2"}, | ||||
| 		// User2's nodes | ||||
| 		{User: users[1], IPv4: ap("100.64.0.3"), Hostname: "user2-node1"}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.4"), Hostname: "user2-node2"}, | ||||
| 		// Tagged node for user1 (should be excluded) | ||||
| 		{User: users[0], IPv4: ap("100.64.0.5"), Hostname: "user1-tagged", ForcedTags: []string{"tag:server"}}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		SSHs: []SSH{ | ||||
| 			{ | ||||
| 				Action:       "accept", | ||||
| 				Sources:      SSHSrcAliases{agp("autogroup:member")}, | ||||
| 				Destinations: SSHDstAliases{agp("autogroup:self")}, | ||||
| 				Users:        []SSHUser{"autogroup:nonroot"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Test for user1's first node | ||||
| 	node1 := nodes[0].View() | ||||
| 	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, sshPolicy) | ||||
| 	require.Len(t, sshPolicy.Rules, 1) | ||||
|  | ||||
| 	rule := sshPolicy.Rules[0] | ||||
|  | ||||
| 	// Principals should only include user1's untagged devices | ||||
| 	require.Len(t, rule.Principals, 2, "should have 2 principals (user1's 2 untagged nodes)") | ||||
|  | ||||
| 	principalIPs := make([]string, len(rule.Principals)) | ||||
| 	for i, p := range rule.Principals { | ||||
| 		principalIPs[i] = p.NodeIP | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs) | ||||
|  | ||||
| 	// Test for user2's first node | ||||
| 	node3 := nodes[2].View() | ||||
| 	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, sshPolicy2) | ||||
| 	require.Len(t, sshPolicy2.Rules, 1) | ||||
|  | ||||
| 	rule2 := sshPolicy2.Rules[0] | ||||
|  | ||||
| 	// Principals should only include user2's untagged devices | ||||
| 	require.Len(t, rule2.Principals, 2, "should have 2 principals (user2's 2 untagged nodes)") | ||||
|  | ||||
| 	principalIPs2 := make([]string, len(rule2.Principals)) | ||||
| 	for i, p := range rule2.Principals { | ||||
| 		principalIPs2[i] = p.NodeIP | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, []string{"100.64.0.3", "100.64.0.4"}, principalIPs2) | ||||
|  | ||||
| 	// Test for tagged node (should have no SSH rules) | ||||
| 	node5 := nodes[4].View() | ||||
| 	sshPolicy3, err := policy.compileSSHPolicy(users, node5, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	if sshPolicy3 != nil { | ||||
| 		assert.Empty(t, sshPolicy3.Rules, "tagged nodes should not get SSH rules with autogroup:self") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestSSHWithAutogroupSelfAndSpecificUser verifies that when a specific user | ||||
| // is in the source and autogroup:self in destination, only that user's devices | ||||
| // can SSH (and only if they match the target user) | ||||
| func TestSSHWithAutogroupSelfAndSpecificUser(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1")}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.3")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.4")}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		SSHs: []SSH{ | ||||
| 			{ | ||||
| 				Action:       "accept", | ||||
| 				Sources:      SSHSrcAliases{up("user1@")}, | ||||
| 				Destinations: SSHDstAliases{agp("autogroup:self")}, | ||||
| 				Users:        []SSHUser{"ubuntu"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// For user1's node: should allow SSH from user1's devices | ||||
| 	node1 := nodes[0].View() | ||||
| 	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, sshPolicy) | ||||
| 	require.Len(t, sshPolicy.Rules, 1) | ||||
|  | ||||
| 	rule := sshPolicy.Rules[0] | ||||
| 	require.Len(t, rule.Principals, 2, "user1 should have 2 principals") | ||||
|  | ||||
| 	principalIPs := make([]string, len(rule.Principals)) | ||||
| 	for i, p := range rule.Principals { | ||||
| 		principalIPs[i] = p.NodeIP | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs) | ||||
|  | ||||
| 	// For user2's node: should have no rules (user1's devices can't match user2's self) | ||||
| 	node3 := nodes[2].View() | ||||
| 	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	if sshPolicy2 != nil { | ||||
| 		assert.Empty(t, sshPolicy2.Rules, "user2 should have no SSH rules since source is user1") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestSSHWithAutogroupSelfAndGroup verifies SSH with group sources and autogroup:self destinations | ||||
| func TestSSHWithAutogroupSelfAndGroup(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2"}, | ||||
| 		{Model: gorm.Model{ID: 3}, Name: "user3"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1")}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.3")}, | ||||
| 		{User: users[1], IPv4: ap("100.64.0.4")}, | ||||
| 		{User: users[2], IPv4: ap("100.64.0.5")}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		Groups: Groups{ | ||||
| 			Group("group:admins"): []Username{Username("user1@"), Username("user2@")}, | ||||
| 		}, | ||||
| 		SSHs: []SSH{ | ||||
| 			{ | ||||
| 				Action:       "accept", | ||||
| 				Sources:      SSHSrcAliases{gp("group:admins")}, | ||||
| 				Destinations: SSHDstAliases{agp("autogroup:self")}, | ||||
| 				Users:        []SSHUser{"root"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// For user1's node: should allow SSH from user1's devices only (not user2's) | ||||
| 	node1 := nodes[0].View() | ||||
| 	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, sshPolicy) | ||||
| 	require.Len(t, sshPolicy.Rules, 1) | ||||
|  | ||||
| 	rule := sshPolicy.Rules[0] | ||||
| 	require.Len(t, rule.Principals, 2, "user1 should have 2 principals (only user1's nodes)") | ||||
|  | ||||
| 	principalIPs := make([]string, len(rule.Principals)) | ||||
| 	for i, p := range rule.Principals { | ||||
| 		principalIPs[i] = p.NodeIP | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs) | ||||
|  | ||||
| 	// For user3's node: should have no rules (not in group:admins) | ||||
| 	node5 := nodes[4].View() | ||||
| 	sshPolicy2, err := policy.compileSSHPolicy(users, node5, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	if sshPolicy2 != nil { | ||||
| 		assert.Empty(t, sshPolicy2.Rules, "user3 should have no SSH rules (not in group)") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestSSHWithAutogroupSelfExcludesTaggedDevices verifies that tagged devices | ||||
| // are excluded from both sources and destinations when autogroup:self is used | ||||
| func TestSSHWithAutogroupSelfExcludesTaggedDevices(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1"}, | ||||
| 	} | ||||
|  | ||||
| 	nodes := types.Nodes{ | ||||
| 		{User: users[0], IPv4: ap("100.64.0.1"), Hostname: "untagged1"}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.2"), Hostname: "untagged2"}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.3"), Hostname: "tagged1", ForcedTags: []string{"tag:server"}}, | ||||
| 		{User: users[0], IPv4: ap("100.64.0.4"), Hostname: "tagged2", ForcedTags: []string{"tag:web"}}, | ||||
| 	} | ||||
|  | ||||
| 	policy := &Policy{ | ||||
| 		TagOwners: TagOwners{ | ||||
| 			Tag("tag:server"): Owners{up("user1@")}, | ||||
| 			Tag("tag:web"):    Owners{up("user1@")}, | ||||
| 		}, | ||||
| 		SSHs: []SSH{ | ||||
| 			{ | ||||
| 				Action:       "accept", | ||||
| 				Sources:      SSHSrcAliases{agp("autogroup:member")}, | ||||
| 				Destinations: SSHDstAliases{agp("autogroup:self")}, | ||||
| 				Users:        []SSHUser{"admin"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := policy.validate() | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// For untagged node: should only get principals from other untagged nodes | ||||
| 	node1 := nodes[0].View() | ||||
| 	sshPolicy, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, sshPolicy) | ||||
| 	require.Len(t, sshPolicy.Rules, 1) | ||||
|  | ||||
| 	rule := sshPolicy.Rules[0] | ||||
| 	require.Len(t, rule.Principals, 2, "should only have 2 principals (untagged nodes)") | ||||
|  | ||||
| 	principalIPs := make([]string, len(rule.Principals)) | ||||
| 	for i, p := range rule.Principals { | ||||
| 		principalIPs[i] = p.NodeIP | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, principalIPs, | ||||
| 		"should only include untagged devices") | ||||
|  | ||||
| 	// For tagged node: should get no SSH rules | ||||
| 	node3 := nodes[2].View() | ||||
| 	sshPolicy2, err := policy.compileSSHPolicy(users, node3, nodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
| 	if sshPolicy2 != nil { | ||||
| 		assert.Empty(t, sshPolicy2.Rules, "tagged node should get no SSH rules with autogroup:self") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -38,6 +38,10 @@ type PolicyManager struct { | ||||
|  | ||||
| 	// Lazy map of SSH policies | ||||
| 	sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy | ||||
|  | ||||
| 	// Lazy map of per-node filter rules (when autogroup:self is used) | ||||
| 	filterRulesMap    map[types.NodeID][]tailcfg.FilterRule | ||||
| 	usesAutogroupSelf bool | ||||
| } | ||||
|  | ||||
| // NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes. | ||||
| @@ -54,6 +58,8 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node | ||||
| 		users:             users, | ||||
| 		nodes:             nodes, | ||||
| 		sshPolicyMap:      make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()), | ||||
| 		filterRulesMap:    make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()), | ||||
| 		usesAutogroupSelf: policy.usesAutogroupSelf(), | ||||
| 	} | ||||
|  | ||||
| 	_, err = pm.updateLocked() | ||||
| @@ -72,8 +78,17 @@ func (pm *PolicyManager) updateLocked() (bool, error) { | ||||
| 	// policies for nodes that have changed. Particularly if the only difference is | ||||
| 	// that nodes has been added or removed. | ||||
| 	clear(pm.sshPolicyMap) | ||||
| 	clear(pm.filterRulesMap) | ||||
|  | ||||
| 	filter, err := pm.pol.compileFilterRules(pm.users, pm.nodes) | ||||
| 	// Check if policy uses autogroup:self | ||||
| 	pm.usesAutogroupSelf = pm.pol.usesAutogroupSelf() | ||||
|  | ||||
| 	var filter []tailcfg.FilterRule | ||||
|  | ||||
| 	var err error | ||||
|  | ||||
| 	// Standard compilation for all policies | ||||
| 	filter, err = pm.pol.compileFilterRules(pm.users, pm.nodes) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("compiling filter rules: %w", err) | ||||
| 	} | ||||
| @@ -218,6 +233,35 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) { | ||||
| 	return pm.filter, pm.matchers | ||||
| } | ||||
|  | ||||
| // FilterForNode returns the filter rules for a specific node. | ||||
| // If the policy uses autogroup:self, this returns node-specific rules for security. | ||||
| // Otherwise, it returns the global filter rules for efficiency. | ||||
| func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) { | ||||
| 	if pm == nil { | ||||
| 		return nil, nil | ||||
| 	} | ||||
|  | ||||
| 	pm.mu.Lock() | ||||
| 	defer pm.mu.Unlock() | ||||
|  | ||||
| 	if !pm.usesAutogroupSelf { | ||||
| 		return pm.filter, nil | ||||
| 	} | ||||
|  | ||||
| 	if rules, ok := pm.filterRulesMap[node.ID()]; ok { | ||||
| 		return rules, nil | ||||
| 	} | ||||
|  | ||||
| 	rules, err := pm.pol.compileFilterRulesForNode(pm.users, node, pm.nodes) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("compiling filter rules for node: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	pm.filterRulesMap[node.ID()] = rules | ||||
|  | ||||
| 	return rules, nil | ||||
| } | ||||
|  | ||||
| // SetUsers updates the users in the policy manager and updates the filter rules. | ||||
| func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) { | ||||
| 	if pm == nil { | ||||
| @@ -255,6 +299,20 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro | ||||
|  | ||||
| 	pm.mu.Lock() | ||||
| 	defer pm.mu.Unlock() | ||||
|  | ||||
| 	// Clear cache based on what actually changed | ||||
| 	if pm.usesAutogroupSelf { | ||||
| 		// For autogroup:self, we need granular invalidation since rules depend on: | ||||
| 		// - User ownership (node.User().ID) | ||||
| 		// - Tag status (node.IsTagged()) | ||||
| 		// - IP addresses (node.IPs()) | ||||
| 		// - Node existence (added/removed) | ||||
| 		pm.invalidateAutogroupSelfCache(pm.nodes, nodes) | ||||
| 	} else { | ||||
| 		// For non-autogroup:self policies, we can clear everything | ||||
| 		clear(pm.filterRulesMap) | ||||
| 	} | ||||
|  | ||||
| 	pm.nodes = nodes | ||||
|  | ||||
| 	return pm.updateLocked() | ||||
| @@ -399,3 +457,113 @@ func (pm *PolicyManager) DebugString() string { | ||||
|  | ||||
| 	return sb.String() | ||||
| } | ||||
|  | ||||
| // invalidateAutogroupSelfCache intelligently clears only the cache entries that need to be | ||||
| // invalidated when using autogroup:self policies. This is much more efficient than clearing | ||||
| // the entire cache. | ||||
| func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.Slice[types.NodeView]) { | ||||
| 	// Build maps for efficient lookup | ||||
| 	oldNodeMap := make(map[types.NodeID]types.NodeView) | ||||
| 	for _, node := range oldNodes.All() { | ||||
| 		oldNodeMap[node.ID()] = node | ||||
| 	} | ||||
|  | ||||
| 	newNodeMap := make(map[types.NodeID]types.NodeView) | ||||
| 	for _, node := range newNodes.All() { | ||||
| 		newNodeMap[node.ID()] = node | ||||
| 	} | ||||
|  | ||||
| 	// Track which users are affected by changes | ||||
| 	affectedUsers := make(map[uint]struct{}) | ||||
|  | ||||
| 	// Check for removed nodes | ||||
| 	for nodeID, oldNode := range oldNodeMap { | ||||
| 		if _, exists := newNodeMap[nodeID]; !exists { | ||||
| 			affectedUsers[oldNode.User().ID] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Check for added nodes | ||||
| 	for nodeID, newNode := range newNodeMap { | ||||
| 		if _, exists := oldNodeMap[nodeID]; !exists { | ||||
| 			affectedUsers[newNode.User().ID] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Check for modified nodes (user changes, tag changes, IP changes) | ||||
| 	for nodeID, newNode := range newNodeMap { | ||||
| 		if oldNode, exists := oldNodeMap[nodeID]; exists { | ||||
| 			// Check if user changed | ||||
| 			if oldNode.User().ID != newNode.User().ID { | ||||
| 				affectedUsers[oldNode.User().ID] = struct{}{} | ||||
| 				affectedUsers[newNode.User().ID] = struct{}{} | ||||
| 			} | ||||
|  | ||||
| 			// Check if tag status changed | ||||
| 			if oldNode.IsTagged() != newNode.IsTagged() { | ||||
| 				affectedUsers[newNode.User().ID] = struct{}{} | ||||
| 			} | ||||
|  | ||||
| 			// Check if IPs changed (simple check - could be more sophisticated) | ||||
| 			oldIPs := oldNode.IPs() | ||||
| 			newIPs := newNode.IPs() | ||||
| 			if len(oldIPs) != len(newIPs) { | ||||
| 				affectedUsers[newNode.User().ID] = struct{}{} | ||||
| 			} else { | ||||
| 				// Check if any IPs are different | ||||
| 				for i, oldIP := range oldIPs { | ||||
| 					if i >= len(newIPs) || oldIP != newIPs[i] { | ||||
| 						affectedUsers[newNode.User().ID] = struct{}{} | ||||
| 						break | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Clear cache entries for affected users only | ||||
| 	// For autogroup:self, we need to clear all nodes belonging to affected users | ||||
| 	// because autogroup:self rules depend on the entire user's device set | ||||
| 	for nodeID := range pm.filterRulesMap { | ||||
| 		// Find the user for this cached node | ||||
| 		var nodeUserID uint | ||||
| 		found := false | ||||
|  | ||||
| 		// Check in new nodes first | ||||
| 		for _, node := range newNodes.All() { | ||||
| 			if node.ID() == nodeID { | ||||
| 				nodeUserID = node.User().ID | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// If not found in new nodes, check old nodes | ||||
| 		if !found { | ||||
| 			for _, node := range oldNodes.All() { | ||||
| 				if node.ID() == nodeID { | ||||
| 					nodeUserID = node.User().ID | ||||
| 					found = true | ||||
| 					break | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// If we found the user and they're affected, clear this cache entry | ||||
| 		if found { | ||||
| 			if _, affected := affectedUsers[nodeUserID]; affected { | ||||
| 				delete(pm.filterRulesMap, nodeID) | ||||
| 			} | ||||
| 		} else { | ||||
| 			// Node not found in either old or new list, clear it | ||||
| 			delete(pm.filterRulesMap, nodeID) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(affectedUsers) > 0 { | ||||
| 		log.Debug(). | ||||
| 			Int("affected_users", len(affectedUsers)). | ||||
| 			Int("remaining_cache_entries", len(pm.filterRulesMap)). | ||||
| 			Msg("Selectively cleared autogroup:self cache for affected users") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -66,3 +66,141 @@ func TestPolicyManager(t *testing.T) { | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestInvalidateAutogroupSelfCache(t *testing.T) { | ||||
| 	users := types.Users{ | ||||
| 		{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}, | ||||
| 		{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"}, | ||||
| 		{Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"}, | ||||
| 	} | ||||
|  | ||||
| 	policy := `{ | ||||
| 		"acls": [ | ||||
| 			{ | ||||
| 				"action": "accept", | ||||
| 				"src": ["autogroup:member"], | ||||
| 				"dst": ["autogroup:self:*"] | ||||
| 			} | ||||
| 		] | ||||
| 	}` | ||||
|  | ||||
| 	initialNodes := types.Nodes{ | ||||
| 		node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil), | ||||
| 		node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil), | ||||
| 		node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 		node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 	} | ||||
|  | ||||
| 	for i, n := range initialNodes { | ||||
| 		n.ID = types.NodeID(i + 1) | ||||
| 	} | ||||
|  | ||||
| 	pm, err := NewPolicyManager([]byte(policy), users, initialNodes.ViewSlice()) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Add to cache by calling FilterForNode for each node | ||||
| 	for _, n := range initialNodes { | ||||
| 		_, err := pm.FilterForNode(n.View()) | ||||
| 		require.NoError(t, err) | ||||
| 	} | ||||
|  | ||||
| 	require.Equal(t, len(initialNodes), len(pm.filterRulesMap)) | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name            string | ||||
| 		newNodes        types.Nodes | ||||
| 		expectedCleared int | ||||
| 		description     string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "no_changes", | ||||
| 			newNodes: types.Nodes{ | ||||
| 				node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil), | ||||
| 				node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil), | ||||
| 				node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 				node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 			}, | ||||
| 			expectedCleared: 0, | ||||
| 			description:     "No changes should clear no cache entries", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "node_added", | ||||
| 			newNodes: types.Nodes{ | ||||
| 				node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil), | ||||
| 				node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil), | ||||
| 				node("user1-node3", "100.64.0.5", "fd7a:115c:a1e0::5", users[0], nil), // New node | ||||
| 				node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 				node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 			}, | ||||
| 			expectedCleared: 2, // user1's existing nodes should be cleared | ||||
| 			description:     "Adding a node should clear cache for that user's existing nodes", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "node_removed", | ||||
| 			newNodes: types.Nodes{ | ||||
| 				node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil), | ||||
| 				// user1-node2 removed | ||||
| 				node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 				node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 			}, | ||||
| 			expectedCleared: 2, // user1's remaining node + removed node should be cleared | ||||
| 			description:     "Removing a node should clear cache for that user's remaining nodes", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "user_changed", | ||||
| 			newNodes: types.Nodes{ | ||||
| 				node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil), | ||||
| 				node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[2], nil), // Changed to user3 | ||||
| 				node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 				node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 			}, | ||||
| 			expectedCleared: 3, // user1's node + user2's node + user3's nodes should be cleared | ||||
| 			description:     "Changing a node's user should clear cache for both old and new users", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "ip_changed", | ||||
| 			newNodes: types.Nodes{ | ||||
| 				node("user1-node1", "100.64.0.10", "fd7a:115c:a1e0::10", users[0], nil), // IP changed | ||||
| 				node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil), | ||||
| 				node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil), | ||||
| 				node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil), | ||||
| 			}, | ||||
| 			expectedCleared: 2, // user1's nodes should be cleared | ||||
| 			description:     "Changing a node's IP should clear cache for that user's nodes", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			for i, n := range tt.newNodes { | ||||
| 				found := false | ||||
| 				for _, origNode := range initialNodes { | ||||
| 					if n.Hostname == origNode.Hostname { | ||||
| 						n.ID = origNode.ID | ||||
| 						found = true | ||||
| 						break | ||||
| 					} | ||||
| 				} | ||||
| 				if !found { | ||||
| 					n.ID = types.NodeID(len(initialNodes) + i + 1) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule) | ||||
| 			for _, n := range initialNodes { | ||||
| 				_, err := pm.FilterForNode(n.View()) | ||||
| 				require.NoError(t, err) | ||||
| 			} | ||||
|  | ||||
| 			initialCacheSize := len(pm.filterRulesMap) | ||||
| 			require.Equal(t, len(initialNodes), initialCacheSize) | ||||
|  | ||||
| 			pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice()) | ||||
|  | ||||
| 			// Verify the expected number of cache entries were cleared | ||||
| 			finalCacheSize := len(pm.filterRulesMap) | ||||
| 			clearedEntries := initialCacheSize - finalCacheSize | ||||
| 			require.Equal(t, tt.expectedCleared, clearedEntries, tt.description) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -32,6 +32,8 @@ var policyJSONOpts = []json.Options{ | ||||
|  | ||||
| const Wildcard = Asterix(0) | ||||
|  | ||||
| var ErrAutogroupSelfRequiresPerNodeResolution = errors.New("autogroup:self requires per-node resolution and cannot be resolved in this context") | ||||
|  | ||||
| type Asterix int | ||||
|  | ||||
| func (a Asterix) Validate() error { | ||||
| @@ -485,8 +487,6 @@ const ( | ||||
| 	AutoGroupMember   AutoGroup = "autogroup:member" | ||||
| 	AutoGroupNonRoot  AutoGroup = "autogroup:nonroot" | ||||
| 	AutoGroupTagged   AutoGroup = "autogroup:tagged" | ||||
|  | ||||
| 	// These are not yet implemented. | ||||
| 	AutoGroupSelf     AutoGroup = "autogroup:self" | ||||
| ) | ||||
|  | ||||
| @@ -495,6 +495,7 @@ var autogroups = []AutoGroup{ | ||||
| 	AutoGroupMember, | ||||
| 	AutoGroupNonRoot, | ||||
| 	AutoGroupTagged, | ||||
| 	AutoGroupSelf, | ||||
| } | ||||
|  | ||||
| func (ag AutoGroup) Validate() error { | ||||
| @@ -590,6 +591,12 @@ func (ag AutoGroup) Resolve(p *Policy, users types.Users, nodes views.Slice[type | ||||
|  | ||||
| 		return build.IPSet() | ||||
|  | ||||
| 	case AutoGroupSelf: | ||||
| 		// autogroup:self represents all devices owned by the same user. | ||||
| 		// This cannot be resolved in the general context and should be handled | ||||
| 		// specially during policy compilation per-node for security. | ||||
| 		return nil, ErrAutogroupSelfRequiresPerNodeResolution | ||||
|  | ||||
| 	default: | ||||
| 		return nil, fmt.Errorf("unknown autogroup %q", ag) | ||||
| 	} | ||||
| @@ -1586,11 +1593,11 @@ type Policy struct { | ||||
| var ( | ||||
| 	// TODO(kradalby): Add these checks for tagOwners and autoApprovers. | ||||
| 	autogroupForSrc       = []AutoGroup{AutoGroupMember, AutoGroupTagged} | ||||
| 	autogroupForDst       = []AutoGroup{AutoGroupInternet, AutoGroupMember, AutoGroupTagged} | ||||
| 	autogroupForDst       = []AutoGroup{AutoGroupInternet, AutoGroupMember, AutoGroupTagged, AutoGroupSelf} | ||||
| 	autogroupForSSHSrc    = []AutoGroup{AutoGroupMember, AutoGroupTagged} | ||||
| 	autogroupForSSHDst    = []AutoGroup{AutoGroupMember, AutoGroupTagged} | ||||
| 	autogroupForSSHDst    = []AutoGroup{AutoGroupMember, AutoGroupTagged, AutoGroupSelf} | ||||
| 	autogroupForSSHUser   = []AutoGroup{AutoGroupNonRoot} | ||||
| 	autogroupNotSupported = []AutoGroup{AutoGroupSelf} | ||||
| 	autogroupNotSupported = []AutoGroup{} | ||||
| ) | ||||
|  | ||||
| func validateAutogroupSupported(ag *AutoGroup) error { | ||||
| @@ -1614,6 +1621,10 @@ func validateAutogroupForSrc(src *AutoGroup) error { | ||||
| 		return errors.New(`"autogroup:internet" used in source, it can only be used in ACL destinations`) | ||||
| 	} | ||||
|  | ||||
| 	if src.Is(AutoGroupSelf) { | ||||
| 		return errors.New(`"autogroup:self" used in source, it can only be used in ACL destinations`) | ||||
| 	} | ||||
|  | ||||
| 	if !slices.Contains(autogroupForSrc, *src) { | ||||
| 		return fmt.Errorf("autogroup %q is not supported for ACL sources, can be %v", *src, autogroupForSrc) | ||||
| 	} | ||||
| @@ -2112,3 +2123,40 @@ func validateProtocolPortCompatibility(protocol Protocol, destinations []AliasWi | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // usesAutogroupSelf checks if the policy uses autogroup:self in any ACL or SSH rules. | ||||
| func (p *Policy) usesAutogroupSelf() bool { | ||||
| 	if p == nil { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	// Check ACL rules | ||||
| 	for _, acl := range p.ACLs { | ||||
| 		for _, src := range acl.Sources { | ||||
| 			if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				return true | ||||
| 			} | ||||
| 		} | ||||
| 		for _, dest := range acl.Destinations { | ||||
| 			if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				return true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Check SSH rules | ||||
| 	for _, ssh := range p.SSHs { | ||||
| 		for _, src := range ssh.Sources { | ||||
| 			if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				return true | ||||
| 			} | ||||
| 		} | ||||
| 		for _, dest := range ssh.Destinations { | ||||
| 			if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { | ||||
| 				return true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return false | ||||
| } | ||||
|   | ||||
| @@ -459,7 +459,7 @@ func TestUnmarshalPolicy(t *testing.T) { | ||||
| 	], | ||||
| } | ||||
| `, | ||||
| 			wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet autogroup:member autogroup:nonroot autogroup:tagged]`, | ||||
| 			wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet autogroup:member autogroup:nonroot autogroup:tagged autogroup:self]`, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "undefined-hostname-errors-2490", | ||||
| @@ -1881,6 +1881,38 @@ func TestResolvePolicy(t *testing.T) { | ||||
| 				mp("100.100.101.7/32"), // Multiple forced tags | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:      "autogroup-self", | ||||
| 			toResolve: ptr.To(AutoGroupSelf), | ||||
| 			nodes: types.Nodes{ | ||||
| 				{ | ||||
| 					User: users["testuser"], | ||||
| 					IPv4: ap("100.100.101.1"), | ||||
| 				}, | ||||
| 				{ | ||||
| 					User: users["testuser2"], | ||||
| 					IPv4: ap("100.100.101.2"), | ||||
| 				}, | ||||
| 				{ | ||||
| 					User:       users["testuser"], | ||||
| 					ForcedTags: []string{"tag:test"}, | ||||
| 					IPv4:       ap("100.100.101.3"), | ||||
| 				}, | ||||
| 				{ | ||||
| 					User: users["testuser2"], | ||||
| 					Hostinfo: &tailcfg.Hostinfo{ | ||||
| 						RequestTags: []string{"tag:test"}, | ||||
| 					}, | ||||
| 					IPv4: ap("100.100.101.4"), | ||||
| 				}, | ||||
| 			}, | ||||
| 			pol: &Policy{ | ||||
| 				TagOwners: TagOwners{ | ||||
| 					Tag("tag:test"): Owners{ptr.To(Username("testuser@"))}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			wantErr: "autogroup:self requires per-node resolution", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:      "autogroup-invalid", | ||||
| 			toResolve: ptr.To(AutoGroup("autogroup:invalid")), | ||||
|   | ||||
| @@ -793,6 +793,11 @@ func (s *State) Filter() ([]tailcfg.FilterRule, []matcher.Match) { | ||||
| 	return s.polMan.Filter() | ||||
| } | ||||
|  | ||||
| // FilterForNode returns filter rules for a specific node, handling autogroup:self per-node. | ||||
| func (s *State) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) { | ||||
| 	return s.polMan.FilterForNode(node) | ||||
| } | ||||
|  | ||||
| // NodeCanHaveTag checks if a node is allowed to have a specific tag. | ||||
| func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool { | ||||
| 	return s.polMan.NodeCanHaveTag(node, tag) | ||||
|   | ||||
| @@ -1536,3 +1536,100 @@ func TestACLAutogroupTagged(t *testing.T) { | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Test that only devices owned by the same user can access each other and cannot access devices of other users | ||||
| func TestACLAutogroupSelf(t *testing.T) { | ||||
| 	IntegrationSkip(t) | ||||
|  | ||||
| 	scenario := aclScenario(t, | ||||
| 		&policyv2.Policy{ | ||||
| 			ACLs: []policyv2.ACL{ | ||||
| 				{ | ||||
| 					Action:  "accept", | ||||
| 					Sources: []policyv2.Alias{ptr.To(policyv2.AutoGroupMember)}, | ||||
| 					Destinations: []policyv2.AliasWithPorts{ | ||||
| 						aliasWithPorts(ptr.To(policyv2.AutoGroupSelf), tailcfg.PortRangeAny), | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		2, | ||||
| 	) | ||||
| 	defer scenario.ShutdownAssertNoPanics(t) | ||||
|  | ||||
| 	err := scenario.WaitForTailscaleSyncWithPeerCount(1, integrationutil.PeerSyncTimeout(), integrationutil.PeerSyncRetryInterval()) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	user1Clients, err := scenario.GetClients("user1") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	user2Clients, err := scenario.GetClients("user2") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Test that user1's devices can access each other | ||||
| 	for _, client := range user1Clients { | ||||
| 		for _, peer := range user1Clients { | ||||
| 			if client.Hostname() == peer.Hostname() { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			fqdn, err := peer.FQDN() | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			url := fmt.Sprintf("http://%s/etc/hostname", fqdn) | ||||
| 			t.Logf("url from %s (user1) to %s (user1)", client.Hostname(), fqdn) | ||||
|  | ||||
| 			result, err := client.Curl(url) | ||||
| 			assert.Len(t, result, 13) | ||||
| 			require.NoError(t, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Test that user2's devices can access each other | ||||
| 	for _, client := range user2Clients { | ||||
| 		for _, peer := range user2Clients { | ||||
| 			if client.Hostname() == peer.Hostname() { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			fqdn, err := peer.FQDN() | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			url := fmt.Sprintf("http://%s/etc/hostname", fqdn) | ||||
| 			t.Logf("url from %s (user2) to %s (user2)", client.Hostname(), fqdn) | ||||
|  | ||||
| 			result, err := client.Curl(url) | ||||
| 			assert.Len(t, result, 13) | ||||
| 			require.NoError(t, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Test that devices from different users cannot access each other | ||||
| 	for _, client := range user1Clients { | ||||
| 		for _, peer := range user2Clients { | ||||
| 			fqdn, err := peer.FQDN() | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			url := fmt.Sprintf("http://%s/etc/hostname", fqdn) | ||||
| 			t.Logf("url from %s (user1) to %s (user2) - should FAIL", client.Hostname(), fqdn) | ||||
|  | ||||
| 			result, err := client.Curl(url) | ||||
| 			assert.Empty(t, result, "user1 should not be able to access user2's devices with autogroup:self") | ||||
| 			assert.Error(t, err, "connection from user1 to user2 should fail") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	for _, client := range user2Clients { | ||||
| 		for _, peer := range user1Clients { | ||||
| 			fqdn, err := peer.FQDN() | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			url := fmt.Sprintf("http://%s/etc/hostname", fqdn) | ||||
| 			t.Logf("url from %s (user2) to %s (user1) - should FAIL", client.Hostname(), fqdn) | ||||
|  | ||||
| 			result, err := client.Curl(url) | ||||
| 			assert.Empty(t, result, "user2 should not be able to access user1's devices with autogroup:self") | ||||
| 			assert.Error(t, err, "connection from user2 to user1 should fail") | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import ( | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"tailscale.com/tailcfg" | ||||
| 	"tailscale.com/types/ptr" | ||||
| ) | ||||
|  | ||||
| func isSSHNoAccessStdError(stderr string) bool { | ||||
| @@ -458,3 +459,84 @@ func assertSSHNoAccessStdError(t *testing.T, err error, stderr string) { | ||||
| 		t.Errorf("expected stderr output suggesting access denied, got: %s", stderr) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestSSHAutogroupSelf tests that SSH with autogroup:self works correctly: | ||||
| // - Users can SSH to their own devices | ||||
| // - Users cannot SSH to other users' devices | ||||
| func TestSSHAutogroupSelf(t *testing.T) { | ||||
| 	IntegrationSkip(t) | ||||
|  | ||||
| 	scenario := sshScenario(t, | ||||
| 		&policyv2.Policy{ | ||||
| 			ACLs: []policyv2.ACL{ | ||||
| 				{ | ||||
| 					Action:   "accept", | ||||
| 					Protocol: "tcp", | ||||
| 					Sources:  []policyv2.Alias{wildcard()}, | ||||
| 					Destinations: []policyv2.AliasWithPorts{ | ||||
| 						aliasWithPorts(wildcard(), tailcfg.PortRangeAny), | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			SSHs: []policyv2.SSH{ | ||||
| 				{ | ||||
| 					Action: "accept", | ||||
| 					Sources: policyv2.SSHSrcAliases{ | ||||
| 						ptr.To(policyv2.AutoGroupMember), | ||||
| 					}, | ||||
| 					Destinations: policyv2.SSHDstAliases{ | ||||
| 						ptr.To(policyv2.AutoGroupSelf), | ||||
| 					}, | ||||
| 					Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		2, // 2 clients per user | ||||
| 	) | ||||
| 	defer scenario.ShutdownAssertNoPanics(t) | ||||
|  | ||||
| 	user1Clients, err := scenario.ListTailscaleClients("user1") | ||||
| 	assertNoErrListClients(t, err) | ||||
|  | ||||
| 	user2Clients, err := scenario.ListTailscaleClients("user2") | ||||
| 	assertNoErrListClients(t, err) | ||||
|  | ||||
| 	err = scenario.WaitForTailscaleSync() | ||||
| 	assertNoErrSync(t, err) | ||||
|  | ||||
| 	// Test that user1's devices can SSH to each other | ||||
| 	for _, client := range user1Clients { | ||||
| 		for _, peer := range user1Clients { | ||||
| 			if client.Hostname() == peer.Hostname() { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			assertSSHHostname(t, client, peer) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Test that user2's devices can SSH to each other | ||||
| 	for _, client := range user2Clients { | ||||
| 		for _, peer := range user2Clients { | ||||
| 			if client.Hostname() == peer.Hostname() { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			assertSSHHostname(t, client, peer) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Test that user1 cannot SSH to user2's devices | ||||
| 	for _, client := range user1Clients { | ||||
| 		for _, peer := range user2Clients { | ||||
| 			assertSSHPermissionDenied(t, client, peer) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Test that user2 cannot SSH to user1's devices | ||||
| 	for _, client := range user2Clients { | ||||
| 		for _, peer := range user1Clients { | ||||
| 			assertSSHPermissionDenied(t, client, peer) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user