package groups import ( "context" "os" "regexp" "strconv" "strings" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" ) const tagGroups = "Groups" var groupPath = "/etc/group" // systemGIDMax mirrors the user convention: regular groups start at 1000. const systemGIDMax = 1000 var ( readErrors = []int{401, 403, 500} writeErrors = []int{400, 401, 403, 404, 409, 500} ) // groupNameRe matches valid group names (same rule as usernames). Starting with // a letter/underscore also rejects leading-dash flag injection. var groupNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`) // Group mirrors one /etc/group entry. type Group struct { Name string `json:"name" example:"wheel" doc:"Group name"` GID int `json:"gid" example:"10" doc:"Group ID"` Members []string `json:"members" doc:"Supplementary members (primary-group members are not listed here)"` System bool `json:"system" doc:"True for system groups (gid < 1000)"` } type ListGroupsOutput struct { Body struct { Groups []Group `json:"groups" doc:"All groups from /etc/group"` } } type GetGroupOutput struct{ Body Group } type GroupPath struct { Group string `path:"group" example:"wheel" doc:"Group name"` } type CreateGroupInput struct { Body struct { Name string `json:"name" example:"developers" doc:"Group name"` GID *int `json:"gid,omitempty" example:"1500" doc:"Explicit GID (omit to auto-assign)"` System bool `json:"system,omitempty" doc:"Create a system group (groupadd --system)"` } } func registerGroups(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "groups-list", Method: "GET", Path: "/api/groups", Summary: "List groups", Description: "Returns every group in /etc/group, including system groups " + "(flagged via `system`).", Tags: []string{tagGroups}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListGroupsOutput, error) { list, err := listGroups() if err != nil { return nil, huma.Error500InternalServerError("group lookup failed", err) } out := &ListGroupsOutput{} out.Body.Groups = list return out, nil }) huma.Register(api, huma.Operation{ OperationID: "groups-get", Method: "GET", Path: "/api/groups/{group}", Summary: "Get a single group", Description: "Returns one group by name. 404 if it does not exist.", Tags: []string{tagGroups}, Metadata: op("read"), Errors: []int{400, 401, 403, 404, 500}, }, func(ctx context.Context, in *GroupPath) (*GetGroupOutput, error) { if err := validateGroupName(in.Group); err != nil { return nil, err } g, ok, err := lookupGroup(in.Group) if err != nil { return nil, huma.Error500InternalServerError("group lookup failed", err) } if !ok { return nil, huma.Error404NotFound("group not found: " + in.Group) } return &GetGroupOutput{Body: g}, nil }) huma.Register(api, huma.Operation{ OperationID: "groups-create", Method: "POST", Path: "/api/groups", Summary: "Create a group", Description: "Creates a group via groupadd. 409 if the group already exists.", Tags: []string{tagGroups}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *CreateGroupInput) (*GetGroupOutput, error) { if err := validateGroupName(in.Body.Name); err != nil { return nil, err } if _, ok, err := lookupGroup(in.Body.Name); err != nil { return nil, huma.Error500InternalServerError("group lookup failed", err) } else if ok { return nil, huma.Error409Conflict("group already exists: " + in.Body.Name) } args := []string{} if in.Body.System { args = append(args, "--system") } if in.Body.GID != nil { args = append(args, "-g", strconv.Itoa(*in.Body.GID)) } args = append(args, "--", in.Body.Name) if _, err := oscmd.Run("groupadd", args...); err != nil { return nil, huma.Error500InternalServerError("groupadd failed", err) } g, ok, err := lookupGroup(in.Body.Name) if err != nil || !ok { return nil, huma.Error500InternalServerError("group created but could not be read back", err) } return &GetGroupOutput{Body: g}, nil }) huma.Register(api, huma.Operation{ OperationID: "groups-delete", Method: "DELETE", Path: "/api/groups/{group}", Summary: "Delete a group", Description: "Removes a group via groupdel. Returns 409 if it is the primary " + "group of an existing user (groupdel refuses), 404 if it does not exist.", Tags: []string{tagGroups}, // Deleting a group is irreversible - gated behind root, not write. Metadata: op("root"), Errors: []int{400, 401, 403, 404, 409, 500}, }, func(ctx context.Context, in *GroupPath) (*oscmd.StatusOutput, error) { if err := validateGroupName(in.Group); err != nil { return nil, err } if _, ok, err := lookupGroup(in.Group); err != nil { return nil, huma.Error500InternalServerError("group lookup failed", err) } else if !ok { return nil, huma.Error404NotFound("group not found: " + in.Group) } if _, err := oscmd.Run("groupdel", "--", in.Group); err != nil { // groupdel refuses to remove a user's primary group; make that actionable. return nil, huma.Error409Conflict("groupdel failed (is it a user's primary group?)", err) } return oscmd.OK(), nil }) } func validateGroupName(name string) error { if !groupNameRe.MatchString(name) { return huma.Error400BadRequest("invalid group name: " + name) } return nil } func listGroups() ([]Group, error) { data, err := os.ReadFile(groupPath) if err != nil { return nil, err } return parseGroup(data), nil } func lookupGroup(name string) (Group, bool, error) { list, err := listGroups() if err != nil { return Group{}, false, err } for _, g := range list { if g.Name == name { return g, true, nil } } return Group{}, false, nil } // parseGroup parses /etc/group content. Blank, commented, or malformed lines // (fewer than 4 fields, non-numeric gid) are skipped. func parseGroup(data []byte) []Group { var groups []Group for line := range strings.SplitSeq(string(data), "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } f := strings.Split(line, ":") if len(f) < 4 { continue } gid, err := strconv.Atoi(f[2]) if err != nil { continue } var members []string if f[3] != "" { members = strings.Split(f[3], ",") } groups = append(groups, Group{ Name: f[0], GID: gid, Members: members, System: gid < systemGIDMax, }) } return groups }