mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user