package users import ( "context" "fmt" "strings" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" ) const ModuleName = "users" type Config struct { // Execute controls whether system commands actually run. Disabled in dev. Execute bool // DefaultShell used when creating users. DefaultShell string // CreateHome creates a home directory for new users. CreateHome bool } type Module struct { cfg Config } func New(cfg Config) *Module { if cfg.DefaultShell == "" { cfg.DefaultShell = "/usr/sbin/nologin" } return &Module{cfg: cfg} } func (m *Module) Name() string { return ModuleName } func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { return database.IsDirty(ModuleName) } func (m *Module) Apply(ctx context.Context, database *db.DB) error { deleted, err := database.ListDeletedUsers() if err != nil { return err } for _, username := range deleted { if err := m.deleteUser(ctx, username); err != nil { return err } if err := database.ClearDeletedUser(username); err != nil { return err } } usersList, err := database.ListUsers() if err != nil { return err } for _, user := range usersList { if err := m.reconcileUser(ctx, database, user); err != nil { return err } } message := fmt.Sprintf("reconciled %d user(s), %d deletion(s)", len(usersList), len(deleted)) if err := database.AppendApplyLog(ModuleName, message, true); err != nil { return err } return database.ClearDirty(ModuleName) } func (m *Module) reconcileUser(ctx context.Context, database *db.DB, user db.User) error { if err := system.ValidateUsername(user.Username); err != nil { return err } exists := m.cfg.Execute && system.UserExists(ctx, user.Username) if !m.cfg.Execute { // In dev mode we do not touch the system; just clear pending secrets // so they are not retained indefinitely. if user.PendingPassword != "" { return database.ClearUserPendingPassword(user.ID) } return nil } groups := sanitizeGroups(user.Groups) if !exists { args := []string{"-s", m.cfg.DefaultShell} if m.cfg.CreateHome { args = append(args, "-m") } else { args = append(args, "-M") } if len(groups) > 0 { args = append(args, "-G", strings.Join(groups, ",")) } args = append(args, user.Username) if err := m.run(ctx, "useradd", args...); err != nil { return err } } else { args := []string{"-G", strings.Join(groups, ",")} args = append(args, user.Username) if err := m.run(ctx, "usermod", args...); err != nil { return err } } if user.Disabled { if err := m.run(ctx, "usermod", "-L", user.Username); err != nil { return err } } else { // Unlock; ignore error when there is nothing to unlock. _ = m.run(ctx, "usermod", "-U", user.Username) } if err := m.applySMB(ctx, database, user); err != nil { return err } return nil } func (m *Module) applySMB(ctx context.Context, database *db.DB, user db.User) error { if user.SMBEnabled { if user.PendingPassword != "" { // smbpasswd -a -s reads the new password twice from stdin. input := user.PendingPassword + "\n" + user.PendingPassword + "\n" stdout, stderr, err := system.RunWithInput(ctx, input, "smbpasswd", "-a", "-s", user.Username) if err != nil { return fmt.Errorf("smbpasswd -a for %s: %w (stdout=%q stderr=%q)", user.Username, err, stdout, stderr) } if err := database.ClearUserPendingPassword(user.ID); err != nil { return err } } // Ensure the smb account is enabled. _ = m.run(ctx, "smbpasswd", "-e", user.Username) } else { // Disable SMB access; ignore error when the account is not present. _ = m.run(ctx, "smbpasswd", "-x", user.Username) } return nil } func (m *Module) deleteUser(ctx context.Context, username string) error { if err := system.ValidateUsername(username); err != nil { return err } if !m.cfg.Execute { return nil } // Remove SMB entry first (ignore if absent), then the system user. _ = m.run(ctx, "smbpasswd", "-x", username) if system.UserExists(ctx, username) { if err := m.run(ctx, "userdel", username); err != nil { return err } } return nil } func (m *Module) run(ctx context.Context, name string, args ...string) error { stdout, stderr, err := system.Run(ctx, name, args...) if err != nil { return fmt.Errorf("%s %s: %w (stdout=%q stderr=%q)", name, strings.Join(args, " "), err, stdout, stderr) } return nil } func sanitizeGroups(groups []string) []string { out := make([]string, 0, len(groups)) for _, g := range groups { g = strings.TrimSpace(g) if g != "" { out = append(out, g) } } return out }