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
|
- TestACLNamedHostsCanReach
|
||||||
- TestACLDevice1CanAccessDevice2
|
- TestACLDevice1CanAccessDevice2
|
||||||
- TestPolicyUpdateWhileRunningWithCLIInDatabase
|
- TestPolicyUpdateWhileRunningWithCLIInDatabase
|
||||||
|
- TestACLAutogroupMember
|
||||||
|
- TestACLAutogroupTagged
|
||||||
- TestAuthKeyLogoutAndReloginSameUser
|
- TestAuthKeyLogoutAndReloginSameUser
|
||||||
- TestAuthKeyLogoutAndReloginNewUser
|
- TestAuthKeyLogoutAndReloginNewUser
|
||||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
- 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)
|
[#2438](https://github.com/juanfont/headscale/pull/2438)
|
||||||
- Add documentation for routes
|
- Add documentation for routes
|
||||||
[#2496](https://github.com/juanfont/headscale/pull/2496)
|
[#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)
|
## 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] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
|
||||||
- [x] ACL management via API
|
- [x] ACL management via API
|
||||||
- [x] Some [Autogroups](https://tailscale.com/kb/1396/targets#autogroups), currently: `autogroup:internet`,
|
- [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
|
- [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
|
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)
|
nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)
|
||||||
|
@ -384,15 +384,20 @@ type AutoGroup string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
AutoGroupInternet AutoGroup = "autogroup:internet"
|
AutoGroupInternet AutoGroup = "autogroup:internet"
|
||||||
|
AutoGroupMember AutoGroup = "autogroup:member"
|
||||||
AutoGroupNonRoot AutoGroup = "autogroup:nonroot"
|
AutoGroupNonRoot AutoGroup = "autogroup:nonroot"
|
||||||
|
AutoGroupTagged AutoGroup = "autogroup:tagged"
|
||||||
|
|
||||||
// These are not yet implemented.
|
// These are not yet implemented.
|
||||||
AutoGroupSelf AutoGroup = "autogroup:self"
|
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 {
|
func (ag AutoGroup) Validate() error {
|
||||||
if slices.Contains(autogroups, ag) {
|
if slices.Contains(autogroups, ag) {
|
||||||
@ -410,13 +415,76 @@ func (ag *AutoGroup) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
switch ag {
|
||||||
case AutoGroupInternet:
|
case AutoGroupInternet:
|
||||||
return util.TheInternet(), nil
|
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 {
|
func (ag *AutoGroup) Is(c AutoGroup) bool {
|
||||||
@ -952,12 +1020,13 @@ type Policy struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
autogroupForSrc = []AutoGroup{}
|
// TODO(kradalby): Add these checks for tagOwners and autoApprovers
|
||||||
autogroupForDst = []AutoGroup{AutoGroupInternet}
|
autogroupForSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
||||||
autogroupForSSHSrc = []AutoGroup{}
|
autogroupForDst = []AutoGroup{AutoGroupInternet, AutoGroupMember, AutoGroupTagged}
|
||||||
autogroupForSSHDst = []AutoGroup{}
|
autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
||||||
|
autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
||||||
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
|
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
|
||||||
autogroupNotSupported = []AutoGroup{AutoGroupSelf, AutoGroupMember, AutoGroupTagged}
|
autogroupNotSupported = []AutoGroup{AutoGroupSelf}
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateAutogroupSupported(ag *AutoGroup) error {
|
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",
|
name: "undefined-hostname-errors-2490",
|
||||||
@ -998,6 +998,135 @@ func TestResolvePolicy(t *testing.T) {
|
|||||||
toResolve: Wildcard,
|
toResolve: Wildcard,
|
||||||
want: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
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 {
|
for _, tt := range tests {
|
||||||
@ -1161,7 +1290,7 @@ func TestResolveAutoApprovers(t *testing.T) {
|
|||||||
name: "mixed-routes-and-exit-nodes",
|
name: "mixed-routes-and-exit-nodes",
|
||||||
policy: &Policy{
|
policy: &Policy{
|
||||||
Groups: Groups{
|
Groups: Groups{
|
||||||
"group:testgroup": Usernames{"user1", "user2"},
|
"group:testgroup": Usernames{"user1@", "user2@"},
|
||||||
},
|
},
|
||||||
AutoApprovers: AutoApproverPolicy{
|
AutoApprovers: AutoApproverPolicy{
|
||||||
Routes: map[netip.Prefix]AutoApprovers{
|
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