1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-06-10 01:17:20 +02:00

merge autogroups

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby 2025-05-17 11:25:09 +02:00
commit cd3c03a0d3
No known key found for this signature in database
14 changed files with 775 additions and 56 deletions

View File

@ -22,6 +22,8 @@ jobs:
- TestACLNamedHostsCanReach
- TestACLDevice1CanAccessDevice2
- TestPolicyUpdateWhileRunningWithCLIInDatabase
- TestACLAutogroupMember
- TestACLAutogroupTagged
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestAuthKeyLogoutAndReloginSameUserExpiredKey

2
.gitignore vendored
View File

@ -20,9 +20,9 @@ vendor/
dist/
/headscale
config.json
config.yaml
config*.yaml
!config-example.yaml
derp.yaml
*.hujson
*.key

View File

@ -7,6 +7,7 @@ before:
release:
prerelease: auto
draft: true
builds:
- id: headscale

View File

@ -4,6 +4,13 @@
### BREAKING
- Policy: Zero or empty destination port is no longer allowed
[#2606](https://github.com/juanfont/headscale/pull/2606)
## 0.26.0 (2025-05-14)
### BREAKING
#### Routes
Route internals have been rewritten, removing the dedicated route table in the
@ -69,17 +76,17 @@ new policy code passes all of our tests.
<summary>Migration notes when the policy is stored in the database.</summary>
This section **only** applies if the policy is stored in the database and
Headscale 0.26 doesn't start due to a policy error (`failed to load ACL
policy`).
Headscale 0.26 doesn't start due to a policy error
(`failed to load ACL policy`).
* Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1`
- Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1`
set. You can check that Headscale picked up the environment variable by
observing this message during startup: `Using policy manager version: 1`
* Dump the policy to a file: `headscale policy get > policy.json`
* Edit `policy.json` and migrate to policy V2. Use the command
- Dump the policy to a file: `headscale policy get > policy.json`
- Edit `policy.json` and migrate to policy V2. Use the command
`headscale policy check --file policy.json` to check for policy errors.
* Load the modified policy: `headscale policy set --file policy.json`
* Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`.
- Load the modified policy: `headscale policy set --file policy.json`
- Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`.
Headscale should now print the message `Using policy manager version: 2` and
startup successfully.
@ -115,6 +122,11 @@ working in v1 and not tested might be broken in v2 (and vice versa).
[#2542](https://github.com/juanfont/headscale/pull/2542)
- Pre auth key API/CLI now uses ID over username
[#2542](https://github.com/juanfont/headscale/pull/2542)
- A non-empty list of global nameservers needs to be specified via
`dns.nameservers.global` if the configuration option `dns.override_local_dns`
is enabled or is not specified in the configuration file. This aligns with
behaviour of tailscale.com.
[#2438](https://github.com/juanfont/headscale/pull/2438)
### Changes
@ -143,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)

View File

@ -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)

View File

@ -5,7 +5,9 @@
- `/etc/headscale`
- `$HOME/.headscale`
- the current working directory
- Use the command line flag `-c`, `--config` to load the configuration from a different path
- To load the configuration from a different path, use:
- the command line flag `-c`, `--config`
- the environment variable `HEADSCALE_CONFIG`
- Validate the configuration file with: `headscale configtest`
!!! example "Get the [example configuration from the GitHub repository](https://github.com/juanfont/headscale/blob/main/config-example.yaml)"

View File

@ -695,6 +695,29 @@ AND auth_key_id NOT IN (
},
Rollback: func(db *gorm.DB) error { return nil },
},
// Fix the provider identifier for users that have a double slash in the
// provider identifier.
{
ID: "202505141324",
Migrate: func(tx *gorm.DB) error {
users, err := ListUsers(tx)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
for _, user := range users {
user.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String)
err := tx.Save(user).Error
if err != nil {
return fmt.Errorf("saving user: %w", err)
}
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)

View File

@ -3,6 +3,7 @@ package v2
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/netip"
"strings"
@ -42,7 +43,7 @@ func (a AliasWithPorts) MarshalJSON() ([]byte, error) {
if a.Alias == nil {
return []byte(`""`), nil
}
var alias string
switch v := a.Alias.(type) {
case *Username:
@ -62,17 +63,17 @@ func (a AliasWithPorts) MarshalJSON() ([]byte, error) {
default:
return nil, fmt.Errorf("unknown alias type: %T", v)
}
// If no ports are specified
if len(a.Ports) == 0 {
return json.Marshal(alias)
}
// Check if it's the wildcard port range
if len(a.Ports) == 1 && a.Ports[0].First == 0 && a.Ports[0].Last == 65535 {
return json.Marshal(fmt.Sprintf("%s:*", alias))
}
// Otherwise, format as "alias:ports"
var ports []string
for _, port := range a.Ports {
@ -82,7 +83,7 @@ func (a AliasWithPorts) MarshalJSON() ([]byte, error) {
ports = append(ports, fmt.Sprintf("%d-%d", port.First, port.Last))
}
}
return json.Marshal(fmt.Sprintf("%s:%s", alias, strings.Join(ports, ",")))
}
@ -467,15 +468,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) {
@ -498,13 +504,76 @@ func (ag AutoGroup) MarshalJSON() ([]byte, error) {
return json.Marshal(string(ag))
}
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 {
@ -556,6 +625,8 @@ func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
return err
}
ve.Ports = ports
} else {
return errors.New(`hostport must contain a colon (":")`)
}
ve.Alias, err = parseAlias(vs)
@ -667,7 +738,7 @@ func (a Aliases) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte("[]"), nil
}
aliases := make([]string, len(a))
for i, alias := range a {
switch v := alias.(type) {
@ -689,7 +760,7 @@ func (a Aliases) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown alias type: %T", v)
}
}
return json.Marshal(aliases)
}
@ -756,7 +827,7 @@ func (aa AutoApprovers) MarshalJSON() ([]byte, error) {
if aa == nil {
return []byte("[]"), nil
}
approvers := make([]string, len(aa))
for i, approver := range aa {
switch v := approver.(type) {
@ -770,7 +841,7 @@ func (aa AutoApprovers) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown auto approver type: %T", v)
}
}
return json.Marshal(approvers)
}
@ -848,7 +919,7 @@ func (o Owners) MarshalJSON() ([]byte, error) {
if o == nil {
return []byte("[]"), nil
}
owners := make([]string, len(o))
for i, owner := range o {
switch v := owner.(type) {
@ -860,7 +931,7 @@ func (o Owners) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown owner type: %T", v)
}
}
return json.Marshal(owners)
}
@ -966,12 +1037,12 @@ func (h Hosts) MarshalJSON() ([]byte, error) {
if h == nil {
return []byte("{}"), nil
}
rawHosts := make(map[string]string)
for host, prefix := range h {
rawHosts[string(host)] = prefix.String()
}
return json.Marshal(rawHosts)
}
@ -985,12 +1056,12 @@ func (to TagOwners) MarshalJSON() ([]byte, error) {
if to == nil {
return []byte("{}"), nil
}
rawTagOwners := make(map[string][]string)
for tag, owners := range to {
tagStr := string(tag)
ownerStrs := make([]string, len(owners))
for i, owner := range owners {
switch v := owner.(type) {
case *Username:
@ -1001,10 +1072,10 @@ func (to TagOwners) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown owner type: %T", v)
}
}
rawTagOwners[tagStr] = ownerStrs
}
return json.Marshal(rawTagOwners)
}
@ -1071,21 +1142,21 @@ func (ap AutoApproverPolicy) MarshalJSON() ([]byte, error) {
if ap.Routes == nil && ap.ExitNode == nil {
return []byte("{}"), nil
}
type Alias AutoApproverPolicy
// Create a new object to avoid marshalling nil slices as null instead of empty arrays
obj := Alias(ap)
// Initialize empty maps/slices to ensure they're marshalled as empty objects/arrays instead of null
if obj.Routes == nil {
obj.Routes = make(map[netip.Prefix]AutoApprovers)
}
if obj.ExitNode == nil {
obj.ExitNode = AutoApprovers{}
}
return json.Marshal(&obj)
}
@ -1182,12 +1253,13 @@ type Policy struct {
// We use the default JSON marshalling behavior provided by the Go runtime.
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 {
@ -1555,7 +1627,7 @@ func (a SSHDstAliases) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte("[]"), nil
}
aliases := make([]string, len(a))
for i, alias := range a {
switch v := alias.(type) {
@ -1573,7 +1645,7 @@ func (a SSHDstAliases) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown SSH destination alias type: %T", v)
}
}
return json.Marshal(aliases)
}
@ -1582,7 +1654,7 @@ func (a SSHSrcAliases) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte("[]"), nil
}
aliases := make([]string, len(a))
for i, alias := range a {
switch v := alias.(type) {
@ -1600,7 +1672,7 @@ func (a SSHSrcAliases) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("unknown SSH source alias type: %T", v)
}
}
return json.Marshal(aliases)
}

View File

@ -439,7 +439,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",
@ -918,6 +918,44 @@ func TestUnmarshalPolicy(t *testing.T) {
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "missing-dst-port-is-err",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"100.64.0.1"
]
}
]
}
`,
wantErr: `hostport must contain a colon (":")`,
},
{
name: "dst-port-zero-is-err",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"100.64.0.1:0"
]
}
]
}
`,
wantErr: `first port must be >0, or use '*' for wildcard`,
},
}
cmps := append(util.Comparers,
@ -1204,6 +1242,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 {
@ -1367,7 +1534,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{

View File

@ -73,6 +73,10 @@ func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
return nil, err
}
if port < 1 {
return nil, errors.New("first port must be >0, or use '*' for wildcard")
}
portRanges = append(portRanges, tailcfg.PortRange{First: port, Last: port})
}
}

View File

@ -194,13 +194,110 @@ type OIDCClaims struct {
Username string `json:"preferred_username,omitempty"`
}
// Identifier returns a unique identifier string combining the Iss and Sub claims.
// The format depends on whether Iss is a URL or not:
// - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub")
// - For non-URLs: Joins with a slash (e.g., "oidc/sub")
// - For empty Iss: Returns just "sub"
// - For empty Sub: Returns just the Issuer
// - For both empty: Returns empty string
//
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
func (c *OIDCClaims) Identifier() string {
if strings.HasPrefix(c.Iss, "http") {
if i, err := url.JoinPath(c.Iss, c.Sub); err == nil {
return i
// Handle empty components special cases
if c.Iss == "" && c.Sub == "" {
return ""
}
if c.Iss == "" {
return CleanIdentifier(c.Sub)
}
if c.Sub == "" {
return CleanIdentifier(c.Iss)
}
// We'll use the raw values and let CleanIdentifier handle all the whitespace
issuer := c.Iss
subject := c.Sub
var result string
// Try to parse as URL to handle URL joining correctly
if u, err := url.Parse(issuer); err == nil && u.Scheme != "" {
// For URLs, use proper URL path joining
if joined, err := url.JoinPath(issuer, subject); err == nil {
result = joined
}
}
return c.Iss + "/" + c.Sub
// If URL joining failed or issuer wasn't a URL, do simple string join
if result == "" {
// Default case: simple string joining with slash
issuer = strings.TrimSuffix(issuer, "/")
subject = strings.TrimPrefix(subject, "/")
result = issuer + "/" + subject
}
// Clean the result and return it
return CleanIdentifier(result)
}
// CleanIdentifier cleans a potentially malformed identifier by removing double slashes
// while preserving protocol specifications like http://. This function will:
// - Trim all whitespace from the beginning and end of the identifier
// - Remove whitespace within path segments
// - Preserve the scheme (http://, https://, etc.) for URLs
// - Remove any duplicate slashes in the path
// - Remove empty path segments
// - For non-URL identifiers, it joins non-empty segments with a single slash
// - Returns empty string for identifiers with only slashes
// - Normalize URL schemes to lowercase
func CleanIdentifier(identifier string) string {
if identifier == "" {
return identifier
}
// Trim leading/trailing whitespace
identifier = strings.TrimSpace(identifier)
// Handle URLs with schemes
u, err := url.Parse(identifier)
if err == nil && u.Scheme != "" {
// Clean path by removing empty segments and whitespace within segments
parts := strings.FieldsFunc(u.Path, func(c rune) bool { return c == '/' })
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
// Remove empty parts after trimming
cleanParts := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
cleanParts = append(cleanParts, part)
}
}
if len(cleanParts) == 0 {
u.Path = ""
} else {
u.Path = "/" + strings.Join(cleanParts, "/")
}
// Ensure scheme is lowercase
u.Scheme = strings.ToLower(u.Scheme)
return u.String()
}
// Handle non-URL identifiers
parts := strings.FieldsFunc(identifier, func(c rune) bool { return c == '/' })
// Clean whitespace from each part
cleanParts := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
cleanParts = append(cleanParts, trimmed)
}
}
if len(cleanParts) == 0 {
return ""
}
return strings.Join(cleanParts, "/")
}
type OIDCUserInfo struct {
@ -231,7 +328,13 @@ func (u *User) FromClaim(claims *OIDCClaims) {
}
}
u.ProviderIdentifier = sql.NullString{String: claims.Identifier(), Valid: true}
// Get provider identifier
identifier := claims.Identifier()
// Ensure provider identifier always has a leading slash for backward compatibility
if claims.Iss == "" && !strings.HasPrefix(identifier, "/") {
identifier = "/" + identifier
}
u.ProviderIdentifier = sql.NullString{String: identifier, Valid: true}
u.DisplayName = claims.Name
u.ProfilePicURL = claims.ProfilePictureURL
u.Provider = util.RegisterMethodOIDC

View File

@ -7,6 +7,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
)
func TestUnmarshallOIDCClaims(t *testing.T) {
@ -76,6 +77,218 @@ func TestUnmarshallOIDCClaims(t *testing.T) {
}
}
func TestOIDCClaimsIdentifier(t *testing.T) {
tests := []struct {
name string
iss string
sub string
expected string
}{
{
name: "standard URL with trailing slash",
iss: "https://oidc.example.com/",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL without trailing slash",
iss: "https://oidc.example.com",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL with uppercase protocol",
iss: "HTTPS://oidc.example.com/",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL with path and trailing slash",
iss: "https://login.microsoftonline.com/v2.0/",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "standard URL with path without trailing slash",
iss: "https://login.microsoftonline.com/v2.0",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "non-URL identifier with slash",
iss: "oidc",
sub: "sub",
expected: "oidc/sub",
},
{
name: "non-URL identifier with trailing slash",
iss: "oidc/",
sub: "sub",
expected: "oidc/sub",
},
{
name: "subject with slash",
iss: "oidc/",
sub: "sub/",
expected: "oidc/sub",
},
{
name: "whitespace",
iss: " oidc/ ",
sub: " sub ",
expected: "oidc/sub",
},
{
name: "newline",
iss: "\noidc/\n",
sub: "\nsub\n",
expected: "oidc/sub",
},
{
name: "tab",
iss: "\toidc/\t",
sub: "\tsub\t",
expected: "oidc/sub",
},
{
name: "empty issuer",
iss: "",
sub: "sub",
expected: "sub",
},
{
name: "empty subject",
iss: "https://oidc.example.com",
sub: "",
expected: "https://oidc.example.com",
},
{
name: "both empty",
iss: "",
sub: "",
expected: "",
},
{
name: "URL with double slash",
iss: "https://login.microsoftonline.com//v2.0",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "FTP URL protocol",
iss: "ftp://example.com/directory",
sub: "resource",
expected: "ftp://example.com/directory/resource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claims := OIDCClaims{
Iss: tt.iss,
Sub: tt.sub,
}
result := claims.Identifier()
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("Identifier() mismatch (-want +got):\n%s", diff)
}
// Now clean the identifier and verify it's still the same
cleaned := CleanIdentifier(result)
// Double-check with cmp.Diff for better error messages
if diff := cmp.Diff(tt.expected, cleaned); diff != "" {
t.Errorf("CleanIdentifier(Identifier()) mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCleanIdentifier(t *testing.T) {
tests := []struct {
name string
identifier string
expected string
}{
{
name: "empty identifier",
identifier: "",
expected: "",
},
{
name: "simple identifier",
identifier: "oidc/sub",
expected: "oidc/sub",
},
{
name: "double slashes in the middle",
identifier: "oidc//sub",
expected: "oidc/sub",
},
{
name: "trailing slash",
identifier: "oidc/sub/",
expected: "oidc/sub",
},
{
name: "multiple double slashes",
identifier: "oidc//sub///id//",
expected: "oidc/sub/id",
},
{
name: "HTTP URL with proper scheme",
identifier: "http://example.com/path",
expected: "http://example.com/path",
},
{
name: "HTTP URL with double slashes in path",
identifier: "http://example.com//path///resource",
expected: "http://example.com/path/resource",
},
{
name: "HTTPS URL with empty segments",
identifier: "https://example.com///path//",
expected: "https://example.com/path",
},
{
name: "URL with double slashes in domain",
identifier: "https://login.microsoftonline.com//v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "FTP URL with double slashes",
identifier: "ftp://example.com//resource//",
expected: "ftp://example.com/resource",
},
{
name: "Just slashes",
identifier: "///",
expected: "",
},
{
name: "Leading slash without URL",
identifier: "/path//to///resource",
expected: "path/to/resource",
},
{
name: "Non-standard protocol",
identifier: "ldap://example.org//path//to//resource",
expected: "ldap://example.org/path/to/resource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CleanIdentifier(tt.identifier)
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("CleanIdentifier() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestOIDCClaimsJSONToUser(t *testing.T) {
tests := []struct {
name string

View File

@ -15,6 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
)
var veryLargeDestination = []policyv2.AliasWithPorts{
@ -1209,3 +1210,120 @@ func TestPolicyUpdateWhileRunningWithCLIInDatabase(t *testing.T) {
}
}
}
func TestACLAutogroupMember(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
scenario := aclScenario(t,
&policyv2.Policy{
ACLs: []policyv2.ACL{
{
Action: "accept",
Sources: []policyv2.Alias{ptr.To(policyv2.AutoGroupMember)},
Destinations: []policyv2.AliasWithPorts{
aliasWithPorts(ptr.To(policyv2.AutoGroupMember), tailcfg.PortRangeAny),
},
},
},
},
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,
&policyv2.Policy{
ACLs: []policyv2.ACL{
{
Action: "accept",
Sources: []policyv2.Alias{ptr.To(policyv2.AutoGroupTagged)},
Destinations: []policyv2.AliasWithPorts{
aliasWithPorts(ptr.To(policyv2.AutoGroupTagged), tailcfg.PortRangeAny),
},
},
},
},
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)
}
}
}

View File

@ -107,7 +107,7 @@ extra:
- icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx
headscale:
version: 0.25.0
version: 0.26.0
# Extensions
markdown_extensions: