601 lines
16 KiB
Go
601 lines
16 KiB
Go
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
|
|
|
|
package resource
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"gopkg.in/yaml.v3"
|
|
_ "log/slog"
|
|
"net/url"
|
|
_ "os"
|
|
"os/exec"
|
|
"os/user"
|
|
"io"
|
|
"encoding/json"
|
|
"errors"
|
|
"gitea.rosskeen.house/rosskeen.house/machine"
|
|
"strings"
|
|
"decl/internal/codec"
|
|
"decl/internal/command"
|
|
"decl/internal/data"
|
|
"decl/internal/folio"
|
|
)
|
|
|
|
type decodeUser User
|
|
|
|
type UserType string
|
|
|
|
const (
|
|
UserTypeName TypeName = "user"
|
|
UserTypeBusyBox UserType = "busybox"
|
|
UserTypeShadow UserType = "shadow"
|
|
)
|
|
|
|
var ErrUnsupportedUserType error = errors.New("The UserType is not supported on this system")
|
|
var ErrInvalidUserType error = errors.New("invalid UserType value")
|
|
|
|
var SupportedUserTypes []UserType = []UserType{UserTypeShadow, UserTypeBusyBox}
|
|
|
|
var SystemUserType UserType = FindSystemUserType()
|
|
|
|
type User struct {
|
|
*Common `json:",inline" yaml:",inline"`
|
|
stater machine.Stater `json:"-" yaml:"-"`
|
|
Name string `json:"name" yaml:"name"`
|
|
UID string `json:"uid,omitempty" yaml:"uid,omitempty"`
|
|
Group string `json:"group,omitempty" yaml:"group,omitempty"`
|
|
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
|
AppendGroups bool `json:"appendgroups,omitempty" yaml:"appendgroups,omitempty"`
|
|
Gecos string `json:"gecos,omitempty" yaml:"gecos,omitempty"`
|
|
Home string `json:"home" yaml:"home"`
|
|
CreateHome bool `json:"createhome,omitempty" yaml:"createhome,omitempty"`
|
|
Shell string `json:"shell,omitempty" yaml:"shell,omitempty"`
|
|
UserType UserType `json:"type,omitempty" yaml:"type,omitempty"`
|
|
|
|
userStatus *user.User `json:"-" yaml:"-"`
|
|
groupStatus *user.Group `json:"-" yaml:"-"`
|
|
|
|
CreateCommand *command.Command `json:"-" yaml:"-"`
|
|
ReadCommand *command.Command `json:"-" yaml:"-"`
|
|
UpdateCommand *command.Command `json:"-" yaml:"-"`
|
|
DeleteCommand *command.Command `json:"-" yaml:"-"`
|
|
config data.ConfigurationValueGetter
|
|
Resources data.ResourceMapper `json:"-" yaml:"-"`
|
|
}
|
|
|
|
func NewUser() *User {
|
|
u := &User{ CreateHome: true, AppendGroups: true, UserType: SystemUserType }
|
|
u.Common = NewCommon(UserTypeName, false)
|
|
u.Common.NormalizePath = u.NormalizePath
|
|
return u
|
|
}
|
|
|
|
func init() {
|
|
folio.DocumentRegistry.ResourceTypes.Register([]string{"user"}, func(u *url.URL) data.Resource {
|
|
user := NewUser()
|
|
user.Name = u.Hostname()
|
|
user.UID = LookupUIDString(u.Hostname())
|
|
user.CreateCommand, user.ReadCommand, user.UpdateCommand, user.DeleteCommand = user.UserType.NewCRUD()
|
|
return user
|
|
})
|
|
}
|
|
|
|
func FindSystemUserType() UserType {
|
|
for _, userType := range SupportedUserTypes {
|
|
c := userType.NewCreateCommand()
|
|
if c.Exists() {
|
|
return userType
|
|
}
|
|
}
|
|
return UserTypeShadow
|
|
}
|
|
|
|
func (u *User) NormalizePath() error {
|
|
return nil
|
|
}
|
|
|
|
func (u *User) SetResourceMapper(resources data.ResourceMapper) {
|
|
u.Resources = resources
|
|
}
|
|
|
|
func (u *User) Clone() data.Resource {
|
|
newu := &User {
|
|
Common: u.Common,
|
|
Name: u.Name,
|
|
UID: u.UID,
|
|
Group: u.Group,
|
|
Groups: u.Groups,
|
|
Gecos: u.Gecos,
|
|
Home: u.Home,
|
|
CreateHome: u.CreateHome,
|
|
Shell: u.Shell,
|
|
UserType: u.UserType,
|
|
}
|
|
newu.CreateCommand, newu.ReadCommand, newu.UpdateCommand, newu.DeleteCommand = u.UserType.NewCRUD()
|
|
return newu
|
|
}
|
|
|
|
func (u *User) StateMachine() machine.Stater {
|
|
if u.stater == nil {
|
|
u.stater = StorageMachine(u)
|
|
}
|
|
return u.stater
|
|
}
|
|
|
|
func (u *User) Notify(m *machine.EventMessage) {
|
|
ctx := context.Background()
|
|
switch m.On {
|
|
case machine.ENTERSTATEEVENT:
|
|
switch m.Dest {
|
|
case "start_stat":
|
|
if statErr := u.ReadStat(); statErr == nil {
|
|
if triggerErr := u.StateMachine().Trigger("exists"); triggerErr == nil {
|
|
return
|
|
}
|
|
} else {
|
|
if triggerErr := u.StateMachine().Trigger("notexists"); triggerErr == nil {
|
|
return
|
|
}
|
|
}
|
|
case "start_read":
|
|
if _,readErr := u.Read(ctx); readErr == nil {
|
|
if triggerErr := u.StateMachine().Trigger("state_read"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
u.Common.State = "absent"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
u.Common.State = "absent"
|
|
panic(readErr)
|
|
}
|
|
case "start_update":
|
|
if updateErr := u.Update(ctx); updateErr == nil {
|
|
if triggerErr := u.stater.Trigger("updated"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
u.Common.State = "absent"
|
|
}
|
|
} else {
|
|
u.Common.State = "absent"
|
|
panic(updateErr)
|
|
}
|
|
case "start_delete":
|
|
if deleteErr := u.Delete(ctx); deleteErr == nil {
|
|
if triggerErr := u.StateMachine().Trigger("deleted"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
u.Common.State = "present"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
u.Common.State = "present"
|
|
panic(deleteErr)
|
|
}
|
|
case "start_create":
|
|
if e := u.Create(ctx); e == nil {
|
|
if triggerErr := u.stater.Trigger("created"); triggerErr == nil {
|
|
return
|
|
}
|
|
}
|
|
u.Common.State = "absent"
|
|
case "absent":
|
|
u.Common.State = "absent"
|
|
case "present", "created", "read":
|
|
u.Common.State = "present"
|
|
}
|
|
case machine.EXITSTATEEVENT:
|
|
}
|
|
}
|
|
|
|
func (u *User) SetURI(uri string) error {
|
|
resourceUri, e := url.Parse(uri)
|
|
if e == nil {
|
|
if resourceUri.Scheme == "user" {
|
|
u.Name = resourceUri.Hostname()
|
|
} else {
|
|
e = fmt.Errorf("%w: %s is not a user", ErrInvalidResourceURI, uri)
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (u *User) URI() string {
|
|
return fmt.Sprintf("user://%s", u.Name)
|
|
}
|
|
|
|
func (u *User) UseConfig(config data.ConfigurationValueGetter) {
|
|
u.config = config
|
|
}
|
|
|
|
func (u *User) ResolveId(ctx context.Context) string {
|
|
if u.config != nil {
|
|
if configUser, configUserErr := u.config.GetValue("user"); configUserErr == nil && u.Name == "self" {
|
|
u.Name = configUser.(string)
|
|
}
|
|
}
|
|
return LookupUIDString(u.Name)
|
|
}
|
|
|
|
func (u *User) Validate() error {
|
|
return fmt.Errorf("failed")
|
|
}
|
|
|
|
func (u *User) Apply() error {
|
|
ctx := context.Background()
|
|
switch u.Common.State {
|
|
case "present":
|
|
_, NoUserExists := LookupUID(u.Name)
|
|
if NoUserExists != nil {
|
|
cmdErr := u.Create(context.Background())
|
|
return cmdErr
|
|
}
|
|
case "absent":
|
|
cmdErr := u.Delete(ctx)
|
|
return cmdErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *User) Load(docData []byte, f codec.Format) (err error) {
|
|
err = f.StringDecoder(string(docData)).Decode(u)
|
|
return
|
|
}
|
|
|
|
func (u *User) LoadReader(r io.ReadCloser, f codec.Format) (err error) {
|
|
err = f.Decoder(r).Decode(u)
|
|
return
|
|
}
|
|
|
|
func (u *User) LoadString(docData string, f codec.Format) (err error) {
|
|
err = f.StringDecoder(docData).Decode(u)
|
|
return
|
|
}
|
|
|
|
func (u *User) LoadDecl(yamlResourceDeclaration string) error {
|
|
return u.LoadString(yamlResourceDeclaration, codec.FormatYaml)
|
|
}
|
|
|
|
func (u *User) Type() string { return "user" }
|
|
|
|
func (u *User) Create(ctx context.Context) (error) {
|
|
_, err := u.CreateCommand.Execute(u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_,e := u.Read(ctx)
|
|
return e
|
|
}
|
|
|
|
func (u *User) ReadStat() (err error) {
|
|
if u.userStatus == nil {
|
|
if u.userStatus, err = user.Lookup(u.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(u.userStatus.Uid) < 1 {
|
|
return ErrResourceStateAbsent
|
|
}
|
|
u.UID = u.userStatus.Uid
|
|
if len(u.Group) > 1 {
|
|
if u.groupStatus == nil {
|
|
if u.groupStatus, err = user.LookupGroup(u.Group); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(u.groupStatus.Gid) < 1 {
|
|
return ErrResourceStateAbsent
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (u *User) Read(ctx context.Context) ([]byte, error) {
|
|
exErr := u.ReadCommand.Extractor(nil, u)
|
|
if exErr != nil {
|
|
u.Common.State = "absent"
|
|
}
|
|
_ = u.ReadGroups()
|
|
if yamlDoc, yamlErr := yaml.Marshal(u); yamlErr != nil {
|
|
return yamlDoc, yamlErr
|
|
} else {
|
|
return yamlDoc, exErr
|
|
}
|
|
}
|
|
|
|
func (u *User) ReadGroups() (err error) {
|
|
knownSecondaryGroups := make(map[string]bool)
|
|
for _, secondaryGroupName := range u.Groups {
|
|
knownSecondaryGroups[secondaryGroupName] = true
|
|
}
|
|
if u.ReadStat() == nil {
|
|
if groups, groupsErr := u.userStatus.GroupIds(); groupsErr == nil {
|
|
for _, secondaryGroup := range groups {
|
|
if readGroup, groupErr := user.LookupGroupId(secondaryGroup); groupErr == nil {
|
|
if ! knownSecondaryGroups[readGroup.Name] {
|
|
u.Groups = append(u.Groups, readGroup.Name)
|
|
knownSecondaryGroups[readGroup.Name] = true
|
|
}
|
|
} else {
|
|
err = groupErr
|
|
}
|
|
}
|
|
} else {
|
|
err = groupsErr
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (u *User) Update(ctx context.Context) (error) {
|
|
_, err := u.UpdateCommand.Execute(u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_,e := u.Read(ctx)
|
|
return e
|
|
}
|
|
|
|
func (u *User) Delete(ctx context.Context) (error) {
|
|
_, err := u.DeleteCommand.Execute(u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (u *User) UnmarshalJSON(data []byte) error {
|
|
if unmarshalErr := json.Unmarshal(data, (*decodeUser)(u)); unmarshalErr != nil {
|
|
return unmarshalErr
|
|
}
|
|
u.CreateCommand, u.ReadCommand, u.UpdateCommand, u.DeleteCommand = u.UserType.NewCRUD()
|
|
return nil
|
|
}
|
|
|
|
func (u *User) UnmarshalYAML(value *yaml.Node) error {
|
|
if unmarshalErr := value.Decode((*decodeUser)(u)); unmarshalErr != nil {
|
|
return unmarshalErr
|
|
}
|
|
u.CreateCommand, u.ReadCommand, u.UpdateCommand, u.DeleteCommand = u.UserType.NewCRUD()
|
|
return nil
|
|
}
|
|
|
|
func (u *UserType) NewCRUD() (create *command.Command, read *command.Command, update *command.Command, del *command.Command) {
|
|
switch *u {
|
|
case UserTypeShadow:
|
|
return NewUserShadowCreateCommand(), NewUserReadCommand(), NewUserShadowUpdateCommand(), NewUserShadowDeleteCommand()
|
|
case UserTypeBusyBox:
|
|
return NewUserBusyBoxCreateCommand(), NewUserReadCommand(), NewUserBusyBoxUpdateCommand(), NewUserBusyBoxDeleteCommand()
|
|
default:
|
|
if _, addUserPathErr := exec.LookPath("adduser"); addUserPathErr == nil {
|
|
*u = UserTypeBusyBox
|
|
return NewUserBusyBoxCreateCommand(), NewUserReadCommand(), NewUserBusyBoxUpdateCommand(), NewUserBusyBoxDeleteCommand()
|
|
}
|
|
if _, pathErr := exec.LookPath("useradd"); pathErr == nil {
|
|
*u = UserTypeShadow
|
|
return NewUserShadowCreateCommand(), NewUserReadCommand(), NewUserShadowUpdateCommand(), NewUserShadowDeleteCommand()
|
|
}
|
|
return NewUserShadowCreateCommand(), NewUserReadCommand(), NewUserShadowUpdateCommand(), NewUserShadowDeleteCommand()
|
|
}
|
|
}
|
|
|
|
func (u *UserType) NewReadCommand() (read *command.Command) {
|
|
return NewUserReadCommand()
|
|
}
|
|
|
|
func (u *UserType) NewCreateCommand() (create *command.Command) {
|
|
switch *u {
|
|
case UserTypeShadow:
|
|
return NewUserShadowCreateCommand()
|
|
case UserTypeBusyBox:
|
|
return NewUserBusyBoxCreateCommand()
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *UserType) NewReadUsersCommand() (*command.Command) {
|
|
return NewReadUsersCommand()
|
|
}
|
|
|
|
|
|
func (u *UserType) UnmarshalValue(value string) error {
|
|
switch value {
|
|
case string(UserTypeShadow), string(UserTypeBusyBox):
|
|
*u = UserType(value)
|
|
return nil
|
|
default:
|
|
return ErrInvalidUserType
|
|
}
|
|
}
|
|
|
|
func (u *UserType) UnmarshalJSON(data []byte) error {
|
|
var s string
|
|
if unmarshalUserTypeErr := json.Unmarshal(data, &s); unmarshalUserTypeErr != nil {
|
|
return unmarshalUserTypeErr
|
|
}
|
|
return u.UnmarshalValue(s)
|
|
}
|
|
|
|
func (u *UserType) UnmarshalYAML(value *yaml.Node) error {
|
|
var s string
|
|
if err := value.Decode(&s); err != nil {
|
|
return err
|
|
}
|
|
return u.UnmarshalValue(s)
|
|
}
|
|
|
|
func NewReadUsersCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "getent"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("passwd"),
|
|
}
|
|
|
|
c.Extractor = func(out []byte, target any) error {
|
|
Users := target.(*[]*User)
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
lineIndex := 0
|
|
for _, line := range lines {
|
|
userRecord := strings.Split(strings.TrimSpace(line), ":")
|
|
if len(*Users) <= lineIndex + 1 {
|
|
*Users = append(*Users, NewUser())
|
|
}
|
|
u := (*Users)[lineIndex]
|
|
u.Name = userRecord[0]
|
|
u.UID = userRecord[2]
|
|
u.Gecos = userRecord[4]
|
|
u.Home = userRecord[5]
|
|
u.Shell = userRecord[6]
|
|
if readUser, userErr := user.Lookup(u.Name); userErr == nil {
|
|
if groups, groupsErr := readUser.GroupIds(); groupsErr == nil {
|
|
for _, secondaryGroup := range groups {
|
|
if readGroup, groupErr := user.LookupGroupId(secondaryGroup); groupErr == nil {
|
|
u.Groups = append(u.Groups, readGroup.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if readGroup, groupErr := user.LookupGroupId(userRecord[3]); groupErr == nil {
|
|
u.Group = readGroup.Name
|
|
}
|
|
u.Common.State = "present"
|
|
u.UserType = SystemUserType
|
|
lineIndex++
|
|
}
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserShadowCreateCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "useradd"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"),
|
|
command.CommandArg("{{ if .Gecos }}-c {{ .Gecos }}{{ end }}"),
|
|
command.CommandArg("{{ if .Group }}-g {{ .Group }}{{ end }}"),
|
|
command.CommandArg("{{ if .Groups }}-G {{ range .Groups }}{{ . }},{{- end }}{{ end }}"),
|
|
command.CommandArg("{{ if .Home }}-d {{ .Home }}{{ end }}"),
|
|
command.CommandArg("{{ if .CreateHome }}-m{{ else }}-M{{ end }}"),
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserBusyBoxCreateCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "adduser"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"),
|
|
command.CommandArg("{{ if .Gecos }}-g {{ .Gecos }}{{ end }}"),
|
|
command.CommandArg("{{ if .Group }}-G {{ .Group }}{{ end }}"),
|
|
command.CommandArg("{{ if .Home }}-h {{ .Home }}{{ end }}"),
|
|
command.CommandArg("{{ if not .CreateHome }}-H{{ end }}"),
|
|
command.CommandArg("-D"),
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserReadCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Extractor = func(out []byte, target any) error {
|
|
u := target.(*User)
|
|
u.Common.State = "absent"
|
|
var readUser *user.User
|
|
var e error
|
|
if u.Name != "" {
|
|
readUser, e = user.Lookup(u.Name)
|
|
} else {
|
|
if u.UID != "" {
|
|
readUser, e = user.LookupId(u.UID)
|
|
}
|
|
}
|
|
|
|
if e == nil {
|
|
u.Name = readUser.Username
|
|
u.UID = readUser.Uid
|
|
u.Home = readUser.HomeDir
|
|
u.Gecos = readUser.Name
|
|
if readGroup, groupErr := user.LookupGroupId(readUser.Gid); groupErr == nil {
|
|
u.Group = readGroup.Name
|
|
} else {
|
|
return groupErr
|
|
}
|
|
if u.UID != "" {
|
|
u.Common.State = "present"
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserBusyBoxUpdateCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "xargs"
|
|
c.StdinAvailable = true
|
|
c.Input = command.CommandInput("{{ if .Groups }}{{ range .Groups }}{{ . }}\n{{ end }}{{ end }}")
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("adduser"),
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserShadowUpdateCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "usermod"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"),
|
|
command.CommandArg("{{ if .Gecos }}-c {{ .Gecos }}{{ end }}"),
|
|
command.CommandArg("{{ if .Group }}-g {{ .groupStatus.Gid }}{{ end }}"),
|
|
command.CommandArg("{{ if .Home }}-d {{ .Home }}{{ if .CreateHome }} -m{{- end }}{{ end }}"),
|
|
command.CommandArg("{{ if .Groups }}-G {{- range $i, $g := .Groups -}}{{ if $i }}, {{- end }}{{ . }}{{- end }}{{ if .AppendGroups }} -a{{- end }}{{- end }}"),
|
|
command.CommandArg("{{ if .Shell }}-s {{ .Shell }}{{ end }}"),
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserShadowDeleteCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "userdel"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewUserBusyBoxDeleteCommand() *command.Command {
|
|
c := command.NewCommand()
|
|
c.Path = "deluser"
|
|
c.Args = []command.CommandArg{
|
|
command.CommandArg("{{ .Name }}"),
|
|
}
|
|
c.Extractor = func(out []byte, target any) error {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|