mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-11 02:21:24 +09:00
177 lines
5.3 KiB
Go
177 lines
5.3 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/juanfont/headscale/hscontrol/scope"
|
|
"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},
|
|
}, scope.UsersRead), 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},
|
|
}, scope.UsersRead), 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
|
|
}
|