From f5b9b32a9fcaf21a446c596ba4baea46815f4354 Mon Sep 17 00:00:00 2001 From: "zero@thinky" Date: Sat, 4 Apr 2026 15:17:34 +0300 Subject: [PATCH] feat(backend): add script interpreters --- backend/cmd/main.go | 21 ++ backend/internal/handlers/scripts.go | 206 ++++++++++++++++++ .../script_interpreter_repository.go | 189 ++++++++++++++++ backend/internal/service/script_service.go | 54 +++++ backend/internal/storage/migrations.go | 11 + 5 files changed, 481 insertions(+) create mode 100644 backend/internal/handlers/scripts.go create mode 100644 backend/internal/repository/script_interpreter_repository.go create mode 100644 backend/internal/service/script_service.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 753bc2d..5538628 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -15,6 +15,7 @@ import ( "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "github.com/gin-gonic/gin" @@ -86,6 +87,14 @@ func main() { cmdr := commander.New(jobRepo) + // Initialize script interpreter repository and service + scriptRepo := repository.NewScriptInterpreterRepo(db) + if err := scriptRepo.Init(context.Background()); err != nil { + log.Printf("Warning: failed to initialize script interpreters table: %v", err) + } + scriptSvc := service.NewScriptService(scriptRepo) + scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdr) + agents := handlers.NewAgentsGroup(h, coll) auth := handlers.AuthGroup{Handlers: h} agentReg := handlers.NewAgentRegistrationGroup(h) @@ -193,6 +202,18 @@ func main() { logsGroup.GET("/agents", logHandlers.GetAgents) logsGroup.GET("/levels", logHandlers.GetLevels) } + + // Scripts (requires admin permission) + scriptsGroup := v1.Group("/scripts") + scriptsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin()) + { + scriptsGroup.POST("/run", scriptHandlers.RunScript) + scriptsGroup.GET("/interpreters", scriptHandlers.ListInterpreters) + scriptsGroup.POST("/interpreters", scriptHandlers.CreateInterpreter) + scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter) + scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter) + scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter) + } } // Start gRPC server with mTLS in background diff --git a/backend/internal/handlers/scripts.go b/backend/internal/handlers/scripts.go new file mode 100644 index 0000000..5e3debd --- /dev/null +++ b/backend/internal/handlers/scripts.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service" + "github.com/gin-gonic/gin" +) + +type ScriptHandlers struct { + svc *service.ScriptService + cmder *commander.Commander +} + +func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) ScriptHandlers { + return ScriptHandlers{svc: svc, cmder: cmder} +} + +// RunScript executes a script on a target agent. +// @Summary Run a script on an agent +// @Description Resolves interpreter argv[] and sends the full command to the agent +// @Tags scripts +// @Accept json +// @Produce json +// @Param body body RunScriptIn true "Script request" +// @Success 201 {object} RunScriptOut +// @Router /scripts/run [post] +func (self *ScriptHandlers) RunScript(c *gin.Context) { + err := func() error { + type RunScriptIn struct { + AgentID string `json:"agent_id" binding:"required"` + InterpreterID int64 `json:"interpreter_id" binding:"required"` + ScriptText string `json:"script_text" binding:"required"` + Stdin *string `json:"stdin"` + } + var in RunScriptIn + if err := c.Bind(&in); err != nil { + return err + } + + command, err := self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText) + if err != nil { + return err + } + + agent, ok := self.cmder.GetAgent(in.AgentID) + if !ok { + c.Status(http.StatusNotFound) + return fmt.Errorf("agent not found") + } + + jid, err := agent.AddJob(models.JobForInsert{ + Command: command, + Stdin: in.Stdin, + }) + if err != nil { + return err + } + + job, err := agent.WaitJob(jid) + if err != nil { + return err + } + + type RunScriptOut struct { + ID int64 `json:"id"` + Command []string `json:"command"` + Stdin *string `json:"stdin"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Status int32 `json:"status"` + } + c.JSON(http.StatusCreated, RunScriptOut{ + ID: job.ID, + Command: job.Command, + Stdin: job.Stdin, + Stdout: job.Stdout, + Stderr: job.Stderr, + Status: job.Status, + }) + return nil + }() + if err != nil { + c.Error(err) + } +} + +// ListInterpreters returns all registered script interpreters. +// @Summary List interpreters +// @Description Returns all script interpreters available in the system +// @Tags scripts +// @Produce json +// @Success 200 {array} repository.ScriptInterpreter +// @Router /scripts/interpreters [get] +func (self *ScriptHandlers) ListInterpreters(c *gin.Context) { + interpreters, err := self.svc.List(c.Request.Context()) + if err != nil { + c.Error(err) + return + } + c.JSON(http.StatusOK, interpreters) +} + +// CreateInterpreter registers a new script interpreter. +// @Summary Create interpreter +// @Description Registers a new script interpreter with name, label, and argv +// @Tags scripts +// @Accept json +// @Produce json +// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition" +// @Success 201 {object} repository.ScriptInterpreter +// @Router /scripts/interpreters [post] +func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) { + var in repository.ScriptInterpreterCreate + if err := c.BindJSON(&in); err != nil { + c.Error(err) + return + } + + si, err := self.svc.Create(c.Request.Context(), in) + if err != nil { + c.Error(err) + return + } + c.JSON(http.StatusCreated, si) +} + +// GetInterpreter returns a single interpreter by ID. +// @Summary Get interpreter +// @Description Returns a script interpreter by ID +// @Tags scripts +// @Produce json +// @Param id path int true "Interpreter ID" +// @Success 200 {object} repository.ScriptInterpreter +// @Router /scripts/interpreters/:id [get] +func (self *ScriptHandlers) GetInterpreter(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.Error(err) + return + } + + si, err := self.svc.GetByID(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + c.JSON(http.StatusOK, si) +} + +// UpdateInterpreter updates an interpreter. +// @Summary Update interpreter +// @Description Updates fields of a script interpreter +// @Tags scripts +// @Accept json +// @Produce json +// @Param id path int true "Interpreter ID" +// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields" +// @Success 200 {object} repository.ScriptInterpreter +// @Router /scripts/interpreters/:id [put] +func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.Error(err) + return + } + + var in repository.ScriptInterpreterUpdate + if err := c.BindJSON(&in); err != nil { + c.Error(err) + return + } + + si, err := self.svc.Update(c.Request.Context(), id, in) + if err != nil { + c.Error(err) + return + } + c.JSON(http.StatusOK, si) +} + +// DeleteInterpreter removes an interpreter. +// @Summary Delete interpreter +// @Description Removes a script interpreter by ID +// @Tags scripts +// @Param id path int true "Interpreter ID" +// @Success 204 +// @Router /scripts/interpreters/:id [delete] +func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.Error(err) + return + } + + if err := self.svc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/repository/script_interpreter_repository.go b/backend/internal/repository/script_interpreter_repository.go new file mode 100644 index 0000000..6a5e807 --- /dev/null +++ b/backend/internal/repository/script_interpreter_repository.go @@ -0,0 +1,189 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" +) + +type ScriptInterpreter struct { + ID int64 `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + Argv []string `json:"argv"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ScriptInterpreterCreate struct { + Name string `json:"name" binding:"required"` + Label string `json:"label" binding:"required"` + Argv []string `json:"argv" binding:"required"` +} + +type ScriptInterpreterUpdate struct { + Name *string `json:"name"` + Label *string `json:"label"` + Argv []string `json:"argv"` +} + +type ScriptInterpreterRepo struct { + DB *sql.DB +} + +func NewScriptInterpreterRepo(db *sql.DB) *ScriptInterpreterRepo { + return &ScriptInterpreterRepo{DB: db} +} + +func (r *ScriptInterpreterRepo) Init(ctx context.Context) error { + _, err := r.DB.ExecContext(ctx, storage.CreateScriptInterpretersTable) + return err +} + +func (r *ScriptInterpreterRepo) Create(ctx context.Context, in ScriptInterpreterCreate) (*ScriptInterpreter, error) { + argvJSON, err := json.Marshal(in.Argv) + if err != nil { + return nil, err + } + + result, err := r.DB.ExecContext(ctx, + `INSERT INTO script_interpreters (name, label, argv) VALUES (?, ?, ?)`, + in.Name, in.Label, string(argvJSON), + ) + if err != nil { + return nil, err + } + + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return r.GetByID(ctx, id) +} + +func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptInterpreter, error) { + var si ScriptInterpreter + var argvJSON string + var createdAt, updatedAt string + + err := r.DB.QueryRowContext(ctx, + `SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`, + id, + ).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil { + return nil, err + } + si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + return &si, nil +} + +func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, error) { + rows, err := r.DB.QueryContext(ctx, + `SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var interpreters []ScriptInterpreter + for rows.Next() { + var si ScriptInterpreter + var argvJSON, createdAt, updatedAt string + if err := rows.Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil { + return nil, err + } + si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + interpreters = append(interpreters, si) + } + return interpreters, rows.Err() +} + +func (r *ScriptInterpreterRepo) Update(ctx context.Context, id int64, in ScriptInterpreterUpdate) (*ScriptInterpreter, error) { + si, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + + set := "" + args := make([]interface{}, 0) + idx := 1 + + if in.Name != nil { + set += "name = ?" + args = append(args, *in.Name) + idx++ + } + if in.Label != nil { + if idx > 1 { + set += ", " + } + set += "label = ?" + args = append(args, *in.Label) + idx++ + } + if in.Argv != nil { + if idx > 1 { + set += ", " + } + argvJSON, err := json.Marshal(in.Argv) + if err != nil { + return nil, err + } + set += "argv = ?" + args = append(args, string(argvJSON)) + idx++ + } + + if idx == 1 { + return si, nil + } + + set += ", updated_at = CURRENT_TIMESTAMP" + args = append(args, id) + + _, err = r.DB.ExecContext(ctx, + `UPDATE script_interpreters SET `+set+` WHERE id = ?`, + args..., + ) + if err != nil { + return nil, err + } + + return r.GetByID(ctx, id) +} + +func (r *ScriptInterpreterRepo) Delete(ctx context.Context, id int64) error { + result, err := r.DB.ExecContext(ctx, + `DELETE FROM script_interpreters WHERE id = ?`, + id, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/service/script_service.go b/backend/internal/service/script_service.go new file mode 100644 index 0000000..7437230 --- /dev/null +++ b/backend/internal/service/script_service.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "fmt" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" +) + +type ScriptService struct { + repo *repository.ScriptInterpreterRepo +} + +func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService { + return &ScriptService{repo: repo} +} + +// ResolveCommand builds the full argv[] by prepending the interpreter's argv +// to the script text (as the last argument). +func (self *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) { + interpreter, err := self.repo.GetByID(ctx, interpreterID) + if err != nil { + return nil, err + } + + if len(interpreter.Argv) == 0 { + return nil, fmt.Errorf("interpreter %q has empty argv", interpreter.Name) + } + + argv := make([]string, len(interpreter.Argv)+1) + copy(argv, interpreter.Argv) + argv[len(argv)-1] = scriptText + return argv, nil +} + +func (self *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) { + return self.repo.Create(ctx, in) +} + +func (self *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) { + return self.repo.GetByID(ctx, id) +} + +func (self *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) { + return self.repo.List(ctx) +} + +func (self *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) { + return self.repo.Update(ctx, id, in) +} + +func (self *ScriptService) Delete(ctx context.Context, id int64) error { + return self.repo.Delete(ctx, id) +} diff --git a/backend/internal/storage/migrations.go b/backend/internal/storage/migrations.go index 87f1f1d..7eaab13 100644 --- a/backend/internal/storage/migrations.go +++ b/backend/internal/storage/migrations.go @@ -46,6 +46,17 @@ CREATE TABLE IF NOT EXISTS jobs ( ); ` +const CreateScriptInterpretersTable = ` +CREATE TABLE IF NOT EXISTS script_interpreters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + argv TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + const CreateLogsTable = ` CREATE TABLE IF NOT EXISTS logs ( timestamp DateTime64(3) DEFAULT now(),