From 475a5ead0cb4bf3a12d843f75b511798d1049411 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 04:20:36 +0000 Subject: [PATCH] api/v2: add user get and list (Tailscale compat) Backs the tailscale_user and tailscale_users data sources. getUser maps gorm/ErrUserNotFound to 404; unmodelled fields are honest constants, deviceCount/lastSeen/currentlyConnected aggregate the user's nodes. --- hscontrol/api/v2/api.go | 3 + hscontrol/api/v2/errors.go | 1 + hscontrol/api/v2/users.go | 175 +++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 hscontrol/api/v2/users.go diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index 02d9a5c6..a2a8b887 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -244,6 +244,9 @@ const ( ScopeFeatureSettings Scope = "feature_settings" ScopeFeatureSettingsRead Scope = "feature_settings:read" + + ScopeUsers Scope = "users" + ScopeUsersRead Scope = "users:read" ) // scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 78241b3d..7bd25c4f 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -66,6 +66,7 @@ func mapError(msg string, err error) error { switch { case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, db.ErrUserNotFound), errors.Is(err, state.ErrNodeNotFound): return huma.Error404NotFound(msg, err) diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go new file mode 100644 index 00000000..c3d94526 --- /dev/null +++ b/hscontrol/api/v2/users.go @@ -0,0 +1,175 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerUsers) +} + +// Headscale models none of type/role/status and has a single tailnet, so these +// fields are fixed strings. Every account is an active member. +const ( + userTypeMember = "member" + userRoleMember = "member" + userStatusActive = "active" + singleTailnetID = "1" + + // tagTailscaleCompat marks operations ported from the Tailscale API. + tagTailscaleCompat = "Tailscale compat" +) + +// User is the Tailscale user response. Identity fields map from the Headscale +// user; type/role/status/tailnetId are constants (see above); the device fields +// are aggregated from the user's nodes. +type User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LoginName string `json:"loginName"` + ProfilePicURL string `json:"profilePicUrl"` + TailnetID string `json:"tailnetId"` + Created time.Time `json:"created"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + DeviceCount int `json:"deviceCount"` + LastSeen time.Time `json:"lastSeen"` + CurrentlyConnected bool `json:"currentlyConnected"` +} + +type ( + userByIDInput struct { + UserID string `doc:"User id (the decimal user id)." path:"id"` + } + listUsersInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Type string `doc:"Filter by user type; Headscale users are all \"member\"." query:"type"` + Role string `doc:"Filter by user role; Headscale users are all \"member\"." query:"role"` + } + + userOutput struct{ Body User } + listUsersOutput struct { + Body struct { + Users []User `json:"users" nullable:"false"` + } + } +) + +func registerUsers(api huma.API, b Backend) { + usersTags := []string{"Users", tagTailscaleCompat} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getUser", + Method: http.MethodGet, + Path: "/api/v2/users/{id}", + Summary: "Get a user", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + view, err := lookupUser(b, in.UserID) + if err != nil { + return nil, err + } + + return &userOutput{Body: userFromView(b, view)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listUsers", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/users", + Summary: "List users", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + out := &listUsersOutput{} + out.Body.Users = []User{} + + // Headscale has only "member" users. A filter for any other type/role + // matches nothing, so return the empty envelope. + if !matchesMember(in.Type) || !matchesMember(in.Role) { + return out, nil + } + + users, err := b.State.ListAllUsers() + if err != nil { + return nil, huma.Error500InternalServerError("listing users", err) + } + + out.Body.Users = make([]User, 0, len(users)) + for i := range users { + out.Body.Users = append(out.Body.Users, userFromView(b, users[i].View())) + } + + return out, nil + }) +} + +// lookupUser resolves a user id to its UserView, mapping a malformed or unknown +// id to 404 (the Tailscale SDK keys IsNotFound off the status code), exactly as +// lookupNode does for devices. +func lookupUser(b Backend, rawID string) (types.UserView, error) { + id, err := parseID(rawID, "user") + if err != nil { + return types.UserView{}, err + } + + user, err := b.State.GetUserByID(types.UserID(id)) + if err != nil { + return types.UserView{}, mapError("looking up user", err) + } + + return user.View(), nil +} + +// matchesMember reports whether an optional type/role filter selects Headscale's +// only user kind. An empty value means "no filter". +func matchesMember(filter string) bool { + return filter == "" || filter == userTypeMember +} + +// userFromView maps a Headscale user onto the Tailscale User through the +// UserView accessors. deviceCount, lastSeen, and currentlyConnected are +// aggregated from the user's nodes in the NodeStore. +func userFromView(b Backend, view types.UserView) User { + u := User{ + ID: strconv.FormatUint(uint64(view.ID()), 10), + DisplayName: view.Display(), + LoginName: view.Username(), + ProfilePicURL: view.ProfilePicURL(), + TailnetID: singleTailnetID, + Created: view.CreatedAt(), + Type: userTypeMember, + Role: userRoleMember, + Status: userStatusActive, + } + + nodes := b.State.ListNodesByUser(types.UserID(view.ID())) + u.DeviceCount = nodes.Len() + + for _, node := range nodes.All() { + if node.IsOnline().Valid() && node.IsOnline().Get() { + u.CurrentlyConnected = true + } + + if ls := node.LastSeen(); ls.Valid() && ls.Get().After(u.LastSeen) { + u.LastSeen = ls.Get() + } + } + + return u +}