Files
nadir-agent/internal/modules/users/users.go
T
2026-06-24 17:29:45 +02:00

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
}