380 lines
13 KiB
Go
380 lines
13 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"nadir/internal/oscmd"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
)
|
|
|
|
const tagUsers = "Users"
|
|
|
|
var passwdPath = "/etc/passwd"
|
|
|
|
// systemUIDMax is the conventional upper bound for system (non-login) accounts;
|
|
// regular users start at 1000 on Debian/Fedora. Used only to flag accounts for
|
|
// the client, not to filter them out.
|
|
const systemUIDMax = 1000
|
|
|
|
var (
|
|
readErrors = []int{401, 403, 500}
|
|
writeErrors = []int{400, 401, 403, 404, 409, 500}
|
|
)
|
|
|
|
// userNameRe matches valid Linux usernames (the useradd default NAME_REGEX).
|
|
// Starting with a letter/underscore also rejects leading-dash flag injection.
|
|
var userNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`)
|
|
|
|
// User mirrors one /etc/passwd entry.
|
|
type User struct {
|
|
Username string `json:"username" example:"alice" doc:"Login name"`
|
|
UID int `json:"uid" example:"1000" doc:"User ID"`
|
|
GID int `json:"gid" example:"1000" doc:"Primary group ID"`
|
|
Comment string `json:"comment" example:"Alice Smith" doc:"GECOS comment (often the full name)"`
|
|
Home string `json:"home" example:"/home/alice" doc:"Home directory"`
|
|
Shell string `json:"shell" example:"/bin/bash" doc:"Login shell"`
|
|
System bool `json:"system" doc:"True for system accounts (uid < 1000)"`
|
|
}
|
|
|
|
type ListUsersOutput struct {
|
|
Body struct {
|
|
Users []User `json:"users" doc:"All accounts from /etc/passwd"`
|
|
}
|
|
}
|
|
|
|
type GetUserOutput struct{ Body User }
|
|
|
|
type UserPath struct {
|
|
Username string `path:"username" example:"alice" doc:"Login name"`
|
|
}
|
|
|
|
type CreateUserInput struct {
|
|
Body struct {
|
|
Username string `json:"username" example:"alice" doc:"Login name"`
|
|
Comment string `json:"comment,omitempty" example:"Alice Smith" doc:"GECOS comment"`
|
|
Shell string `json:"shell,omitempty" example:"/bin/bash" doc:"Login shell"`
|
|
Home string `json:"home,omitempty" example:"/home/alice" doc:"Home directory (defaults to /home/<username>)"`
|
|
CreateHome bool `json:"create_home,omitempty" doc:"Create the home directory (useradd -m)"`
|
|
System bool `json:"system,omitempty" doc:"Create a system account (useradd --system)"`
|
|
}
|
|
}
|
|
|
|
type DeleteUserInput struct {
|
|
Username string `path:"username" example:"alice" doc:"Login name"`
|
|
RemoveHome bool `query:"remove_home" doc:"Also remove the home directory and mail spool (userdel -r)"`
|
|
}
|
|
|
|
type SetPasswordInput struct {
|
|
Username string `path:"username" example:"alice" doc:"Login name"`
|
|
Body struct {
|
|
Password string `json:"password" doc:"New password (sent to chpasswd over stdin, never argv)"`
|
|
}
|
|
}
|
|
|
|
type SetGroupsInput struct {
|
|
Username string `path:"username" example:"alice" doc:"Login name"`
|
|
Body struct {
|
|
Groups []string `json:"groups" doc:"Supplementary groups; replaces the user's full supplementary set"`
|
|
}
|
|
}
|
|
|
|
type UserGroupsOutput struct {
|
|
Body struct {
|
|
Username string `json:"username" example:"alice"`
|
|
Groups []string `json:"groups" doc:"All groups the user belongs to (primary + supplementary)"`
|
|
}
|
|
}
|
|
|
|
func registerUsers(api huma.API, sessions sessionStore) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-list",
|
|
Method: "GET",
|
|
Path: "/api/users",
|
|
Summary: "List user accounts",
|
|
Description: "Returns every account in /etc/passwd, including system " +
|
|
"accounts (flagged via `system`).",
|
|
Tags: []string{tagUsers},
|
|
Metadata: op("read"),
|
|
Errors: readErrors,
|
|
}, func(ctx context.Context, _ *struct{}) (*ListUsersOutput, error) {
|
|
list, err := listUsers()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
}
|
|
out := &ListUsersOutput{}
|
|
out.Body.Users = list
|
|
return out, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-get",
|
|
Method: "GET",
|
|
Path: "/api/users/{username}",
|
|
Summary: "Get a single user",
|
|
Description: "Returns one account by login name. 404 if it does not exist.",
|
|
Tags: []string{tagUsers},
|
|
Metadata: op("read"),
|
|
Errors: []int{400, 401, 403, 404, 500},
|
|
}, func(ctx context.Context, in *UserPath) (*GetUserOutput, error) {
|
|
if err := validateUsername(in.Username); err != nil {
|
|
return nil, err
|
|
}
|
|
u, ok, err := lookupUser(in.Username)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
}
|
|
if !ok {
|
|
return nil, huma.Error404NotFound("user not found: " + in.Username)
|
|
}
|
|
return &GetUserOutput{Body: u}, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-create",
|
|
Method: "POST",
|
|
Path: "/api/users",
|
|
Summary: "Create a user account",
|
|
Description: "Creates an account via useradd. The new account has a locked " +
|
|
"password until one is set via the password endpoint. 409 if the user " +
|
|
"already exists.",
|
|
Tags: []string{tagUsers},
|
|
Metadata: op("write"),
|
|
Errors: writeErrors,
|
|
}, func(ctx context.Context, in *CreateUserInput) (*GetUserOutput, error) {
|
|
if err := validateUsername(in.Body.Username); err != nil {
|
|
return nil, err
|
|
}
|
|
// -c/-s/-d are option *arguments*, so the "--" separator doesn't shield
|
|
// them. Validate at the boundary: a ':' or newline in the GECOS field
|
|
// would corrupt /etc/passwd; shell/home must be absolute paths.
|
|
if strings.ContainsAny(in.Body.Comment, ":\n") {
|
|
return nil, huma.Error400BadRequest("comment may not contain ':' or newlines")
|
|
}
|
|
if in.Body.Shell != "" && !filepath.IsAbs(in.Body.Shell) {
|
|
return nil, huma.Error400BadRequest("shell must be an absolute path")
|
|
}
|
|
if in.Body.Home != "" && !filepath.IsAbs(in.Body.Home) {
|
|
return nil, huma.Error400BadRequest("home must be an absolute path")
|
|
}
|
|
if _, ok, err := lookupUser(in.Body.Username); err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
} else if ok {
|
|
return nil, huma.Error409Conflict("user already exists: " + in.Body.Username)
|
|
}
|
|
|
|
args := []string{}
|
|
if in.Body.System {
|
|
args = append(args, "--system")
|
|
}
|
|
if in.Body.CreateHome {
|
|
args = append(args, "-m")
|
|
}
|
|
if in.Body.Comment != "" {
|
|
args = append(args, "-c", in.Body.Comment)
|
|
}
|
|
if in.Body.Shell != "" {
|
|
args = append(args, "-s", in.Body.Shell)
|
|
}
|
|
if in.Body.Home != "" {
|
|
args = append(args, "-d", in.Body.Home)
|
|
}
|
|
args = append(args, "--", in.Body.Username)
|
|
if _, err := oscmd.Run("useradd", args...); err != nil {
|
|
return nil, huma.Error500InternalServerError("useradd failed", err)
|
|
}
|
|
|
|
u, ok, err := lookupUser(in.Body.Username)
|
|
if err != nil || !ok {
|
|
return nil, huma.Error500InternalServerError("user created but could not be read back", err)
|
|
}
|
|
return &GetUserOutput{Body: u}, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-delete",
|
|
Method: "DELETE",
|
|
Path: "/api/users/{username}",
|
|
Summary: "Delete a user account",
|
|
Description: "Removes an account via userdel. Pass ?remove_home=true to " +
|
|
"also delete the home directory. 404 if the user does not exist.",
|
|
Tags: []string{tagUsers},
|
|
// Deleting an account is irreversible - gated behind root, not write.
|
|
Metadata: op("root"),
|
|
Errors: []int{400, 401, 403, 404, 500},
|
|
}, func(ctx context.Context, in *DeleteUserInput) (*oscmd.StatusOutput, error) {
|
|
if err := validateUsername(in.Username); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok, err := lookupUser(in.Username); err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
} else if !ok {
|
|
return nil, huma.Error404NotFound("user not found: " + in.Username)
|
|
}
|
|
|
|
args := []string{}
|
|
if in.RemoveHome {
|
|
args = append(args, "-r")
|
|
}
|
|
args = append(args, "--", in.Username)
|
|
if _, err := oscmd.Run("userdel", args...); err != nil {
|
|
return nil, huma.Error500InternalServerError("userdel failed", err)
|
|
}
|
|
return oscmd.OK(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-set-password",
|
|
Method: "POST",
|
|
Path: "/api/users/{username}/password",
|
|
Summary: "Set a user's password",
|
|
Description: "Sets the password via chpasswd (fed over stdin, so the secret " +
|
|
"never appears in the process list). 404 if the user does not exist. " +
|
|
"Requires the `root` permission: resetting a privileged account's " +
|
|
"password (e.g. root) is a full-system action, not a routine write.",
|
|
Tags: []string{tagUsers},
|
|
Metadata: op("root"),
|
|
Errors: []int{400, 401, 403, 404, 500},
|
|
}, func(ctx context.Context, in *SetPasswordInput) (*oscmd.StatusOutput, error) {
|
|
if err := validateUsername(in.Username); err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Body.Password == "" {
|
|
return nil, huma.Error400BadRequest("empty password")
|
|
}
|
|
// chpasswd reads one "name:password" line per stdin line, so a newline in
|
|
// the password would inject a second line and set another account's password.
|
|
if strings.ContainsAny(in.Body.Password, "\n\r") {
|
|
return nil, huma.Error400BadRequest("password may not contain newlines")
|
|
}
|
|
if _, ok, err := lookupUser(in.Username); err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
} else if !ok {
|
|
return nil, huma.Error404NotFound("user not found: " + in.Username)
|
|
}
|
|
// chpasswd reads "name:password" lines from stdin.
|
|
if _, err := oscmd.RunStdin(in.Username+":"+in.Body.Password+"\n", "chpasswd"); err != nil {
|
|
return nil, huma.Error500InternalServerError("chpasswd failed", err)
|
|
}
|
|
if sessions != nil {
|
|
if err := sessions.DeleteByUsername(in.Username); err != nil {
|
|
log.Printf("failed to invalidate sessions for %s: %v", in.Username, err)
|
|
}
|
|
}
|
|
return oscmd.OK(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "users-set-groups",
|
|
Method: "PUT",
|
|
Path: "/api/users/{username}/groups",
|
|
Summary: "Set a user's supplementary groups",
|
|
Description: "Replaces the user's full supplementary group set via " +
|
|
"`usermod -G` (an empty list removes them from all supplementary " +
|
|
"groups). Returns the resulting group membership. 404 if the user " +
|
|
"does not exist; 400 if any named group is missing. Requires the " +
|
|
"`root` permission: adding an account to wheel/sudo/docker is a " +
|
|
"privilege grant, not a routine write.",
|
|
Tags: []string{tagUsers},
|
|
Metadata: op("root"),
|
|
Errors: writeErrors,
|
|
}, func(ctx context.Context, in *SetGroupsInput) (*UserGroupsOutput, error) {
|
|
if err := validateUsername(in.Username); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, g := range in.Body.Groups {
|
|
// Group names follow the same rule as usernames; this also blocks
|
|
// flag injection and a stray comma turning one group into two.
|
|
if !userNameRe.MatchString(g) {
|
|
return nil, huma.Error400BadRequest("invalid group name: " + g)
|
|
}
|
|
}
|
|
if _, ok, err := lookupUser(in.Username); err != nil {
|
|
return nil, huma.Error500InternalServerError("user lookup failed", err)
|
|
} else if !ok {
|
|
return nil, huma.Error404NotFound("user not found: " + in.Username)
|
|
}
|
|
if _, err := oscmd.Run("usermod", "-G", strings.Join(in.Body.Groups, ","), "--", in.Username); err != nil {
|
|
return nil, huma.Error400BadRequest("usermod failed (does every named group exist?)", err)
|
|
}
|
|
|
|
// id -nG lists all groups (primary + supplementary) the user now has.
|
|
out, err := oscmd.Run("id", "-nG", in.Username)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("groups set but read-back failed", err)
|
|
}
|
|
res := &UserGroupsOutput{}
|
|
res.Body.Username = in.Username
|
|
res.Body.Groups = strings.Fields(out)
|
|
return res, nil
|
|
})
|
|
}
|
|
|
|
// validateUsername rejects empty, flag-like, or malformed names before exec.
|
|
func validateUsername(name string) error {
|
|
if !userNameRe.MatchString(name) {
|
|
return huma.Error400BadRequest("invalid username: " + name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listUsers() ([]User, error) {
|
|
data, err := os.ReadFile(passwdPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parsePasswd(data), nil
|
|
}
|
|
|
|
func lookupUser(name string) (User, bool, error) {
|
|
list, err := listUsers()
|
|
if err != nil {
|
|
return User{}, false, err
|
|
}
|
|
for _, u := range list {
|
|
if u.Username == name {
|
|
return u, true, nil
|
|
}
|
|
}
|
|
return User{}, false, nil
|
|
}
|
|
|
|
// parsePasswd parses /etc/passwd content. Lines that are blank, commented, or
|
|
// malformed (fewer than 7 fields, non-numeric ids) are skipped.
|
|
func parsePasswd(data []byte) []User {
|
|
var users []User
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
f := strings.Split(line, ":")
|
|
if len(f) < 7 {
|
|
continue
|
|
}
|
|
uid, err := strconv.Atoi(f[2])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
gid, err := strconv.Atoi(f[3])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
users = append(users, User{
|
|
Username: f[0],
|
|
UID: uid,
|
|
GID: gid,
|
|
Comment: f[4],
|
|
Home: f[5],
|
|
Shell: f[6],
|
|
System: uid < systemUIDMax,
|
|
})
|
|
}
|
|
return users
|
|
}
|