diff --git a/backend/internal/api/controller/command.go b/backend/internal/api/controller/command.go index 555d315..1edfd5a 100644 --- a/backend/internal/api/controller/command.go +++ b/backend/internal/api/controller/command.go @@ -82,13 +82,15 @@ func (c *Controller) UpdateCommand(ctx *gin.Context) { // GetCommands godoc // // @Tags command -// @Param page_index query int true "command id" -// @Param page_size query int true "command id" +// @Param page_index query int true "page index" +// @Param page_size query int true "page size" // @Param search query string false "name or cmd" // @Param id query int false "command id" // @Param ids query string false "command ids" // @Param name query string false "command name" // @Param enable query int false "command enable" +// @Param category query string false "command category" +// @Param risk_level query int false "command risk level" // @Param info query bool false "is info mode" // @Param search query string false "name or cmd" // @Success 200 {object} HttpResponse{data=ListData{list=[]model.Command}} diff --git a/backend/internal/api/controller/command_template.go b/backend/internal/api/controller/command_template.go new file mode 100644 index 0000000..76071ba --- /dev/null +++ b/backend/internal/api/controller/command_template.go @@ -0,0 +1,151 @@ +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/spf13/cast" + + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/internal/service" + "github.com/veops/oneterm/pkg/config" + pkgErrors "github.com/veops/oneterm/pkg/errors" +) + +var ( + commandTemplateService = service.NewCommandTemplateService() +) + +// CreateCommandTemplate godoc +// +// @Tags command_template +// @Param template body model.CommandTemplate true "command template" +// @Success 200 {object} HttpResponse +// @Router /command_template [post] +func (c *Controller) CreateCommandTemplate(ctx *gin.Context) { + template := &model.CommandTemplate{} + doCreate(ctx, true, template, config.RESOURCE_AUTHORIZATION, commandTemplatePreHooks...) +} + +// DeleteCommandTemplate godoc +// +// @Tags command_template +// @Param id path int true "template id" +// @Success 200 {object} HttpResponse +// @Router /command_template/:id [delete] +func (c *Controller) DeleteCommandTemplate(ctx *gin.Context) { + doDelete(ctx, true, &model.CommandTemplate{}, config.RESOURCE_AUTHORIZATION, commandTemplateDeleteChecks...) +} + +// UpdateCommandTemplate godoc +// +// @Tags command_template +// @Param id path int true "template id" +// @Param template body model.CommandTemplate true "command template" +// @Success 200 {object} HttpResponse +// @Router /command_template/:id [put] +func (c *Controller) UpdateCommandTemplate(ctx *gin.Context) { + template := &model.CommandTemplate{} + doUpdate(ctx, true, template, config.RESOURCE_AUTHORIZATION, commandTemplatePreHooks...) +} + +// GetCommandTemplates godoc +// +// @Tags command_template +// @Param page_index query int false "page index" +// @Param page_size query int false "page size" +// @Param category query string false "template category" +// @Param builtin query bool false "filter by builtin status" +// @Param info query bool false "info mode" +// @Success 200 {object} HttpResponse{data=[]model.CommandTemplate} +// @Router /command_template [get] +func (c *Controller) GetCommandTemplates(ctx *gin.Context) { + info := cast.ToBool(ctx.Query("info")) + + // Build base query using service layer + db, err := commandTemplateService.BuildQuery(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}}) + return + } + + // Apply filters + if category := ctx.Query("category"); category != "" { + db = db.Where("category = ?", category) + } + + if builtinStr := ctx.Query("builtin"); builtinStr != "" { + builtin := cast.ToBool(builtinStr) + db = db.Where("is_builtin = ?", builtin) + } + + doGet(ctx, !info, db, config.RESOURCE_AUTHORIZATION, commandTemplatePostHooks...) +} + +// GetBuiltInCommandTemplates godoc +// +// @Tags command_template +// @Success 200 {object} HttpResponse{data=[]model.CommandTemplate} +// @Router /command_template/builtin [get] +func (c *Controller) GetBuiltInCommandTemplates(ctx *gin.Context) { + templates, err := commandTemplateService.GetBuiltInTemplates(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}}) + return + } + + ctx.JSON(http.StatusOK, NewHttpResponseWithData(templates)) +} + +// GetTemplateCommands godoc +// +// @Tags command_template +// @Param id path int true "template id" +// @Success 200 {object} HttpResponse{data=[]model.Command} +// @Router /command_template/:id/commands [get] +func (c *Controller) GetTemplateCommands(ctx *gin.Context) { + id := cast.ToInt(ctx.Param("id")) + if id == 0 { + ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument, Data: map[string]any{"err": "invalid template id"}}) + return + } + + commands, err := commandTemplateService.GetTemplateCommands(ctx, id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}}) + return + } + + ctx.JSON(http.StatusOK, NewHttpResponseWithData(commands)) +} + +// Command template hooks +var ( + commandTemplatePreHooks = []preHook[*model.CommandTemplate]{ + func(ctx *gin.Context, data *model.CommandTemplate) { + if err := commandTemplateService.ValidateCommandTemplate(data); err != nil { + ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument, Data: map[string]any{"err": err.Error()}}) + } + }, + } + + commandTemplatePostHooks = []postHook[*model.CommandTemplate]{ + func(ctx *gin.Context, data []*model.CommandTemplate) { + // Add any post-processing logic here + }, + } + + commandTemplateDeleteChecks = []deleteCheck{ + func(ctx *gin.Context, id int) { + // Check if template is built-in + template, err := commandTemplateService.GetCommandTemplate(ctx, id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}}) + return + } + if template != nil && template.IsBuiltin { + ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument, Data: map[string]any{"err": "cannot delete built-in command template"}}) + } + }, + } +) diff --git a/backend/internal/model/command.go b/backend/internal/model/command.go index f20b9d9..d6b11cb 100644 --- a/backend/internal/model/command.go +++ b/backend/internal/model/command.go @@ -7,6 +7,29 @@ import ( "gorm.io/plugin/soft_delete" ) +// CommandCategory defines command categories +type CommandCategory string + +const ( + CategorySecurity CommandCategory = "security" // Security related + CategorySystem CommandCategory = "system" // System operations + CategoryDatabase CommandCategory = "database" // Database operations + CategoryNetwork CommandCategory = "network" // Network operations + CategoryFile CommandCategory = "file" // File operations + CategoryDeveloper CommandCategory = "developer" // Development related + CategoryCustom CommandCategory = "custom" // Custom commands +) + +// CommandRiskLevel defines risk levels +type CommandRiskLevel int + +const ( + RiskLevelSafe CommandRiskLevel = 0 // Safe commands + RiskLevelWarning CommandRiskLevel = 1 // Warning level + RiskLevelDanger CommandRiskLevel = 2 // Dangerous commands + RiskLevelCritical CommandRiskLevel = 3 // Critical danger +) + type Command struct { Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"` Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128"` @@ -15,6 +38,13 @@ type Command struct { Enable bool `json:"enable" gorm:"column:enable"` Re *regexp.Regexp `json:"-" gorm:"-"` + // Enhanced fields for better management + Category CommandCategory `json:"category" gorm:"column:category;default:'custom'"` + RiskLevel CommandRiskLevel `json:"risk_level" gorm:"column:risk_level;default:0"` + Description string `json:"description" gorm:"column:description"` + Tags Slice[string] `json:"tags" gorm:"column:tags;type:json"` + IsGlobal bool `json:"is_global" gorm:"column:is_global;default:false"` // Global predefined command + Permissions []string `json:"permissions" gorm:"-"` ResourceId int `json:"resource_id" gorm:"column:resource_id"` CreatorId int `json:"creator_id" gorm:"column:creator_id"` @@ -52,3 +82,70 @@ func (m *Command) GetId() int { func (m *Command) SetPerms(perms []string) { m.Permissions = perms } + +// GetRiskLevelText returns human readable risk level +func (m *Command) GetRiskLevelText() string { + switch m.RiskLevel { + case RiskLevelSafe: + return "Safe" + case RiskLevelWarning: + return "Warning" + case RiskLevelDanger: + return "Danger" + case RiskLevelCritical: + return "Critical" + default: + return "Unknown" + } +} + +// CommandTemplate represents predefined command templates +type CommandTemplate struct { + Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"` + Name string `json:"name" gorm:"column:name;size:128"` + Description string `json:"description" gorm:"column:description"` + Category CommandCategory `json:"category" gorm:"column:category"` + CmdIds Slice[int] `json:"cmd_ids" gorm:"column:cmd_ids;type:json"` + IsBuiltin bool `json:"is_builtin" gorm:"column:is_builtin;default:false"` // Built-in template + ResourceId int `json:"resource_id" gorm:"column:resource_id"` + CreatorId int `json:"creator_id" gorm:"column:creator_id"` + UpdaterId int `json:"updater_id" gorm:"column:updater_id"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` +} + +func (m *CommandTemplate) TableName() string { + return "command_template" +} + +func (m *CommandTemplate) GetName() string { + return m.Name +} + +func (m *CommandTemplate) GetId() int { + return m.Id +} + +func (m *CommandTemplate) SetId(id int) { + m.Id = id +} + +func (m *CommandTemplate) SetCreatorId(creatorId int) { + m.CreatorId = creatorId +} + +func (m *CommandTemplate) SetUpdaterId(updaterId int) { + m.UpdaterId = updaterId +} + +func (m *CommandTemplate) SetResourceId(resourceId int) { + m.ResourceId = resourceId +} + +func (m *CommandTemplate) GetResourceId() int { + return m.ResourceId +} + +func (m *CommandTemplate) SetPerms(perms []string) { + // CommandTemplate doesn't have permissions field, but interface requires it +} diff --git a/backend/internal/repository/command_template.go b/backend/internal/repository/command_template.go new file mode 100644 index 0000000..76e627d --- /dev/null +++ b/backend/internal/repository/command_template.go @@ -0,0 +1,123 @@ +package repository + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/veops/oneterm/internal/model" +) + +// ICommandTemplateRepository defines the interface for command template data access +type ICommandTemplateRepository interface { + Create(ctx context.Context, template *model.CommandTemplate) error + GetByID(ctx context.Context, id int) (*model.CommandTemplate, error) + GetByName(ctx context.Context, name string) (*model.CommandTemplate, error) + List(ctx context.Context, offset, limit int, category string, builtin *bool) ([]*model.CommandTemplate, int64, error) + Update(ctx context.Context, template *model.CommandTemplate) error + Delete(ctx context.Context, id int) error + GetBuiltInTemplates(ctx context.Context) ([]*model.CommandTemplate, error) +} + +// CommandTemplateRepository implements ICommandTemplateRepository +type CommandTemplateRepository struct { + db *gorm.DB +} + +// NewCommandTemplateRepository creates a new command template repository +func NewCommandTemplateRepository(db *gorm.DB) ICommandTemplateRepository { + return &CommandTemplateRepository{db: db} +} + +// Create creates a new command template +func (r *CommandTemplateRepository) Create(ctx context.Context, template *model.CommandTemplate) error { + return r.db.WithContext(ctx).Create(template).Error +} + +// GetByID retrieves a command template by ID +func (r *CommandTemplateRepository) GetByID(ctx context.Context, id int) (*model.CommandTemplate, error) { + var template model.CommandTemplate + err := r.db.WithContext(ctx).Where("id = ?", id).First(&template).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &template, nil +} + +// GetByName retrieves a command template by name +func (r *CommandTemplateRepository) GetByName(ctx context.Context, name string) (*model.CommandTemplate, error) { + var template model.CommandTemplate + err := r.db.WithContext(ctx).Where("name = ?", name).First(&template).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &template, nil +} + +// List retrieves command templates with pagination and filters +func (r *CommandTemplateRepository) List(ctx context.Context, offset, limit int, category string, builtin *bool) ([]*model.CommandTemplate, int64, error) { + query := r.db.WithContext(ctx).Model(&model.CommandTemplate{}) + + // Apply filters + if category != "" { + query = query.Where("category = ?", category) + } + if builtin != nil { + query = query.Where("is_builtin = ?", *builtin) + } + + // Get total count + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Apply pagination and ordering + var templates []*model.CommandTemplate + err := query.Order("is_builtin DESC, created_at DESC"). + Offset(offset). + Limit(limit). + Find(&templates).Error + + return templates, total, err +} + +// Update updates an existing command template +func (r *CommandTemplateRepository) Update(ctx context.Context, template *model.CommandTemplate) error { + // Don't allow updating built-in templates + if template.IsBuiltin { + return errors.New("cannot update built-in command template") + } + return r.db.WithContext(ctx).Save(template).Error +} + +// Delete soft deletes a command template +func (r *CommandTemplateRepository) Delete(ctx context.Context, id int) error { + // Check if it's a built-in template + var template model.CommandTemplate + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&template).Error; err != nil { + return err + } + + if template.IsBuiltin { + return errors.New("cannot delete built-in command template") + } + + return r.db.WithContext(ctx).Delete(&model.CommandTemplate{}, id).Error +} + +// GetBuiltInTemplates retrieves all built-in command templates +func (r *CommandTemplateRepository) GetBuiltInTemplates(ctx context.Context) ([]*model.CommandTemplate, error) { + var templates []*model.CommandTemplate + err := r.db.WithContext(ctx).Where("is_builtin = ?", true). + Order("category, id"). + Find(&templates).Error + return templates, err +} diff --git a/backend/internal/service/command.go b/backend/internal/service/command.go index bd2584f..59067fa 100644 --- a/backend/internal/service/command.go +++ b/backend/internal/service/command.go @@ -55,6 +55,17 @@ func (s *CommandService) BuildQuery(ctx *gin.Context) (*gorm.DB, error) { db = db.Where("id IN ?", lo.Map(strings.Split(q, ","), func(s string, _ int) int { return cast.ToInt(s) })) } + // Apply category filter + if category := ctx.Query("category"); category != "" { + db = db.Where("category = ?", category) + } + + // Apply risk level filter + if riskLevelStr := ctx.Query("risk_level"); riskLevelStr != "" { + riskLevel := cast.ToInt(riskLevelStr) + db = db.Where("risk_level = ?", riskLevel) + } + return db, nil } diff --git a/backend/internal/service/command_init.go b/backend/internal/service/command_init.go new file mode 100644 index 0000000..b47a700 --- /dev/null +++ b/backend/internal/service/command_init.go @@ -0,0 +1,377 @@ +package service + +import ( + "context" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/samber/lo" + + myi18n "github.com/veops/oneterm/internal/i18n" + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/pkg/db" + "github.com/veops/oneterm/pkg/logger" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// CommandDefinition defines a command with i18n message keys +type CommandDefinition struct { + NameKey *i18n.Message + DescriptionKey *i18n.Message + Cmd string + IsRe bool + RiskLevel model.CommandRiskLevel + Category model.CommandCategory + TagKeys []*i18n.Message +} + +// TemplateDefinition defines a template with i18n message keys +type TemplateDefinition struct { + NameKey *i18n.Message + DescriptionKey *i18n.Message + Category model.CommandCategory + CommandRefs []string // References to command names by i18n key IDs +} + +// getLocalizer creates a localizer for the given context (defaults to English) +func getLocalizer(ctx context.Context) *i18n.Localizer { + // Default to English, as this runs during system startup without user context + return i18n.NewLocalizer(myi18n.Bundle, "en") +} + +// localizeMessage localizes a message using the given localizer +func localizeMessage(localizer *i18n.Localizer, msg *i18n.Message) string { + result, _ := localizer.Localize(&i18n.LocalizeConfig{DefaultMessage: msg}) + return result +} + +// localizeTags localizes an array of tag message keys +func localizeTags(localizer *i18n.Localizer, tagKeys []*i18n.Message) []string { + return lo.Map(tagKeys, func(msg *i18n.Message, _ int) string { + return localizeMessage(localizer, msg) + }) +} + +// InitBuiltinCommands initializes predefined dangerous commands on system startup +func InitBuiltinCommands() error { + ctx := context.Background() + localizer := getLocalizer(ctx) + + logger.L().Info("Starting initialization of predefined dangerous commands") + + // Initialize commands + commands := getPreDefinedCommands() + for _, cmdDef := range commands { + if err := createCommandIfNotExists(localizer, cmdDef); err != nil { + logger.L().Error("Failed to create predefined command", + zap.String("name_key", cmdDef.NameKey.ID), + zap.Error(err)) + continue + } + } + + // Initialize templates + templates := getPreDefinedTemplates() + for _, tmplDef := range templates { + if err := createTemplateIfNotExists(localizer, tmplDef); err != nil { + logger.L().Error("Failed to create predefined template", + zap.String("name_key", tmplDef.NameKey.ID), + zap.Error(err)) + continue + } + } + + logger.L().Info("Predefined dangerous commands initialization completed successfully") + return nil +} + +// createCommandIfNotExists creates a command if it doesn't exist +func createCommandIfNotExists(localizer *i18n.Localizer, cmdDef CommandDefinition) error { + name := localizeMessage(localizer, cmdDef.NameKey) + + var existingCmd model.Command + err := db.DB.Where("name = ? AND is_global = ?", name, true).First(&existingCmd).Error + if err == nil { + logger.L().Debug("Predefined command already exists, skipping", zap.String("name", name)) + return nil + } + + if err != gorm.ErrRecordNotFound { + return err + } + + // Create new command + newCmd := &model.Command{ + Name: name, + Cmd: cmdDef.Cmd, + IsRe: cmdDef.IsRe, + Enable: true, + Category: cmdDef.Category, + RiskLevel: cmdDef.RiskLevel, + Description: localizeMessage(localizer, cmdDef.DescriptionKey), + Tags: localizeTags(localizer, cmdDef.TagKeys), + IsGlobal: true, + CreatorId: 1, // System user + UpdaterId: 1, + } + + if err := db.DB.Create(newCmd).Error; err != nil { + return err + } + + logger.L().Info("Successfully created predefined command", + zap.String("name", name), + zap.Int("risk_level", int(cmdDef.RiskLevel))) + + return nil +} + +// createTemplateIfNotExists creates a template if it doesn't exist +func createTemplateIfNotExists(localizer *i18n.Localizer, tmplDef TemplateDefinition) error { + name := localizeMessage(localizer, tmplDef.NameKey) + + var existingTemplate model.CommandTemplate + err := db.DB.Where("name = ? AND is_builtin = ?", name, true).First(&existingTemplate).Error + if err == nil { + logger.L().Debug("Predefined template already exists, skipping", zap.String("name", name)) + return nil + } + + if err != gorm.ErrRecordNotFound { + return err + } + + // Get command IDs by localized names + cmdIds, err := getCommandIdsByLocalizedNames(localizer, tmplDef.CommandRefs) + if err != nil { + logger.L().Warn("Some commands not found for template", + zap.String("template", name), + zap.Error(err)) + } + + // Create new template + newTemplate := &model.CommandTemplate{ + Name: name, + Description: localizeMessage(localizer, tmplDef.DescriptionKey), + Category: tmplDef.Category, + CmdIds: cmdIds, + IsBuiltin: true, + CreatorId: 1, // System user + } + + if err := db.DB.Create(newTemplate).Error; err != nil { + return err + } + + logger.L().Info("Successfully created predefined template", + zap.String("name", name), + zap.Int("command_count", len(cmdIds))) + + return nil +} + +// getCommandIdsByLocalizedNames retrieves command IDs by their localized names +func getCommandIdsByLocalizedNames(localizer *i18n.Localizer, nameKeys []string) (model.Slice[int], error) { + // Convert i18n keys to localized names + localizedNames := lo.Map(nameKeys, func(keyID string, _ int) string { + // Find the message by ID in our predefined commands + cmdDefs := getPreDefinedCommands() + if cmdDef, found := lo.Find(cmdDefs, func(def CommandDefinition) bool { + return def.NameKey.ID == keyID + }); found { + return localizeMessage(localizer, cmdDef.NameKey) + } + return keyID // fallback to key ID if not found + }) + + var commands []model.Command + if err := db.DB.Where("name IN ? AND is_global = ?", localizedNames, true).Find(&commands).Error; err != nil { + return nil, err + } + + return lo.Map(commands, func(cmd model.Command, _ int) int { + return cmd.Id + }), nil +} + +// getPreDefinedCommands returns command definitions with i18n message keys +func getPreDefinedCommands() []CommandDefinition { + return []CommandDefinition{ + // Critical dangerous commands (RiskLevel: 3) + { + NameKey: myi18n.CmdDeleteRootDir, + DescriptionKey: myi18n.CmdDeleteRootDirDesc, + Cmd: "^rm\\s+(-rf?|--recursive\\s+--force?)\\s+/\\s*$", + IsRe: true, + RiskLevel: model.RiskLevelCritical, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagDangerous, myi18n.TagDelete, myi18n.TagSystem}, + }, + { + NameKey: myi18n.CmdDeleteSystemDirs, + DescriptionKey: myi18n.CmdDeleteSystemDirsDesc, + Cmd: "^rm\\s+(-rf?|--recursive\\s+--force?)\\s+/(bin|boot|dev|etc|lib|lib64|proc|root|sbin|sys|usr)(/.*)?$", + IsRe: true, + RiskLevel: model.RiskLevelCritical, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagDangerous, myi18n.TagDelete, myi18n.TagSystemDirs}, + }, + { + NameKey: myi18n.CmdDiskDestruction, + DescriptionKey: myi18n.CmdDiskDestructionDesc, + Cmd: "^dd\\s+if=/dev/(zero|random|urandom)\\s+of=/dev/[sh]d[a-z]+", + IsRe: true, + RiskLevel: model.RiskLevelCritical, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagDangerous, myi18n.TagDisk, myi18n.TagDestruction}, + }, + { + NameKey: myi18n.CmdFormatDisk, + DescriptionKey: myi18n.CmdFormatDiskDesc, + Cmd: "^(mkfs|fdisk|parted|gdisk)\\.(ext[234]|xfs|btrfs|ntfs|fat32)\\s+/dev/[sh]d[a-z]+", + IsRe: true, + RiskLevel: model.RiskLevelCritical, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagDangerous, myi18n.TagFormat, myi18n.TagDisk}, + }, + { + NameKey: myi18n.CmdForkBomb, + DescriptionKey: myi18n.CmdForkBombDesc, + Cmd: ":(\\)|\\{\\s*\\|\\s*:\\s*&\\s*\\};\\s*:", + IsRe: true, + RiskLevel: model.RiskLevelCritical, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagDangerous, myi18n.TagAttack, myi18n.TagResourceExhaustion}, + }, + + // Dangerous commands (RiskLevel: 2) + { + NameKey: myi18n.CmdSystemReboot, + DescriptionKey: myi18n.CmdSystemRebootDesc, + Cmd: "^(shutdown|reboot|halt|poweroff|init\\s+[06])\\b", + IsRe: true, + RiskLevel: model.RiskLevelDanger, + Category: model.CategorySystem, + TagKeys: []*i18n.Message{myi18n.TagReboot, myi18n.TagShutdown, myi18n.TagSystem}, + }, + { + NameKey: myi18n.CmdModifySystemFiles, + DescriptionKey: myi18n.CmdModifySystemFilesDesc, + Cmd: "^(vi|vim|nano|emacs|cat\\s*>|echo\\s*>)\\s+/(etc|boot|sys)/.+", + IsRe: true, + RiskLevel: model.RiskLevelDanger, + Category: model.CategorySystem, + TagKeys: []*i18n.Message{myi18n.TagEdit, myi18n.TagSystemFiles, myi18n.TagConfig}, + }, + { + NameKey: myi18n.CmdDropDatabase, + DescriptionKey: myi18n.CmdDropDatabaseDesc, + Cmd: "^(mysql|psql|mongo).*drop\\s+(database|schema|collection)\\s+\\w+", + IsRe: true, + RiskLevel: model.RiskLevelDanger, + Category: model.CategoryDatabase, + TagKeys: []*i18n.Message{myi18n.TagDatabase, myi18n.TagDelete, myi18n.TagDrop}, + }, + { + NameKey: myi18n.CmdTruncateTable, + DescriptionKey: myi18n.CmdTruncateTableDesc, + Cmd: "^(mysql|psql).*\\b(truncate\\s+table|delete\\s+from\\s+\\w+\\s*;?)\\s*$", + IsRe: true, + RiskLevel: model.RiskLevelDanger, + Category: model.CategoryDatabase, + TagKeys: []*i18n.Message{myi18n.TagDatabase, myi18n.TagClear, myi18n.TagTruncate}, + }, + { + NameKey: myi18n.CmdModifyPermissions, + DescriptionKey: myi18n.CmdModifyPermissionsDesc, + Cmd: "^(chmod|chown|chgrp)\\s+(777|666|755)\\s+/", + IsRe: true, + RiskLevel: model.RiskLevelDanger, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagPermissions, myi18n.TagChmod, myi18n.TagSecurity}, + }, + + // Warning level commands (RiskLevel: 1) + { + NameKey: myi18n.CmdDropTable, + DescriptionKey: myi18n.CmdDropTableDesc, + Cmd: "^(mysql|psql).*drop\\s+table\\s+\\w+", + IsRe: true, + RiskLevel: model.RiskLevelWarning, + Category: model.CategoryDatabase, + TagKeys: []*i18n.Message{myi18n.TagDatabase, myi18n.TagDelete, myi18n.TagTable}, + }, + { + NameKey: myi18n.CmdServiceControl, + DescriptionKey: myi18n.CmdServiceControlDesc, + Cmd: "^(systemctl|service)\\s+(stop|restart|disable)\\s+\\w+", + IsRe: true, + RiskLevel: model.RiskLevelWarning, + Category: model.CategorySystem, + TagKeys: []*i18n.Message{myi18n.TagService, myi18n.TagSystemctl, myi18n.TagControl}, + }, + { + NameKey: myi18n.CmdNetworkConfig, + DescriptionKey: myi18n.CmdNetworkConfigDesc, + Cmd: "^(iptables|ufw|firewall-cmd|ip\\s+route)\\s+", + IsRe: true, + RiskLevel: model.RiskLevelWarning, + Category: model.CategoryNetwork, + TagKeys: []*i18n.Message{myi18n.TagNetwork, myi18n.TagFirewall, myi18n.TagRouting}, + }, + { + NameKey: myi18n.CmdUserManagement, + DescriptionKey: myi18n.CmdUserManagementDesc, + Cmd: "^(useradd|userdel|usermod|passwd)\\s+", + IsRe: true, + RiskLevel: model.RiskLevelWarning, + Category: model.CategorySecurity, + TagKeys: []*i18n.Message{myi18n.TagUser, myi18n.TagManagement, myi18n.TagSecurity}, + }, + { + NameKey: myi18n.CmdKernelModule, + DescriptionKey: myi18n.CmdKernelModuleDesc, + Cmd: "^(modprobe|rmmod|insmod)\\s+", + IsRe: true, + RiskLevel: model.RiskLevelWarning, + Category: model.CategorySystem, + TagKeys: []*i18n.Message{myi18n.TagKernel, myi18n.TagModule, myi18n.TagSystem}, + }, + } +} + +// getPreDefinedTemplates returns template definitions with i18n message keys +func getPreDefinedTemplates() []TemplateDefinition { + return []TemplateDefinition{ + { + NameKey: myi18n.TmplBasicSecurity, + DescriptionKey: myi18n.TmplBasicSecurityDesc, + Category: model.CategorySecurity, + CommandRefs: []string{"CmdDeleteRootDir", "CmdDeleteSystemDirs", "CmdDiskDestruction", "CmdFormatDisk", "CmdForkBomb"}, + }, + { + NameKey: myi18n.TmplDatabaseProtection, + DescriptionKey: myi18n.TmplDatabaseProtectionDesc, + Category: model.CategoryDatabase, + CommandRefs: []string{"CmdDropDatabase", "CmdTruncateTable", "CmdDropTable"}, + }, + { + NameKey: myi18n.TmplServiceRestrictions, + DescriptionKey: myi18n.TmplServiceRestrictionsDesc, + Category: model.CategorySystem, + CommandRefs: []string{"CmdSystemReboot", "CmdModifySystemFiles", "CmdServiceControl"}, + }, + { + NameKey: myi18n.TmplNetworkSecurity, + DescriptionKey: myi18n.TmplNetworkSecurityDesc, + Category: model.CategorySecurity, + CommandRefs: []string{"CmdNetworkConfig", "CmdModifyPermissions", "CmdUserManagement"}, + }, + { + NameKey: myi18n.TmplDevEnvironment, + DescriptionKey: myi18n.TmplDevEnvironmentDesc, + Category: model.CategorySecurity, + CommandRefs: []string{"CmdDeleteRootDir", "CmdFormatDisk", "CmdForkBomb"}, + }, + } +} diff --git a/backend/internal/service/command_template.go b/backend/internal/service/command_template.go new file mode 100644 index 0000000..c5be03c --- /dev/null +++ b/backend/internal/service/command_template.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/internal/repository" + dbpkg "github.com/veops/oneterm/pkg/db" +) + +// CommandTemplateService handles business logic for command templates +type CommandTemplateService struct { + repo repository.ICommandTemplateRepository +} + +// NewCommandTemplateService creates a new command template service +func NewCommandTemplateService() *CommandTemplateService { + repo := repository.NewCommandTemplateRepository(dbpkg.DB) + return &CommandTemplateService{ + repo: repo, + } +} + +// BuildQuery builds the base query for command templates +func (s *CommandTemplateService) BuildQuery(ctx context.Context) (*gorm.DB, error) { + db := dbpkg.DB.Model(model.DefaultCommandTemplate) + return db, nil +} + +// CreateCommandTemplate creates a new command template +func (s *CommandTemplateService) CreateCommandTemplate(ctx context.Context, template *model.CommandTemplate) error { + // Validate the template + if err := s.ValidateCommandTemplate(template); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Check for duplicate names + existing, err := s.repo.GetByName(ctx, template.Name) + if err != nil { + return fmt.Errorf("failed to check existing template: %w", err) + } + if existing != nil { + return errors.New("template with this name already exists") + } + + // Set default values + template.IsBuiltin = false + + return s.repo.Create(ctx, template) +} + +// GetCommandTemplate retrieves a command template by ID +func (s *CommandTemplateService) GetCommandTemplate(ctx context.Context, id int) (*model.CommandTemplate, error) { + return s.repo.GetByID(ctx, id) +} + +// GetCommandTemplateByName retrieves a command template by name +func (s *CommandTemplateService) GetCommandTemplateByName(ctx context.Context, name string) (*model.CommandTemplate, error) { + return s.repo.GetByName(ctx, name) +} + +// ListCommandTemplates retrieves command templates with pagination and filters +func (s *CommandTemplateService) ListCommandTemplates(ctx context.Context, offset, limit int, category string, builtin *bool) ([]*model.CommandTemplate, int64, error) { + return s.repo.List(ctx, offset, limit, category, builtin) +} + +// UpdateCommandTemplate updates an existing command template +func (s *CommandTemplateService) UpdateCommandTemplate(ctx context.Context, template *model.CommandTemplate) error { + // Validate the template + if err := s.ValidateCommandTemplate(template); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Check if template exists + existing, err := s.repo.GetByID(ctx, template.Id) + if err != nil { + return fmt.Errorf("failed to get existing template: %w", err) + } + if existing == nil { + return errors.New("command template not found") + } + + // Don't allow changing built-in status + template.IsBuiltin = existing.IsBuiltin + + return s.repo.Update(ctx, template) +} + +// DeleteCommandTemplate deletes a command template +func (s *CommandTemplateService) DeleteCommandTemplate(ctx context.Context, id int) error { + return s.repo.Delete(ctx, id) +} + +// GetBuiltInTemplates retrieves all built-in command templates +func (s *CommandTemplateService) GetBuiltInTemplates(ctx context.Context) ([]*model.CommandTemplate, error) { + return s.repo.GetBuiltInTemplates(ctx) +} + +// ValidateCommandTemplate validates a command template +func (s *CommandTemplateService) ValidateCommandTemplate(template *model.CommandTemplate) error { + if template.Name == "" { + return errors.New("template name is required") + } + + if len(template.Name) > 128 { + return errors.New("template name too long (max 128 characters)") + } + + if template.Category == "" { + return errors.New("template category is required") + } + + // Validate category + validCategories := []string{"security", "system", "database", "network", "file", "developer", "custom"} + categoryValid := false + for _, cat := range validCategories { + if string(template.Category) == cat { + categoryValid = true + break + } + } + if !categoryValid { + return fmt.Errorf("invalid category: %s. Valid categories: %v", template.Category, validCategories) + } + + // Validate command IDs if provided + if len(template.CmdIds) > 0 { + if err := s.validateCommandIds(template.CmdIds); err != nil { + return fmt.Errorf("invalid command IDs: %w", err) + } + } + + return nil +} + +// validateCommandIds validates that all command IDs exist +func (s *CommandTemplateService) validateCommandIds(cmdIds model.Slice[int]) error { + if len(cmdIds) == 0 { + return nil + } + + // Convert model.Slice[int] to []int for SQL query + ids := make([]int, len(cmdIds)) + copy(ids, cmdIds) + + // Check if all command IDs exist + var count int64 + err := dbpkg.DB.Model(&model.Command{}).Where("id IN ?", ids).Count(&count).Error + if err != nil { + return fmt.Errorf("failed to validate command IDs: %w", err) + } + + if int(count) != len(cmdIds) { + return errors.New("some command IDs do not exist") + } + + return nil +} + +// GetTemplateCommands retrieves all commands for a template +func (s *CommandTemplateService) GetTemplateCommands(ctx context.Context, templateId int) ([]*model.Command, error) { + template, err := s.repo.GetByID(ctx, templateId) + if err != nil { + return nil, err + } + if template == nil { + return nil, errors.New("command template not found") + } + + if len(template.CmdIds) == 0 { + return []*model.Command{}, nil + } + + // Convert model.Slice[int] to []int for SQL query + ids := make([]int, len(template.CmdIds)) + copy(ids, template.CmdIds) + + var commands []*model.Command + err = dbpkg.DB.WithContext(ctx).Where("id IN ?", ids).Find(&commands).Error + return commands, err +}