diff --git a/agent/.goreleaser.yml b/agent/.goreleaser.yml new file mode 100644 index 0000000..324e161 --- /dev/null +++ b/agent/.goreleaser.yml @@ -0,0 +1,81 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 +project_name: BanForge + + + +gitea_urls: + api: https://gitea.d3m0k1d.ru/api/v1 + download: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/download + skip_tls_verify: false + + +builds: + - id: banforge + main: ./cmd/banforge/main.go + binary: banforge + ignore: + - goos: windows + - goos: darwin + - goos: freebsd + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - "-s -w" + env: + - CGO_ENABLED=0 +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +nfpms: + - id: banforge + package_name: banforge + file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + homepage: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN + description: HellreigN agent + maintainer: d3m0k1d + license: GPLv3.0 + formats: + - apk + - deb + - rpm + - archlinux + bindir: /usr/bin + scripts: + postinstall: build/postinstall.sh + postremove: build/postremove.sh + contents: + - src: docs/man/banforge.1 + dst: /usr/share/man/man1/banforge.1 + file_info: + mode: 0644 + - src: docs/man/banforge.5 + dst: /usr/share/man/man5/banforge.5 + file_info: + mode: 0644 +release: + gitea: + owner: d3m0k1d + name: BanForge + mode: keep-existing + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + algorithm: sha256 + +sboms: + - artifacts: archive + documents: + - "{{ .ArtifactName }}.spdx.json" + cmd: syft + args: ["$artifact", "--output", "spdx-json=$document"] diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 1ae37e5..45d04fc 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -92,10 +92,14 @@ func main() { if err := scriptRepo.Init(context.Background()); err != nil { log.Printf("Warning: failed to initialize script interpreters table: %v", err) } - scriptSvc := service.NewScriptService(scriptRepo) + scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo) scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdr) jobsHandlers := handlers.NewJobsHandlers(cmdr, scriptSvc) + // Initialize script management service and handlers + scriptManageSvc := service.NewScriptService(h.Repo) + scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptManageSvc, cmdr) + agents := handlers.NewAgentsGroup(h, coll) auth := handlers.AuthGroup{Handlers: h} agentReg := handlers.NewAgentRegistrationGroup(h) @@ -239,6 +243,14 @@ func main() { scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter) scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter) scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter) + + // Script management (tree, CRUD) + scriptsGroup.GET("/tree", scriptManageHandlers.GetTree) + scriptsGroup.POST("", scriptManageHandlers.CreateScript) + scriptsGroup.GET("/:id", scriptManageHandlers.GetScript) + scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript) + scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript) + scriptsGroup.POST("/:id/run", scriptManageHandlers.RunScriptByID) } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 11053f9..e37faad 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1304,6 +1304,282 @@ const docTemplate = `{ } } }, + "/scripts": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new script with path, content, and interpreter binding", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Create script", + "parameters": [ + { + "description": "Script data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + } + } + } + }, + "/scripts/:id": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a script by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates a script's path, content, or interpreter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Update script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Script data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes a script by its ID", + "tags": [ + "scripts" + ], + "summary": "Delete script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/scripts/:id/run": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Run script by ID", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Agent token and optional stdin", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RunStoredScriptIn" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_handlers.RunScriptOut" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/scripts/interpreters": { "get": { "security": [ @@ -1511,6 +1787,34 @@ const docTemplate = `{ } } } + }, + "/scripts/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a hierarchical tree of all scripts organized by their paths", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script directory tree", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode" + } + } + } + } + } } }, "definitions": { @@ -1704,6 +2008,47 @@ const docTemplate = `{ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate": { + "type": "object", + "required": [ + "interpreter_id", + "path" + ], + "properties": { + "content": { + "type": "string" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter": { "type": "object", "properties": { @@ -1769,6 +2114,47 @@ const docTemplate = `{ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode" + } + }, + "content": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "interpreter_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "description": "\"folder\" or \"file\"", + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate": { "type": "object", "required": [ @@ -2109,6 +2495,20 @@ const docTemplate = `{ "type": "string" } } + }, + "internal_handlers.RunStoredScriptIn": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "stdin": { + "type": "string" + }, + "token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index fd29e5a..fd31f8b 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1293,6 +1293,282 @@ } } }, + "/scripts": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new script with path, content, and interpreter binding", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Create script", + "parameters": [ + { + "description": "Script data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + } + } + } + }, + "/scripts/:id": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a script by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates a script's path, content, or interpreter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Update script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Script data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes a script by its ID", + "tags": [ + "scripts" + ], + "summary": "Delete script", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/scripts/:id/run": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Run script by ID", + "parameters": [ + { + "type": "integer", + "description": "Script ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Agent token and optional stdin", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RunStoredScriptIn" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_handlers.RunScriptOut" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/scripts/interpreters": { "get": { "security": [ @@ -1500,6 +1776,34 @@ } } } + }, + "/scripts/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a hierarchical tree of all scripts organized by their paths", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script directory tree", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode" + } + } + } + } + } } }, "definitions": { @@ -1693,6 +1997,47 @@ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate": { + "type": "object", + "required": [ + "interpreter_id", + "path" + ], + "properties": { + "content": { + "type": "string" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter": { "type": "object", "properties": { @@ -1758,6 +2103,47 @@ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode" + } + }, + "content": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "interpreter_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "description": "\"folder\" or \"file\"", + "type": "string" + } + } + }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "interpreter_id": { + "type": "integer" + }, + "path": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate": { "type": "object", "required": [ @@ -2098,6 +2484,20 @@ "type": "string" } } + }, + "internal_handlers.RunStoredScriptIn": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "stdin": { + "type": "string" + }, + "token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index a324bbf..dde312e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -130,6 +130,33 @@ definitions: required: - label type: object + gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + interpreter_id: + type: integer + path: + type: string + updated_at: + type: string + type: object + gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate: + properties: + content: + type: string + interpreter_id: + type: integer + path: + type: string + required: + - interpreter_id + - path + type: object gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter: properties: argv: @@ -173,6 +200,33 @@ definitions: name: type: string type: object + gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode: + properties: + children: + items: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode' + type: array + content: + type: string + id: + type: integer + interpreter_id: + type: integer + name: + type: string + type: + description: '"folder" or "file"' + type: string + type: object + gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate: + properties: + content: + type: string + interpreter_id: + type: integer + path: + type: string + type: object gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate: properties: is_active: @@ -402,6 +456,15 @@ definitions: stdout: type: string type: object + internal_handlers.RunStoredScriptIn: + properties: + stdin: + type: string + token: + type: string + required: + - token + type: object info: contact: {} paths: @@ -1228,6 +1291,183 @@ paths: summary: Get distinct services tags: - logs + /scripts: + post: + consumes: + - application/json + description: Creates a new script with path, content, and interpreter binding + parameters: + - description: Script data + in: body + name: body + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptCreate' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script' + security: + - Bearer: [] + summary: Create script + tags: + - scripts + /scripts/:id: + delete: + description: Deletes a script by its ID + parameters: + - description: Script ID + in: path + name: id + required: true + type: integer + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Delete script + tags: + - scripts + get: + description: Returns a script by its ID + parameters: + - description: Script ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Get script + tags: + - scripts + put: + consumes: + - application/json + description: Updates a script's path, content, or interpreter + parameters: + - description: Script ID + in: path + name: id + required: true + type: integer + - description: Script data + in: body + name: body + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Update script + tags: + - scripts + /scripts/:id/run: + post: + consumes: + - application/json + description: Loads a script from storage, resolves interpreter command, and + executes on the specified agent + parameters: + - description: Script ID + in: path + name: id + required: true + type: integer + - description: Agent token and optional stdin + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_handlers.RunStoredScriptIn' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_handlers.RunScriptOut' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Run script by ID + tags: + - scripts /scripts/interpreters: get: description: Returns all script interpreters available in the system @@ -1357,6 +1597,23 @@ paths: summary: Run a script on an agent tags: - scripts + /scripts/tree: + get: + description: Returns a hierarchical tree of all scripts organized by their paths + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptTreeNode' + type: array + security: + - Bearer: [] + summary: Get script directory tree + tags: + - scripts securityDefinitions: Bearer: description: Type "Bearer" followed by a space and the JWT token. diff --git a/backend/internal/ansible/executor.go b/backend/internal/ansible/executor.go index 0e7adae..f00707f 100644 --- a/backend/internal/ansible/executor.go +++ b/backend/internal/ansible/executor.go @@ -50,6 +50,11 @@ func (e *Executor) WorkDir() string { return e.workDir } +// GRPCURL returns the gRPC server URL (host:port) +func (e *Executor) GRPCURL() string { + return e.grpcServerHost + ":" + e.grpcServerPort +} + // Deploy runs Ansible playbook for the given inventory func (e *Executor) Deploy( ctx context.Context, @@ -66,6 +71,7 @@ func (e *Executor) Deploy( cmd := exec.CommandContext(ctx, "ansible-playbook", "-i", inventoryPath, "-e", fmt.Sprintf("backend_url=%s", e.backendURL), + "-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort), playbookPath, ) diff --git a/backend/internal/ansible/inventory.go b/backend/internal/ansible/inventory.go index d69a9f3..03b099d 100644 --- a/backend/internal/ansible/inventory.go +++ b/backend/internal/ansible/inventory.go @@ -18,6 +18,7 @@ type InventoryHost struct { Password string DeployType string Token string + GRPCURL string } // Inventory represents an Ansible inventory file @@ -32,6 +33,7 @@ const inventoryTemplateText = `{{ range .Hosts }} deploy_type={{ .DeployType }} agent_token={{ .Token }} agent_label={{ .Name }} +grpc_url={{ .GRPCURL }} {{ end }}` diff --git a/backend/internal/ansible/playbooks.go b/backend/internal/ansible/playbooks.go index dbb0d40..9a44729 100644 --- a/backend/internal/ansible/playbooks.go +++ b/backend/internal/ansible/playbooks.go @@ -1,6 +1,8 @@ package ansible -// BinaryDeployPlaybook returns the Ansible playbook for binary deployment +// BinaryDeployPlaybook returns the Ansible playbook for binary deployment. +// Downloads the agent binary, writes config, and starts it directly (no systemd). +// systemd unit is managed separately (e.g. via goreleaser .deb/.rpm packages). const BinaryDeployPlaybook = `--- - name: Deploy HellreigN Agent (Binary) hosts: all @@ -11,7 +13,6 @@ const BinaryDeployPlaybook = `--- backend_url: "{{ backend_url }}" install_dir: /opt/hellreign bin_name: hellreign-agent - service_name: hellreign-agent cert_dir: "{{ install_dir }}/certs" tasks: @@ -37,45 +38,29 @@ const BinaryDeployPlaybook = `--- copy: content: | backend_url: "{{ backend_url }}" + grpc_url: "{{ grpc_url | default('localhost:9001') }}" label: "{{ agent_label }}" registration_token: "{{ agent_token }}" cert_dir: "{{ cert_dir }}" + services: + - name: system + type: journald dest: "{{ install_dir }}/config.yml" mode: '0644' - - name: Create systemd service file - copy: - content: | - [Unit] - Description=HellreigN Agent - After=network.target - - [Service] - Type=simple - ExecStart={{ install_dir }}/{{ bin_name }} - Restart=always - RestartSec=5 - Environment=CONFIG_FILE={{ install_dir }}/config.yml - StandardOutput=journal - StandardError=journal - - [Install] - WantedBy=multi-user.target - dest: /etc/systemd/system/{{ service_name }}.service - mode: '0644' - - - name: Reload systemd daemon - systemd: - daemon_reload: yes - - - name: Enable and start HellreigN Agent service - systemd: - name: "{{ service_name }}" - enabled: yes - state: started + - name: Start HellreigN Agent + shell: | + nohup {{ install_dir }}/{{ bin_name }} > /dev/null 2>&1 & + echo $! + args: + executable: /bin/bash + environment: + CONFIG_FILE: "{{ install_dir }}/config.yml" + register: agent_pid + changed_when: true ` -// DockerDeployPlaybook returns the Ansible playbook for Docker deployment +// DockerDeployPlaybook returns the Ansible playbook for Docker deployment. const DockerDeployPlaybook = `--- - name: Deploy HellreigN Agent (Docker) hosts: all @@ -84,6 +69,7 @@ const DockerDeployPlaybook = `--- agent_label: "{{ agent_label }}" agent_token: "{{ agent_token }}" backend_url: "{{ backend_url }}" + grpc_url: "{{ grpc_url | default('localhost:9001') }}" container_name: hellreign-agent-{{ agent_label }} image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest" cert_dir: /etc/hellreign-agent/certs @@ -117,9 +103,13 @@ const DockerDeployPlaybook = `--- copy: content: | backend_url: "{{ backend_url }}" + grpc_url: "{{ grpc_url }}" label: "{{ agent_label }}" registration_token: "{{ agent_token }}" cert_dir: "{{ cert_dir }}" + services: + - name: system + type: journald dest: "{{ cert_dir }}/config.yml" mode: '0644' diff --git a/backend/internal/grpcsrv/collector/collector.go b/backend/internal/grpcsrv/collector/collector.go index 61f95c9..ab711ea 100644 --- a/backend/internal/grpcsrv/collector/collector.go +++ b/backend/internal/grpcsrv/collector/collector.go @@ -186,45 +186,3 @@ func (c *Collector) Agents() []*Agent { } return result } - -// ServicesStream handles the ServicesUpdate client-streaming RPC. -// Agents send service status updates which are stored in the collector. -// Returns a single response when the agent closes the stream. -func (c *Collector) ServicesStream(stream proto.Collector_ServicesStreamServer) error { - md, ok := metadata.FromIncomingContext(stream.Context()) - if !ok { - return fmt.Errorf("no metadata in context") - } - - whoamiVals := md["whoami"] - if len(whoamiVals) == 0 { - return fmt.Errorf("whoami metadata missing") - } - agentName := whoamiVals[0] - - log.Printf("Agent %s started services update stream", agentName) - - for { - update, err := stream.Recv() - if err == io.EOF { - log.Printf("Agent %s finished services update stream", agentName) - return stream.SendAndClose(&proto.ServicesUpdateResp{}) - } - if err != nil { - return fmt.Errorf("failed to receive services update: %w", err) - } - - c.mu.Lock() - if agent, ok := c.agents[agentName]; ok { - services := make([]string, 0, len(update.Services)) - for _, s := range update.Services { - services = append(services, fmt.Sprintf("%s:%s", s.Name, s.Status)) - } - agent.Services = services - log.Printf("Updated services for agent %s: %v", agentName, agent.Services) - } else { - log.Printf("Warning: received services update for unknown agent %s", agentName) - } - c.mu.Unlock() - } -} diff --git a/backend/internal/grpcsrv/commander/commander.go b/backend/internal/grpcsrv/commander/commander.go index 804d9d8..4e08924 100644 --- a/backend/internal/grpcsrv/commander/commander.go +++ b/backend/internal/grpcsrv/commander/commander.go @@ -60,6 +60,18 @@ func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) { return } +// GetAgentByLabel searches for an agent by its human-readable label. +func (self *Commander) GetAgentByLabel(label string) (agent Agent, ok bool) { + self.mu.RLock() + defer self.mu.RUnlock() + for _, a := range self.agents { + if a.Label == label { + return a, true + } + } + return +} + func (self *Commander) Agents() []Agent { self.mu.RLock() defer self.mu.RUnlock() diff --git a/backend/internal/handlers/agent_deploy.go b/backend/internal/handlers/agent_deploy.go index 7ade880..1eb886e 100644 --- a/backend/internal/handlers/agent_deploy.go +++ b/backend/internal/handlers/agent_deploy.go @@ -117,6 +117,7 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) { Password: server.Password, DeployType: string(server.DeployType), Token: token, + GRPCURL: adg.executor.GRPCURL(), }, } diff --git a/backend/internal/handlers/scripts_manage.go b/backend/internal/handlers/scripts_manage.go new file mode 100644 index 0000000..b0835ab --- /dev/null +++ b/backend/internal/handlers/scripts_manage.go @@ -0,0 +1,274 @@ +package handlers + +import ( + "errors" + "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" +) + +// ScriptHandlersGroup handles script management routes. +type ScriptHandlersGroup struct { + svc *service.ScriptService + cmder *commander.Commander +} + +// NewScriptHandlersGroup creates a new ScriptHandlersGroup. +func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander) *ScriptHandlersGroup { + return &ScriptHandlersGroup{svc: svc, cmder: cmder} +} + +// GetTree returns the script directory tree. +// @Summary Get script directory tree +// @Description Returns a hierarchical tree of all scripts organized by their paths +// @Tags scripts +// @Produce json +// @Success 200 {array} repository.ScriptTreeNode +// @Security Bearer +// @Router /scripts/tree [get] +func (sh *ScriptHandlersGroup) GetTree(c *gin.Context) { + tree, err := sh.svc.BuildTree() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build script tree"}) + return + } + + if tree == nil { + tree = []repository.ScriptTreeNode{} + } + + c.JSON(http.StatusOK, tree) +} + +// CreateScript creates a new script. +// @Summary Create script +// @Description Creates a new script with path, content, and interpreter binding +// @Tags scripts +// @Accept json +// @Produce json +// @Param body body repository.ScriptCreate true "Script data" +// @Success 201 {object} repository.Script +// @Security Bearer +// @Router /scripts [post] +func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) { + var req repository.ScriptCreate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + script, err := sh.svc.Repo.CreateScript(req) + if err != nil { + if isUniqueConstraint(err) { + c.JSON(http.StatusConflict, gin.H{"error": "script with this path already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create script"}) + return + } + + c.JSON(http.StatusCreated, script) +} + +// GetScript returns a script by ID. +// @Summary Get script +// @Description Returns a script by its ID +// @Tags scripts +// @Produce json +// @Param id path int true "Script ID" +// @Success 200 {object} repository.Script +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Security Bearer +// @Router /scripts/:id [get] +func (sh *ScriptHandlersGroup) GetScript(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + script, err := sh.svc.Repo.GetScript(id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"}) + return + } + + c.JSON(http.StatusOK, script) +} + +// UpdateScript updates a script. +// @Summary Update script +// @Description Updates a script's path, content, or interpreter +// @Tags scripts +// @Accept json +// @Produce json +// @Param id path int true "Script ID" +// @Param body body repository.ScriptUpdate true "Script data" +// @Success 200 {object} repository.Script +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Security Bearer +// @Router /scripts/:id [put] +func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req repository.ScriptUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + script, err := sh.svc.Repo.UpdateScript(id, req) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update script"}) + return + } + + c.JSON(http.StatusOK, script) +} + +// DeleteScript deletes a script. +// @Summary Delete script +// @Description Deletes a script by its ID +// @Tags scripts +// @Param id path int true "Script ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Security Bearer +// @Router /scripts/:id [delete] +func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + if err := sh.svc.Repo.DeleteScript(id); err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete script"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "script deleted"}) +} + +// RunScriptByID executes a stored script on a target agent. +// @Summary Run script by ID +// @Description Loads a script from storage, resolves interpreter command, and executes on the specified agent +// @Tags scripts +// @Accept json +// @Produce json +// @Param id path int true "Script ID" +// @Param body body RunStoredScriptIn true "Agent token and optional stdin" +// @Success 201 {object} RunScriptOut +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security Bearer +// @Router /scripts/:id/run [post] +func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var in RunStoredScriptIn + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + script, err := sh.svc.Repo.GetScript(id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"}) + return + } + + command, err := sh.svc.ResolveCommand(c.Request.Context(), script.InterpreterID, script.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve command: %v", err)}) + return + } + + agent, ok := sh.cmder.GetAgent(in.Token) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + jid, err := agent.AddJob(models.JobForInsert{ + Command: command, + Stdin: in.Stdin, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add job: %v", err)}) + return + } + + job, err := agent.WaitJob(jid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("job execution failed: %v", err)}) + return + } + + c.JSON(http.StatusCreated, RunScriptOut{ + ID: job.ID, + Command: job.Command, + Stdin: job.Stdin, + Stdout: job.Stdout, + Stderr: job.Stderr, + Status: job.Status, + }) +} + +// RunStoredScriptIn is the request body for running a stored script on an agent. +type RunStoredScriptIn struct { + Token string `json:"token" binding:"required"` + Stdin *string `json:"stdin"` +} + +// isUniqueConstraint checks if the error is a SQLite UNIQUE constraint violation. +func isUniqueConstraint(err error) bool { + return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint")) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/backend/internal/repository/models.go b/backend/internal/repository/models.go index 83fbc03..27ffde0 100644 --- a/backend/internal/repository/models.go +++ b/backend/internal/repository/models.go @@ -149,3 +149,37 @@ type DeployResult struct { Success bool `json:"success" example:"true" description:"Whether deployment succeeded"` Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"` } + +// Script represents a stored script with path and interpreter binding. +type Script struct { + ID int64 `json:"id"` + Path string `json:"path"` + Content string `json:"content"` + InterpreterID int64 `json:"interpreter_id"` + CreatedAt *string `json:"created_at"` + UpdatedAt *string `json:"updated_at"` +} + +// ScriptCreate is the request body for creating a script. +type ScriptCreate struct { + Path string `json:"path" binding:"required"` + Content string `json:"content"` + InterpreterID int64 `json:"interpreter_id" binding:"required"` +} + +// ScriptUpdate is the request body for updating a script. +type ScriptUpdate struct { + Path *string `json:"path"` + Content *string `json:"content"` + InterpreterID *int64 `json:"interpreter_id"` +} + +// ScriptTreeNode represents a node in the script directory tree. +type ScriptTreeNode struct { + Name string `json:"name"` + Type string `json:"type"` // "folder" or "file" + Children []ScriptTreeNode `json:"children,omitempty"` + ID *int64 `json:"id,omitempty"` + Content *string `json:"content,omitempty"` + InterpreterID *int64 `json:"interpreter_id,omitempty"` +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index a5c9286..a3c2813 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -507,3 +507,134 @@ func (r *Repository) UpdatePassword(login string, newPassword string) error { } return nil } + +// CreateScript inserts a new script into the database. +func (r *Repository) CreateScript(sc ScriptCreate) (*Script, error) { + result, err := r.DB.Exec( + `INSERT INTO scripts (path, content, interpreter_id) VALUES (?, ?, ?)`, + sc.Path, sc.Content, sc.InterpreterID, + ) + if err != nil { + return nil, fmt.Errorf("insert script: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("get last insert id: %w", err) + } + + return &Script{ + ID: id, + Path: sc.Path, + Content: sc.Content, + InterpreterID: sc.InterpreterID, + }, nil +} + +// GetScript retrieves a script by ID. +func (r *Repository) GetScript(id int64) (*Script, error) { + var s Script + err := r.DB.QueryRow( + `SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE id = ?`, + id, + ).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &s, nil +} + +// GetScriptByPath retrieves a script by its path. +func (r *Repository) GetScriptByPath(path string) (*Script, error) { + var s Script + err := r.DB.QueryRow( + `SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE path = ?`, + path, + ).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &s, nil +} + +// ListScripts returns all scripts. +func (r *Repository) ListScripts() ([]Script, error) { + rows, err := r.DB.Query( + `SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var scripts []Script + for rows.Next() { + var s Script + if err := rows.Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + scripts = append(scripts, s) + } + return scripts, rows.Err() +} + +// UpdateScript updates a script by ID. +func (r *Repository) UpdateScript(id int64, update ScriptUpdate) (*Script, error) { + existing, err := r.GetScript(id) + if err != nil { + return nil, err + } + + newPath := existing.Path + newContent := existing.Content + newInterpreterID := existing.InterpreterID + + if update.Path != nil { + newPath = *update.Path + } + if update.Content != nil { + newContent = *update.Content + } + if update.InterpreterID != nil { + newInterpreterID = *update.InterpreterID + } + + _, err = r.DB.Exec( + `UPDATE scripts SET path = ?, content = ?, interpreter_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + newPath, newContent, newInterpreterID, id, + ) + if err != nil { + return nil, fmt.Errorf("update script: %w", err) + } + + return &Script{ + ID: id, + Path: newPath, + Content: newContent, + InterpreterID: newInterpreterID, + }, nil +} + +// DeleteScript deletes a script by ID. +func (r *Repository) DeleteScript(id int64) error { + result, err := r.DB.Exec(`DELETE FROM scripts 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 index b43bc04..39cd9bb 100644 --- a/backend/internal/service/script_service.go +++ b/backend/internal/service/script_service.go @@ -3,66 +3,170 @@ package service import ( "context" "fmt" + "sort" + "strings" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" ) +// ScriptService handles script CRUD, tree building, and interpreter resolution. type ScriptService struct { - repo *repository.ScriptInterpreterRepo + Repo *repository.Repository + InterpreterRepo *repository.ScriptInterpreterRepo } -func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService { - return &ScriptService{repo: repo} +// NewScriptService creates a new ScriptService with both script and interpreter repos. +func NewScriptService(repo *repository.Repository) *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) +// NewScriptServiceWithInterpreters creates a ScriptService with interpreter support. +func NewScriptServiceWithInterpreters(repo *repository.Repository, interpRepo *repository.ScriptInterpreterRepo) *ScriptService { + return &ScriptService{Repo: repo, InterpreterRepo: interpRepo} +} + +// treeNode is an internal representation for building the tree. +type treeNode struct { + name string + typ string // "folder" or "file" + children map[string]*treeNode + // File-specific fields + id *int64 + content *string + interpreterID *int64 +} + +// BuildTree builds a directory tree from all scripts in the database. +// Each script path is treated as a file path (e.g. "deploy/nginx/restart.sh"). +func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) { + scripts, err := s.Repo.ListScripts() if err != nil { return nil, err } - if len(interpreter.Argv) == 0 { - return nil, fmt.Errorf("interpreter %q has empty argv", interpreter.Name) + root := make(map[string]*treeNode) + + for _, sc := range scripts { + parts := strings.Split(sc.Path, "/") + + // Walk through path parts, creating folders as needed + currentMap := root + for i, part := range parts { + isFile := i == len(parts)-1 + if _, exists := currentMap[part]; !exists { + node := &treeNode{ + name: part, + children: make(map[string]*treeNode), + } + if isFile { + node.typ = "file" + id := sc.ID + content := sc.Content + interpreterID := sc.InterpreterID + node.id = &id + node.content = &content + node.interpreterID = &interpreterID + } else { + node.typ = "folder" + } + currentMap[part] = node + } + currentMap = currentMap[part].children + } } - argv := make([]string, len(interpreter.Argv)+1) - copy(argv, interpreter.Argv) - argv[len(argv)-1] = scriptText - return argv, nil + return buildTreeSlice(root), nil } -func (self *ScriptService) Create( - ctx context.Context, - in repository.ScriptInterpreterCreate, -) (*repository.ScriptInterpreter, error) { - return self.repo.Create(ctx, in) +// buildTreeSlice converts a map of treeNodes to a sorted slice of ScriptTreeNode. +func buildTreeSlice(m map[string]*treeNode) []repository.ScriptTreeNode { + result := make([]repository.ScriptTreeNode, 0, len(m)) + for _, node := range m { + result = append(result, toScriptTreeNode(node)) + } + + // Sort: folders first, then files, alphabetically within each group + sort.Slice(result, func(i, j int) bool { + if result[i].Type != result[j].Type { + return result[i].Type == "folder" + } + return result[i].Name < result[j].Name + }) + + return result } -func (self *ScriptService) GetByID( - ctx context.Context, - id int64, -) (*repository.ScriptInterpreter, error) { - return self.repo.GetByID(ctx, id) +// toScriptTreeNode converts a treeNode to a ScriptTreeNode with recursively converted children. +func toScriptTreeNode(node *treeNode) repository.ScriptTreeNode { + result := repository.ScriptTreeNode{ + Name: node.name, + Type: node.typ, + Children: []repository.ScriptTreeNode{}, + } + + if node.typ == "file" { + result.ID = node.id + result.Content = node.content + result.InterpreterID = node.interpreterID + } else { + result.Children = buildTreeSlice(node.children) + } + + return result } -func (self *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) { - return self.repo.List(ctx) +// ResolveCommand resolves the full command for a script using its interpreter. +func (s *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) { + if s.InterpreterRepo == nil { + return nil, fmt.Errorf("interpreter repo not configured") + } + + interpreter, err := s.InterpreterRepo.GetByID(ctx, interpreterID) + if err != nil { + return nil, fmt.Errorf("get interpreter: %w", err) + } + + // Build command: argv[0] argv[1] ... -c scriptText + cmd := append(interpreter.Argv, "-c", scriptText) + return cmd, nil } -func (self *ScriptService) Update( - ctx context.Context, - id int64, - in repository.ScriptInterpreterUpdate, -) (*repository.ScriptInterpreter, error) { - return self.repo.Update(ctx, id, in) +// List returns all interpreters. +func (s *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) { + if s.InterpreterRepo == nil { + return nil, fmt.Errorf("interpreter repo not configured") + } + return s.InterpreterRepo.List(ctx) } -func (self *ScriptService) Delete(ctx context.Context, id int64) error { - return self.repo.Delete(ctx, id) +// Create creates a new interpreter. +func (s *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) { + if s.InterpreterRepo == nil { + return nil, fmt.Errorf("interpreter repo not configured") + } + return s.InterpreterRepo.Create(ctx, in) +} + +// GetByID returns an interpreter by ID. +func (s *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) { + if s.InterpreterRepo == nil { + return nil, fmt.Errorf("interpreter repo not configured") + } + return s.InterpreterRepo.GetByID(ctx, id) +} + +// Update updates an interpreter. +func (s *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) { + if s.InterpreterRepo == nil { + return nil, fmt.Errorf("interpreter repo not configured") + } + return s.InterpreterRepo.Update(ctx, id, in) +} + +// Delete deletes an interpreter. +func (s *ScriptService) Delete(ctx context.Context, id int64) error { + if s.InterpreterRepo == nil { + return fmt.Errorf("interpreter repo not configured") + } + return s.InterpreterRepo.Delete(ctx, id) } diff --git a/backend/internal/storage/migrations.go b/backend/internal/storage/migrations.go index 7eaab13..29fc584 100644 --- a/backend/internal/storage/migrations.go +++ b/backend/internal/storage/migrations.go @@ -57,6 +57,18 @@ CREATE TABLE IF NOT EXISTS script_interpreters ( ); ` +const CreateScriptsTable = ` +CREATE TABLE IF NOT EXISTS scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + interpreter_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (interpreter_id) REFERENCES script_interpreters(id) +); +` + const CreateLogsTable = ` CREATE TABLE IF NOT EXISTS logs ( timestamp DateTime64(3) DEFAULT now(), diff --git a/backend/internal/storage/sqlite.go b/backend/internal/storage/sqlite.go index 380d179..6c4dd2c 100644 --- a/backend/internal/storage/sqlite.go +++ b/backend/internal/storage/sqlite.go @@ -44,5 +44,10 @@ func Open(path string) (*sql.DB, error) { log.Println("[sqlite] is_active column migration applied") } + // Create scripts table if not exists + if _, err := db.Exec(CreateScriptsTable); err != nil { + return nil, fmt.Errorf("migrate scripts: %w", err) + } + return db, nil }