Skip to content

feat: add autogroup:member, autogroup:tagged #2572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
- TestACLNamedHostsCanReach
- TestACLDevice1CanAccessDevice2
- TestPolicyUpdateWhileRunningWithCLIInDatabase
- TestACLAutogroupMember
- TestACLAutogroupTagged
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ working in v1 and not tested might be broken in v2 (and vice versa).
[#2438](https://github.com/juanfont/headscale/pull/2438)
- Add documentation for routes
[#2496](https://github.com/juanfont/headscale/pull/2496)
- Add support for `autogroup:member`, `autogroup:tagged`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add the PR to this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 2b3ee90

[#2572](https://github.com/juanfont/headscale/pull/2572)

## 0.25.1 (2025-02-25)

Expand Down
2 changes: 1 addition & 1 deletion docs/about/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:nonroot`, `autogroup:member`, `autogroup:tagged`
- [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)
Expand Down
93 changes: 81 additions & 12 deletions hscontrol/policy/v2/types.go

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message in validateAutogroupForSSHDst incorrectly states 'for SSH sources' but should say 'for SSH destinations' to match the function's purpose.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the validateAutogroupForSSHDst function, the error message incorrectly refers to 'SSH sources' instead of 'SSH destinations', which could be confusing during troubleshooting.

Original file line number Diff line number Diff line change
Expand Up @@ -383,15 +383,20 @@ type AutoGroup string

const (
AutoGroupInternet AutoGroup = "autogroup:internet"
AutoGroupMember AutoGroup = "autogroup:member"
AutoGroupNonRoot AutoGroup = "autogroup:nonroot"
AutoGroupTagged AutoGroup = "autogroup:tagged"

// These are not yet implemented.
AutoGroupSelf AutoGroup = "autogroup:self"
AutoGroupMember AutoGroup = "autogroup:member"
AutoGroupTagged AutoGroup = "autogroup:tagged"
AutoGroupSelf AutoGroup = "autogroup:self"
)

var autogroups = []AutoGroup{AutoGroupInternet}
var autogroups = []AutoGroup{
AutoGroupInternet,
AutoGroupMember,
AutoGroupNonRoot,
AutoGroupTagged,
}

func (ag AutoGroup) Validate() error {
if slices.Contains(autogroups, ag) {
Expand All @@ -409,13 +414,76 @@ func (ag *AutoGroup) UnmarshalJSON(b []byte) error {
return nil
}

func (ag AutoGroup) Resolve(_ *Policy, _ types.Users, _ types.Nodes) (*netipx.IPSet, error) {
func (ag AutoGroup) Resolve(p *Policy, users types.Users, nodes types.Nodes) (*netipx.IPSet, error) {
var build netipx.IPSetBuilder

switch ag {
case AutoGroupInternet:
return util.TheInternet(), nil
}

return nil, nil
case AutoGroupMember:
// autogroup:member represents all untagged devices in the tailnet.
tagMap, err := resolveTagOwners(p, users, nodes)
if err != nil {
return nil, err
}

for _, node := range nodes {
// Skip if node has forced tags
if len(node.ForcedTags) != 0 {
continue
}

// Skip if node has any allowed requested tags
hasAllowedTag := false
if node.Hostinfo != nil && len(node.Hostinfo.RequestTags) != 0 {
for _, tag := range node.Hostinfo.RequestTags {
if tagips, ok := tagMap[Tag(tag)]; ok && node.InIPSet(tagips) {
hasAllowedTag = true
break
}
}
}
if hasAllowedTag {
continue
}

// Node is a member if it has no forced tags and no allowed requested tags
node.AppendToIPSet(&build)
}

return build.IPSet()

case AutoGroupTagged:
// autogroup:tagged represents all devices with a tag in the tailnet.
tagMap, err := resolveTagOwners(p, users, nodes)
if err != nil {
return nil, err
}

for _, node := range nodes {
// Include if node has forced tags
if len(node.ForcedTags) != 0 {
node.AppendToIPSet(&build)
continue
}

// Include if node has any allowed requested tags
if node.Hostinfo != nil && len(node.Hostinfo.RequestTags) != 0 {
for _, tag := range node.Hostinfo.RequestTags {
if _, ok := tagMap[Tag(tag)]; ok {
node.AppendToIPSet(&build)
break
}
}
}
}

return build.IPSet()

default:
return nil, fmt.Errorf("unknown autogroup %q", ag)
}
}

func (ag *AutoGroup) Is(c AutoGroup) bool {
Expand Down Expand Up @@ -949,12 +1017,13 @@ type Policy struct {
}

var (
autogroupForSrc = []AutoGroup{}
autogroupForDst = []AutoGroup{AutoGroupInternet}
autogroupForSSHSrc = []AutoGroup{}
autogroupForSSHDst = []AutoGroup{}
// TODO(kradalby): Add these checks for tagOwners and autoApprovers
autogroupForSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
autogroupForDst = []AutoGroup{AutoGroupInternet, AutoGroupMember, AutoGroupTagged}
autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged}
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
autogroupNotSupported = []AutoGroup{AutoGroupSelf, AutoGroupMember, AutoGroupTagged}
autogroupNotSupported = []AutoGroup{AutoGroupSelf}
)

func validateAutogroupSupported(ag *AutoGroup) error {
Expand Down
133 changes: 131 additions & 2 deletions hscontrol/policy/v2/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ func TestUnmarshalPolicy(t *testing.T) {
],
}
`,
wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet]`,
wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet autogroup:member autogroup:nonroot autogroup:tagged]`,
},
{
name: "undefined-hostname-errors-2490",
Expand Down Expand Up @@ -960,6 +960,135 @@ func TestResolvePolicy(t *testing.T) {
toResolve: Wildcard,
want: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
},
{
name: "autogroup-member-comprehensive",
toResolve: ptr.To(AutoGroup(AutoGroupMember)),
nodes: types.Nodes{
// Node with no tags (should be included)
{
User: users["testuser"],
IPv4: ap("100.100.101.1"),
},
// Node with forced tags (should be excluded)
{
User: users["testuser"],
ForcedTags: []string{"tag:test"},
IPv4: ap("100.100.101.2"),
},
// Node with allowed requested tag (should be excluded)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:test"},
},
IPv4: ap("100.100.101.3"),
},
// Node with non-allowed requested tag (should be included)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:notallowed"},
},
IPv4: ap("100.100.101.4"),
},
// Node with multiple requested tags, one allowed (should be excluded)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:test", "tag:notallowed"},
},
IPv4: ap("100.100.101.5"),
},
// Node with multiple requested tags, none allowed (should be included)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
},
IPv4: ap("100.100.101.6"),
},
},
pol: &Policy{
TagOwners: TagOwners{
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
},
},
want: []netip.Prefix{
mp("100.100.101.1/32"), // No tags
mp("100.100.101.4/32"), // Non-allowed requested tag
mp("100.100.101.6/32"), // Multiple non-allowed requested tags
},
},
{
name: "autogroup-tagged",
toResolve: ptr.To(AutoGroup(AutoGroupTagged)),
nodes: types.Nodes{
// Node with no tags (should be excluded)
{
User: users["testuser"],
IPv4: ap("100.100.101.1"),
},
// Node with forced tag (should be included)
{
User: users["testuser"],
ForcedTags: []string{"tag:test"},
IPv4: ap("100.100.101.2"),
},
// Node with allowed requested tag (should be included)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:test"},
},
IPv4: ap("100.100.101.3"),
},
// Node with non-allowed requested tag (should be excluded)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:notallowed"},
},
IPv4: ap("100.100.101.4"),
},
// Node with multiple requested tags, one allowed (should be included)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:test", "tag:notallowed"},
},
IPv4: ap("100.100.101.5"),
},
// Node with multiple requested tags, none allowed (should be excluded)
{
User: users["testuser"],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
},
IPv4: ap("100.100.101.6"),
},
// Node with multiple forced tags (should be included)
{
User: users["testuser"],
ForcedTags: []string{"tag:test", "tag:other"},
IPv4: ap("100.100.101.7"),
},
},
pol: &Policy{
TagOwners: TagOwners{
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
},
},
want: []netip.Prefix{
mp("100.100.101.2/31"), // Forced tag and allowed requested tag consecutive IPs are put in 31 prefix
mp("100.100.101.5/32"), // Multiple requested tags, one allowed
mp("100.100.101.7/32"), // Multiple forced tags
},
},
{
name: "autogroup-invalid",
toResolve: ptr.To(AutoGroup("autogroup:invalid")),
wantErr: "unknown autogroup",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -1123,7 +1252,7 @@ func TestResolveAutoApprovers(t *testing.T) {
name: "mixed-routes-and-exit-nodes",
policy: &Policy{
Groups: Groups{
"group:testgroup": Usernames{"user1", "user2"},
"group:testgroup": Usernames{"user1@", "user2@"},
},
AutoApprovers: AutoApproverPolicy{
Routes: map[netip.Prefix]AutoApprovers{
Expand Down
Loading
Loading