Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ryan Malloy 2026-05-21 17:58:02 -06:00
commit 2c8640f822
1496 changed files with 12110903 additions and 24774 deletions

View file

@ -4,6 +4,7 @@ import (
"cmp"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/mail"
"net/url"
@ -12,22 +13,47 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// ErrCannotParseBoolean is returned when a value cannot be parsed as boolean.
var ErrCannotParseBoolean = errors.New("cannot parse value as boolean")
// ErrCannotParseStringSlice is returned when a value cannot be parsed as string or []string.
var ErrCannotParseStringSlice = errors.New("cannot parse value as string or []string")
type UserID uint64
type Users []User
const (
// TaggedDevicesUserID is the special user ID for tagged devices.
// This ID is used when rendering tagged nodes in the Tailscale protocol.
TaggedDevicesUserID = 2147455555
)
// TaggedDevices is a special user used in [tailcfg.MapResponse] for tagged nodes.
// Tagged nodes don't belong to a real user - the tag is their identity.
// This special user ID is used when rendering tagged nodes in the Tailscale protocol.
var TaggedDevices = User{
Model: gorm.Model{ID: TaggedDevicesUserID},
Name: "tagged-devices",
DisplayName: "Tagged Devices",
}
func (u Users) String() string {
var sb strings.Builder
sb.WriteString("[ ")
for _, user := range u {
fmt.Fprintf(&sb, "%d: %s, ", user.ID, user.Name)
}
sb.WriteString(" ]")
return sb.String()
@ -38,29 +64,30 @@ func (u Users) String() string {
// At the end of the day, users in Tailscale are some kind of 'bubbles' or users
// that contain our machines.
type User struct {
gorm.Model
gorm.Model //nolint:embeddedstructfieldcheck
// The index `idx_name_provider_identifier` is to enforce uniqueness
// between Name and ProviderIdentifier. This ensures that
// you can have multiple users with the same name in OIDC,
// but not if you only run with CLI users.
// Name (username) for the user, is used if email is empty
// Should not be used, please use Username().
// It is unique if ProviderIdentifier is not set.
// Should not be used, please use [User.Username].
// It is unique if [User.ProviderIdentifier] is not set.
Name string
// Typically the full name of the user
DisplayName string
// Email of the user
// Should not be used, please use Username().
// Should not be used, please use [User.Username].
Email string
// ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token.
// It is unique if set.
// It is unique together with Name.
// It is unique together with [User.Name].
ProviderIdentifier sql.NullString
// Provider is the origin of the user account,
@ -79,9 +106,17 @@ func (u *User) StringID() string {
if u == nil {
return ""
}
return strconv.FormatUint(uint64(u.ID), 10)
}
// TypedID returns a pointer to the user's ID as a [UserID] type.
// This is a convenience method to avoid ugly casting like ptr.To(types.UserID(user.ID)).
func (u *User) TypedID() *UserID {
uid := UserID(u.ID)
return &uid
}
// Username is the main way to get the username of a user,
// it will return the email if it exists, the name if it exists,
// the OIDCIdentifier if it exists, and the ID if nothing else exists.
@ -98,8 +133,8 @@ func (u *User) Username() string {
)
}
// Display returns the DisplayName if it exists, otherwise
// it will return the Username.
// Display returns the [User.DisplayName] if it exists, otherwise
// it will return the [User.Username].
func (u *User) Display() string {
return cmp.Or(u.DisplayName, u.Username())
}
@ -115,13 +150,13 @@ func (u *User) GetGroups() []string {
if u.Groups == "" {
return []string{}
}
var groups []string
if err := json.Unmarshal([]byte(u.Groups), &groups); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal user groups")
return []string{}
}
return groups
}
@ -131,53 +166,77 @@ func (u *User) SetGroups(groups []string) {
u.Groups = ""
return
}
data, err := json.Marshal(groups)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal user groups")
u.Groups = ""
return
}
u.Groups = string(data)
}
func (u *User) TailscaleUser() *tailcfg.User {
user := tailcfg.User{
ID: tailcfg.UserID(u.ID),
func (u *User) TailscaleUser() tailcfg.User {
return tailcfg.User{
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
Created: u.CreatedAt,
}
return &user
}
func (u *User) TailscaleLogin() *tailcfg.Login {
login := tailcfg.Login{
ID: tailcfg.LoginID(u.ID),
func (u UserView) TailscaleUser() tailcfg.User {
return u.ж.TailscaleUser()
}
// ID returns the user's ID.
// This is a custom accessor because [gorm.Model].ID is embedded
// and the viewer generator doesn't always produce it.
func (u UserView) ID() uint {
return u.ж.ID
}
func (u *User) TailscaleLogin() tailcfg.Login {
return tailcfg.Login{
ID: tailcfg.LoginID(u.ID), //nolint:gosec // safe conversion for user ID
Provider: u.Provider,
LoginName: u.Username(),
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
}
}
return &login
func (u UserView) TailscaleLogin() tailcfg.Login {
return u.ж.TailscaleLogin()
}
func (u *User) TailscaleUserProfile() tailcfg.UserProfile {
return tailcfg.UserProfile{
ID: tailcfg.UserID(u.ID),
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
LoginName: u.Username(),
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
}
}
func (u UserView) TailscaleUserProfile() tailcfg.UserProfile {
return u.ж.TailscaleUserProfile()
}
func (u *User) Proto() *v1.User {
// Use Name if set, otherwise fall back to Username() which provides
// a display-friendly identifier (Email > ProviderIdentifier > ID).
// This ensures OIDC users (who typically have empty Name) display
// their email, while CLI users retain their original Name.
name := u.Name
if name == "" {
name = u.Username()
}
return &v1.User{
Id: uint64(u.ID),
Name: u.Name,
Name: name,
CreatedAt: timestamppb.New(u.CreatedAt),
DisplayName: u.DisplayName,
Email: u.Email,
@ -187,18 +246,67 @@ func (u *User) Proto() *v1.User {
}
}
// JumpCloud returns a JSON where email_verified is returned as a
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
func (u *User) MarshalZerologObject(e *zerolog.Event) {
if u == nil {
return
}
e.Uint(zf.UserID, u.ID)
e.Str(zf.UserName, u.Username())
e.Str(zf.UserDisplay, u.Display())
if u.Provider != "" {
e.Str(zf.UserProvider, u.Provider)
}
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [UserView].
func (u UserView) MarshalZerologObject(e *zerolog.Event) {
if !u.Valid() {
return
}
u.ж.MarshalZerologObject(e)
}
// FlexibleStringSlice handles OIDC providers (e.g. JumpCloud) that return the
// groups claim as a plain string when the user belongs to a single group,
// instead of a single-element array.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
var arr []string
err := json.Unmarshal(data, &arr)
if err == nil {
*f = arr
return nil
}
var single string
err = json.Unmarshal(data, &single)
if err == nil {
*f = []string{single}
return nil
}
return fmt.Errorf("%w: %s", ErrCannotParseStringSlice, string(data))
}
// FlexibleBoolean handles JumpCloud's JSON where email_verified is returned as a
// string "true" or "false" instead of a boolean.
// This maps bool to a specific type with a custom unmarshaler to
// ensure we can decode it from a string.
// https://github.com/juanfont/headscale/issues/2293
type FlexibleBoolean bool
func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
var val any
err := json.Unmarshal(data, &val)
if err != nil {
return fmt.Errorf("could not unmarshal data: %w", err)
return fmt.Errorf("unmarshalling data: %w", err)
}
switch v := val.(type) {
@ -207,12 +315,13 @@ func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
case string:
pv, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("could not parse %s as boolean: %w", v, err)
return fmt.Errorf("parsing %s as boolean: %w", v, err)
}
*bit = FlexibleBoolean(pv)
default:
return fmt.Errorf("could not parse %v as boolean", v)
return fmt.Errorf("%w: %v", ErrCannotParseBoolean, v)
}
return nil
@ -224,31 +333,33 @@ type OIDCClaims struct {
Iss string `json:"iss"`
// Name is the user's full name.
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Groups FlexibleStringSlice `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
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:
// Identifier returns a unique identifier string combining the [OIDCClaims.Iss] and [OIDCClaims.Sub] claims.
// The format depends on whether [OIDCClaims.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 empty [OIDCClaims.Iss]: Returns just "sub"
// - For empty [OIDCClaims.Sub]: Returns just the Issuer
// - For both empty: Returns empty string
//
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
// The result is cleaned using [CleanIdentifier] to ensure consistent formatting.
func (c *OIDCClaims) Identifier() string {
// 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)
}
@ -258,21 +369,14 @@ func (c *OIDCClaims) Identifier() string {
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
}
}
// 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
}
// Always use simple string concatenation with a slash separator.
// url.JoinPath resolves path-traversal segments like ".." and ".",
// which can silently drop the subject and cause identifier collisions
// between distinct OIDC users (e.g., Sub=".." produces the same
// identifier as an empty Sub).
issuer = strings.TrimSuffix(issuer, "/")
subject = strings.TrimPrefix(subject, "/")
result = issuer + "/" + subject
// Clean the result and return it
return CleanIdentifier(result)
@ -333,6 +437,7 @@ func CleanIdentifier(identifier string) string {
cleanParts = append(cleanParts, trimmed)
}
}
if len(cleanParts) == 0 {
return ""
}
@ -341,28 +446,28 @@ func CleanIdentifier(identifier string) string {
}
type OIDCUserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups []string `json:"groups"`
Picture string `json:"picture"`
Sub string `json:"sub"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups FlexibleStringSlice `json:"groups"`
Picture string `json:"picture"`
}
// FromClaim overrides a User from OIDC claims.
// FromClaim overrides a [User] from OIDC claims.
// All fields will be updated, except for the ID.
func (u *User) FromClaim(claims *OIDCClaims) {
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
err := util.ValidateUsername(claims.Username)
if err == nil {
u.Name = claims.Username
} else {
log.Debug().Caller().Err(err).Msgf("Username %s is not valid", claims.Username)
log.Debug().Caller().Err(err).Msgf("username %s is not valid", claims.Username)
}
if claims.EmailVerified {
if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) {
_, err = mail.ParseAddress(claims.Email)
if err == nil {
u.Email = claims.Email
@ -375,6 +480,7 @@ func (u *User) FromClaim(claims *OIDCClaims) {
if claims.Iss == "" && !strings.HasPrefix(identifier, "/") {
identifier = "/" + identifier
}
u.ProviderIdentifier = sql.NullString{String: identifier, Valid: true}
u.DisplayName = claims.Name
u.ProfilePicURL = claims.ProfilePictureURL