// Copyright 2024 Matthew Rich . 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:"-"` 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) (user data.Resource) { user = NewUser() if u != nil { if err := folio.CastParsedURI(u).ConstructResource(user); err != nil { panic(err) } } return }) } func FindSystemUserType() UserType { for _, userType := range SupportedUserTypes { c := userType.NewCreateCommand() if c.Exists() { return userType } } return UserTypeShadow } func (u *User) Init(uri data.URIParser) error { if uri == nil { uri = folio.URI(u.URI()).Parse() } else { u.Name = uri.URL().Hostname() } u.UID = LookupUIDString(uri.URL().Hostname()) u.CreateCommand, u.ReadCommand, u.UpdateCommand, u.DeleteCommand = u.UserType.NewCRUD() return u.SetParsedURI(uri) } 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) URI() string { return fmt.Sprintf("user://%s", u.Name) } 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) (err error) { switch u.UserType { case UserTypeBusyBox: _, err = u.CreateCommand.Execute(u) if err != nil { return err } } _, err = u.UpdateCommand.Execute(u) if err != nil { return err } _, err = u.Read(ctx) return } 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.Split = false c.Args = []command.CommandArg{ command.CommandArg("{{ if .UID }}-u{{ end }}"), command.CommandArg("{{ if .UID }}{{ .UID }}{{ end }}"), command.CommandArg("{{ if .Gecos }}-g{{ end }}"), command.CommandArg("{{ if .Gecos }}{{ .Gecos }}{{ end }}"), command.CommandArg("{{ if .Group }}-G{{ end }}"), command.CommandArg("{{ if .Group }}{{ .Group }}{{ end }}"), command.CommandArg("{{ if .Home }}-h{{ end }}"), command.CommandArg("{{ if .Home }}{{ .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("-r"), command.CommandArg("-IGROUP"), command.CommandArg("adduser"), command.CommandArg("{{ .Name }}"), command.CommandArg("GROUP"), } 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 }