Skip to content

Commit 6750414

Browse files
authored
feat: add autogroup:member, autogroup:tagged (#2572)
1 parent b50e10a commit 6750414

File tree

6 files changed

+329
-15
lines changed

6 files changed

+329
-15
lines changed

.github/workflows/test-integration.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
- TestACLNamedHostsCanReach
2323
- TestACLDevice1CanAccessDevice2
2424
- TestPolicyUpdateWhileRunningWithCLIInDatabase
25+
- TestACLAutogroupMember
26+
- TestACLAutogroupTagged
2527
- TestAuthKeyLogoutAndReloginSameUser
2628
- TestAuthKeyLogoutAndReloginNewUser
2729
- TestAuthKeyLogoutAndReloginSameUserExpiredKey

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ working in v1 and not tested might be broken in v2 (and vice versa).
155155
[#2438](https://github.com/juanfont/headscale/pull/2438)
156156
- Add documentation for routes
157157
[#2496](https://github.com/juanfont/headscale/pull/2496)
158+
- Add support for `autogroup:member`, `autogroup:tagged`
159+
[#2572](https://github.com/juanfont/headscale/pull/2572)
158160

159161
## 0.25.1 (2025-02-25)
160162

docs/about/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
2323
- [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
2424
- [x] ACL management via API
2525
- [x] Some [Autogroups](https://tailscale.com/kb/1396/targets#autogroups), currently: `autogroup:internet`,
26-
`autogroup:nonroot`
26+
`autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`
2727
- [x] [Auto approvers](https://tailscale.com/kb/1337/acl-syntax#auto-approvers) for [subnet
2828
routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit
2929
nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)

hscontrol/policy/v2/types.go

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,20 @@ type AutoGroup string
384384

385385
const (
386386
AutoGroupInternet AutoGroup = "autogroup:internet"
387+
AutoGroupMember AutoGroup = "autogroup:member"
387388
AutoGroupNonRoot AutoGroup = "autogroup:nonroot"
389+
AutoGroupTagged AutoGroup = "autogroup:tagged"
388390

389391
// These are not yet implemented.
390-
AutoGroupSelf AutoGroup = "autogroup:self"
391-
AutoGroupMember AutoGroup = "autogroup:member"
392-
AutoGroupTagged AutoGroup = "autogroup:tagged"
392+
AutoGroupSelf AutoGroup = "autogroup:self"
393393
)
394394

395-
var autogroups = []AutoGroup{AutoGroupInternet}
395+
var autogroups = []AutoGroup{
396+
AutoGroupInternet,
397+
AutoGroupMember,
398+
AutoGroupNonRoot,
399+
AutoGroupTagged,
400+
}
396401

397402
func (ag AutoGroup) Validate() error {
398403
if slices.Contains(autogroups, ag) {
@@ -410,13 +415,76 @@ func (ag *AutoGroup) UnmarshalJSON(b []byte) error {
410415
return nil
411416
}
412417

413-
func (ag AutoGroup) Resolve(_ *Policy, _ types.Users, _ types.Nodes) (*netipx.IPSet, error) {
418+
func (ag AutoGroup) Resolve(p *Policy, users types.Users, nodes types.Nodes) (*netipx.IPSet, error) {
419+
var build netipx.IPSetBuilder
420+
414421
switch ag {
415422
case AutoGroupInternet:
416423
return util.TheInternet(), nil
417-
}
418424

419-
return nil, nil
425+
case AutoGroupMember:
426+
// autogroup:member represents all untagged devices in the tailnet.
427+
tagMap, err := resolveTagOwners(p, users, nodes)
428+
if err != nil {
429+
return nil, err
430+
}
431+
432+
for _, node := range nodes {
433+
// Skip if node has forced tags
434+
if len(node.ForcedTags) != 0 {
435+
continue
436+
}
437+
438+
// Skip if node has any allowed requested tags
439+
hasAllowedTag := false
440+
if node.Hostinfo != nil && len(node.Hostinfo.RequestTags) != 0 {
441+
for _, tag := range node.Hostinfo.RequestTags {
442+
if tagips, ok := tagMap[Tag(tag)]; ok && node.InIPSet(tagips) {
443+
hasAllowedTag = true
444+
break
445+
}
446+
}
447+
}
448+
if hasAllowedTag {
449+
continue
450+
}
451+
452+
// Node is a member if it has no forced tags and no allowed requested tags
453+
node.AppendToIPSet(&build)
454+
}
455+
456+
return build.IPSet()
457+
458+
case AutoGroupTagged:
459+
// autogroup:tagged represents all devices with a tag in the tailnet.
460+
tagMap, err := resolveTagOwners(p, users, nodes)
461+
if err != nil {
462+
return nil, err
463+
}
464+
465+
for _, node := range nodes {
466+
// Include if node has forced tags
467+
if len(node.ForcedTags) != 0 {
468+
node.AppendToIPSet(&build)
469+
continue
470+
}
471+
472+
// Include if node has any allowed requested tags
473+
if node.Hostinfo != nil && len(node.Hostinfo.RequestTags) != 0 {
474+
for _, tag := range node.Hostinfo.RequestTags {
475+
if _, ok := tagMap[Tag(tag)]; ok {
476+
node.AppendToIPSet(&build)
477+
break
478+
}
479+
}
480+
}
481+
}
482+
483+
return build.IPSet()
484+
485+
default:
486+
return nil, fmt.Errorf("unknown autogroup %q", ag)
487+
}
420488
}
421489

422490
func (ag *AutoGroup) Is(c AutoGroup) bool {
@@ -952,12 +1020,13 @@ type Policy struct {
9521020
}
9531021

9541022
var (
955-
autogroupForSrc = []AutoGroup{}
956-
autogroupForDst = []AutoGroup{AutoGroupInternet}
957-
autogroupForSSHSrc = []AutoGroup{}
958-
autogroupForSSHDst = []AutoGroup{}
1023+
// TODO(kradalby): Add these checks for tagOwners and autoApprovers
1024+
autogroupForSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
1025+
autogroupForDst = []AutoGroup{AutoGroupInternet, AutoGroupMember, AutoGroupTagged}
1026+
autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
1027+
autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged}
9591028
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
960-
autogroupNotSupported = []AutoGroup{AutoGroupSelf, AutoGroupMember, AutoGroupTagged}
1029+
autogroupNotSupported = []AutoGroup{AutoGroupSelf}
9611030
)
9621031

9631032
func validateAutogroupSupported(ag *AutoGroup) error {

hscontrol/policy/v2/types_test.go

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ func TestUnmarshalPolicy(t *testing.T) {
359359
],
360360
}
361361
`,
362-
wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet]`,
362+
wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet autogroup:member autogroup:nonroot autogroup:tagged]`,
363363
},
364364
{
365365
name: "undefined-hostname-errors-2490",
@@ -998,6 +998,135 @@ func TestResolvePolicy(t *testing.T) {
998998
toResolve: Wildcard,
999999
want: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
10001000
},
1001+
{
1002+
name: "autogroup-member-comprehensive",
1003+
toResolve: ptr.To(AutoGroup(AutoGroupMember)),
1004+
nodes: types.Nodes{
1005+
// Node with no tags (should be included)
1006+
{
1007+
User: users["testuser"],
1008+
IPv4: ap("100.100.101.1"),
1009+
},
1010+
// Node with forced tags (should be excluded)
1011+
{
1012+
User: users["testuser"],
1013+
ForcedTags: []string{"tag:test"},
1014+
IPv4: ap("100.100.101.2"),
1015+
},
1016+
// Node with allowed requested tag (should be excluded)
1017+
{
1018+
User: users["testuser"],
1019+
Hostinfo: &tailcfg.Hostinfo{
1020+
RequestTags: []string{"tag:test"},
1021+
},
1022+
IPv4: ap("100.100.101.3"),
1023+
},
1024+
// Node with non-allowed requested tag (should be included)
1025+
{
1026+
User: users["testuser"],
1027+
Hostinfo: &tailcfg.Hostinfo{
1028+
RequestTags: []string{"tag:notallowed"},
1029+
},
1030+
IPv4: ap("100.100.101.4"),
1031+
},
1032+
// Node with multiple requested tags, one allowed (should be excluded)
1033+
{
1034+
User: users["testuser"],
1035+
Hostinfo: &tailcfg.Hostinfo{
1036+
RequestTags: []string{"tag:test", "tag:notallowed"},
1037+
},
1038+
IPv4: ap("100.100.101.5"),
1039+
},
1040+
// Node with multiple requested tags, none allowed (should be included)
1041+
{
1042+
User: users["testuser"],
1043+
Hostinfo: &tailcfg.Hostinfo{
1044+
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
1045+
},
1046+
IPv4: ap("100.100.101.6"),
1047+
},
1048+
},
1049+
pol: &Policy{
1050+
TagOwners: TagOwners{
1051+
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
1052+
},
1053+
},
1054+
want: []netip.Prefix{
1055+
mp("100.100.101.1/32"), // No tags
1056+
mp("100.100.101.4/32"), // Non-allowed requested tag
1057+
mp("100.100.101.6/32"), // Multiple non-allowed requested tags
1058+
},
1059+
},
1060+
{
1061+
name: "autogroup-tagged",
1062+
toResolve: ptr.To(AutoGroup(AutoGroupTagged)),
1063+
nodes: types.Nodes{
1064+
// Node with no tags (should be excluded)
1065+
{
1066+
User: users["testuser"],
1067+
IPv4: ap("100.100.101.1"),
1068+
},
1069+
// Node with forced tag (should be included)
1070+
{
1071+
User: users["testuser"],
1072+
ForcedTags: []string{"tag:test"},
1073+
IPv4: ap("100.100.101.2"),
1074+
},
1075+
// Node with allowed requested tag (should be included)
1076+
{
1077+
User: users["testuser"],
1078+
Hostinfo: &tailcfg.Hostinfo{
1079+
RequestTags: []string{"tag:test"},
1080+
},
1081+
IPv4: ap("100.100.101.3"),
1082+
},
1083+
// Node with non-allowed requested tag (should be excluded)
1084+
{
1085+
User: users["testuser"],
1086+
Hostinfo: &tailcfg.Hostinfo{
1087+
RequestTags: []string{"tag:notallowed"},
1088+
},
1089+
IPv4: ap("100.100.101.4"),
1090+
},
1091+
// Node with multiple requested tags, one allowed (should be included)
1092+
{
1093+
User: users["testuser"],
1094+
Hostinfo: &tailcfg.Hostinfo{
1095+
RequestTags: []string{"tag:test", "tag:notallowed"},
1096+
},
1097+
IPv4: ap("100.100.101.5"),
1098+
},
1099+
// Node with multiple requested tags, none allowed (should be excluded)
1100+
{
1101+
User: users["testuser"],
1102+
Hostinfo: &tailcfg.Hostinfo{
1103+
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
1104+
},
1105+
IPv4: ap("100.100.101.6"),
1106+
},
1107+
// Node with multiple forced tags (should be included)
1108+
{
1109+
User: users["testuser"],
1110+
ForcedTags: []string{"tag:test", "tag:other"},
1111+
IPv4: ap("100.100.101.7"),
1112+
},
1113+
},
1114+
pol: &Policy{
1115+
TagOwners: TagOwners{
1116+
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
1117+
},
1118+
},
1119+
want: []netip.Prefix{
1120+
mp("100.100.101.2/31"), // Forced tag and allowed requested tag consecutive IPs are put in 31 prefix
1121+
mp("100.100.101.5/32"), // Multiple requested tags, one allowed
1122+
mp("100.100.101.7/32"), // Multiple forced tags
1123+
},
1124+
},
1125+
{
1126+
name: "autogroup-invalid",
1127+
toResolve: ptr.To(AutoGroup("autogroup:invalid")),
1128+
wantErr: "unknown autogroup",
1129+
},
10011130
}
10021131

10031132
for _, tt := range tests {
@@ -1161,7 +1290,7 @@ func TestResolveAutoApprovers(t *testing.T) {
11611290
name: "mixed-routes-and-exit-nodes",
11621291
policy: &Policy{
11631292
Groups: Groups{
1164-
"group:testgroup": Usernames{"user1", "user2"},
1293+
"group:testgroup": Usernames{"user1@", "user2@"},
11651294
},
11661295
AutoApprovers: AutoApproverPolicy{
11671296
Routes: map[netip.Prefix]AutoApprovers{

0 commit comments

Comments
 (0)