mirror of
https://github.com/juanfont/headscale.git
synced 2025-05-18 01:16:48 +02:00
feat: add autogroup:member, autogroup:tagged (#2572)
This commit is contained in:
parent
b50e10a1be
commit
6750414db1
2
.github/workflows/test-integration.yaml
vendored
2
.github/workflows/test-integration.yaml
vendored
@ -22,6 +22,8 @@ jobs:
|
||||
- TestACLNamedHostsCanReach
|
||||
- TestACLDevice1CanAccessDevice2
|
||||
- TestPolicyUpdateWhileRunningWithCLIInDatabase
|
||||
- TestACLAutogroupMember
|
||||
- TestACLAutogroupTagged
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
|
@ -155,6 +155,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`
|
||||
[#2572](https://github.com/juanfont/headscale/pull/2572)
|
||||
|
||||
## 0.25.1 (2025-02-25)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -384,15 +384,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"
|
||||
)
|
||||
|
||||
var autogroups = []AutoGroup{AutoGroupInternet}
|
||||
var autogroups = []AutoGroup{
|
||||
AutoGroupInternet,
|
||||
AutoGroupMember,
|
||||
AutoGroupNonRoot,
|
||||
AutoGroupTagged,
|
||||
}
|
||||
|
||||
func (ag AutoGroup) Validate() error {
|
||||
if slices.Contains(autogroups, ag) {
|
||||
@ -410,13 +415,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
|
||||
|
||||
case AutoGroupMember:
|
||||
// autogroup:member represents all untagged devices in the tailnet.
|
||||
tagMap, err := resolveTagOwners(p, users, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
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 {
|
||||
@ -952,12 +1020,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 {
|
||||
|
@ -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",
|
||||
@ -998,6 +998,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 {
|
||||
@ -1161,7 +1290,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{
|
||||
|
@ -1139,3 +1139,115 @@ func TestPolicyUpdateWhileRunningWithCLIInDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLAutogroupMember(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
scenario := aclScenario(t,
|
||||
&policyv1.ACLPolicy{
|
||||
ACLs: []policyv1.ACL{
|
||||
{
|
||||
Action: "accept",
|
||||
Sources: []string{"autogroup:member"},
|
||||
Destinations: []string{"autogroup:member:*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
2,
|
||||
)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that untagged nodes can access each other
|
||||
for _, client := range allClients {
|
||||
status, err := client.Status()
|
||||
require.NoError(t, err)
|
||||
if status.Self.Tags != nil && status.Self.Tags.Len() > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := peer.Status()
|
||||
require.NoError(t, err)
|
||||
if status.Self.Tags != nil && status.Self.Tags.Len() > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fqdn, err := peer.FQDN()
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("http://%s/etc/hostname", fqdn)
|
||||
t.Logf("url from %s to %s", client.Hostname(), url)
|
||||
|
||||
result, err := client.Curl(url)
|
||||
assert.Len(t, result, 13)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLAutogroupTagged(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
scenario := aclScenario(t,
|
||||
&policyv1.ACLPolicy{
|
||||
ACLs: []policyv1.ACL{
|
||||
{
|
||||
Action: "accept",
|
||||
Sources: []string{"autogroup:tagged"},
|
||||
Destinations: []string{"autogroup:tagged:*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
2,
|
||||
)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that tagged nodes can access each other
|
||||
for _, client := range allClients {
|
||||
status, err := client.Status()
|
||||
require.NoError(t, err)
|
||||
if status.Self.Tags == nil || status.Self.Tags.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := peer.Status()
|
||||
require.NoError(t, err)
|
||||
if status.Self.Tags == nil || status.Self.Tags.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fqdn, err := peer.FQDN()
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("http://%s/etc/hostname", fqdn)
|
||||
t.Logf("url from %s to %s", client.Hostname(), url)
|
||||
|
||||
result, err := client.Curl(url)
|
||||
assert.Len(t, result, 13)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user