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

226 lines
6.4 KiB
Go

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
}