2026-06-22 16:06:57 +02:00
|
|
|
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 {
|
2026-06-24 17:29:45 +02:00
|
|
|
return nil, huma.Error500InternalServerError("group lookup failed", err)
|
2026-06-22 16:06:57 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-06-24 17:29:45 +02:00
|
|
|
return nil, huma.Error500InternalServerError("group lookup failed", err)
|
2026-06-22 16:06:57 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-06-24 17:29:45 +02:00
|
|
|
return nil, huma.Error500InternalServerError("group lookup failed", err)
|
2026-06-22 16:06:57 +02:00
|
|
|
} 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 {
|
2026-06-24 17:29:45 +02:00
|
|
|
return nil, huma.Error500InternalServerError("group lookup failed", err)
|
2026-06-22 16:06:57 +02:00
|
|
|
} 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
|
|
|
|
|
}
|