diff --git a/agent/internal/commander/impl.go b/agent/internal/commander/impl.go index 7bb9ebb..1a61e3a 100644 --- a/agent/internal/commander/impl.go +++ b/agent/internal/commander/impl.go @@ -3,11 +3,10 @@ package commander import ( "bytes" "errors" - "io" - "os/exec" - "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "golang.org/x/sync/errgroup" + "io" + "os/exec" ) type CommandExecutor struct{} diff --git a/backend/cmd/main.go b/backend/cmd/main.go index c4cb983..afc8fb5 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -160,12 +160,28 @@ func main() { // User management (admin only) - Full CRUD authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser) authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser) - authTokenGroup.PUT("/users/:login/permissions", handlers.RequireAdmin(), auth.UpdateUserPermissions) - authTokenGroup.PUT("/users/:login/password", handlers.RequireAdmin(), auth.ResetUserPassword) + authTokenGroup.PUT( + "/users/:login/permissions", + handlers.RequireAdmin(), + auth.UpdateUserPermissions, + ) + authTokenGroup.PUT( + "/users/:login/password", + handlers.RequireAdmin(), + auth.ResetUserPassword, + ) // User activation management (admin only) - authTokenGroup.POST("/users/:login/activate", handlers.RequireAdmin(), auth.ActivateUser) - authTokenGroup.POST("/users/:login/deactivate", handlers.RequireAdmin(), auth.DeactivateUser) + authTokenGroup.POST( + "/users/:login/activate", + handlers.RequireAdmin(), + auth.ActivateUser, + ) + authTokenGroup.POST( + "/users/:login/deactivate", + handlers.RequireAdmin(), + auth.DeactivateUser, + ) authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers) } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 5cbdbb6..d6fb994 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -295,6 +295,11 @@ const docTemplate = `{ }, "/auth/token": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Creates a new user with permissions", "consumes": [ "application/json" @@ -354,6 +359,11 @@ const docTemplate = `{ } }, "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deletes the current authenticated user", "tags": [ "auth" @@ -392,6 +402,11 @@ const docTemplate = `{ }, "/auth/tokens": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns list of all users with their permissions", "produces": [ "application/json" @@ -424,6 +439,11 @@ const docTemplate = `{ }, "/auth/tokens/:login": { "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deletes a user by their login", "tags": [ "auth" @@ -471,6 +491,11 @@ const docTemplate = `{ }, "/auth/users/:login": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns a user by their login (admin only)", "produces": [ "application/json" @@ -525,6 +550,11 @@ const docTemplate = `{ } }, "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates a user's name and last name (admin only)", "consumes": [ "application/json" @@ -593,6 +623,11 @@ const docTemplate = `{ }, "/auth/users/:login/activate": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Activates a user account by login (admin only)", "tags": [ "auth" @@ -649,6 +684,11 @@ const docTemplate = `{ }, "/auth/users/:login/deactivate": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deactivates a user account by login (admin only)", "tags": [ "auth" @@ -705,6 +745,11 @@ const docTemplate = `{ }, "/auth/users/:login/password": { "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Resets a user's password to a new value (admin only)", "consumes": [ "application/json" @@ -773,6 +818,11 @@ const docTemplate = `{ }, "/auth/users/:login/permissions": { "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates a user's permissions and activation status (admin only)", "consumes": [ "application/json" @@ -841,6 +891,11 @@ const docTemplate = `{ }, "/auth/users/inactive": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns list of all users waiting for activation", "produces": [ "application/json" @@ -873,6 +928,11 @@ const docTemplate = `{ }, "/auth/validate": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Check if the provided Bearer token is valid and return its permissions", "produces": [ "application/json" @@ -902,6 +962,11 @@ const docTemplate = `{ }, "/jobs": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Sends a command to the specified agent, waits for execution, and returns the result", "consumes": [ "application/json" @@ -1238,6 +1303,11 @@ const docTemplate = `{ }, "/scripts/interpreters": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns all script interpreters available in the system", "produces": [ "application/json" @@ -1259,6 +1329,11 @@ const docTemplate = `{ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Registers a new script interpreter with name, label, and argv", "consumes": [ "application/json" @@ -1293,6 +1368,11 @@ const docTemplate = `{ }, "/scripts/interpreters/:id": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns a script interpreter by ID", "produces": [ "application/json" @@ -1320,6 +1400,11 @@ const docTemplate = `{ } }, "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates fields of a script interpreter", "consumes": [ "application/json" @@ -1359,6 +1444,11 @@ const docTemplate = `{ } }, "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Removes a script interpreter by ID", "tags": [ "scripts" @@ -1382,6 +1472,11 @@ const docTemplate = `{ }, "/scripts/run": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Resolves interpreter argv[] and sends the full command to the agent", "consumes": [ "application/json" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 34e524a..434ffee 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -284,6 +284,11 @@ }, "/auth/token": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Creates a new user with permissions", "consumes": [ "application/json" @@ -343,6 +348,11 @@ } }, "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deletes the current authenticated user", "tags": [ "auth" @@ -381,6 +391,11 @@ }, "/auth/tokens": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns list of all users with their permissions", "produces": [ "application/json" @@ -413,6 +428,11 @@ }, "/auth/tokens/:login": { "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deletes a user by their login", "tags": [ "auth" @@ -460,6 +480,11 @@ }, "/auth/users/:login": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns a user by their login (admin only)", "produces": [ "application/json" @@ -514,6 +539,11 @@ } }, "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates a user's name and last name (admin only)", "consumes": [ "application/json" @@ -582,6 +612,11 @@ }, "/auth/users/:login/activate": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Activates a user account by login (admin only)", "tags": [ "auth" @@ -638,6 +673,11 @@ }, "/auth/users/:login/deactivate": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Deactivates a user account by login (admin only)", "tags": [ "auth" @@ -694,6 +734,11 @@ }, "/auth/users/:login/password": { "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Resets a user's password to a new value (admin only)", "consumes": [ "application/json" @@ -762,6 +807,11 @@ }, "/auth/users/:login/permissions": { "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates a user's permissions and activation status (admin only)", "consumes": [ "application/json" @@ -830,6 +880,11 @@ }, "/auth/users/inactive": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns list of all users waiting for activation", "produces": [ "application/json" @@ -862,6 +917,11 @@ }, "/auth/validate": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Check if the provided Bearer token is valid and return its permissions", "produces": [ "application/json" @@ -891,6 +951,11 @@ }, "/jobs": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Sends a command to the specified agent, waits for execution, and returns the result", "consumes": [ "application/json" @@ -1227,6 +1292,11 @@ }, "/scripts/interpreters": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns all script interpreters available in the system", "produces": [ "application/json" @@ -1248,6 +1318,11 @@ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Registers a new script interpreter with name, label, and argv", "consumes": [ "application/json" @@ -1282,6 +1357,11 @@ }, "/scripts/interpreters/:id": { "get": { + "security": [ + { + "Bearer": [] + } + ], "description": "Returns a script interpreter by ID", "produces": [ "application/json" @@ -1309,6 +1389,11 @@ } }, "put": { + "security": [ + { + "Bearer": [] + } + ], "description": "Updates fields of a script interpreter", "consumes": [ "application/json" @@ -1348,6 +1433,11 @@ } }, "delete": { + "security": [ + { + "Bearer": [] + } + ], "description": "Removes a script interpreter by ID", "tags": [ "scripts" @@ -1371,6 +1461,11 @@ }, "/scripts/run": { "post": { + "security": [ + { + "Bearer": [] + } + ], "description": "Resolves interpreter argv[] and sends the full command to the agent", "consumes": [ "application/json" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 6bfb058..636f9d0 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -597,6 +597,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Delete my account tags: - auth @@ -636,6 +638,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Create user tags: - auth @@ -657,6 +661,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: List users tags: - auth @@ -688,6 +694,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Delete user tags: - auth @@ -725,6 +733,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Get user by login tags: - auth @@ -769,6 +779,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Update user tags: - auth @@ -806,6 +818,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Activate user tags: - auth @@ -843,6 +857,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Deactivate user tags: - auth @@ -888,6 +904,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Reset user password tags: - auth @@ -933,6 +951,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Update user permissions tags: - auth @@ -954,6 +974,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: List inactive users tags: - auth @@ -973,6 +995,8 @@ paths: additionalProperties: type: string type: object + security: + - Bearer: [] summary: Validate token tags: - auth @@ -996,6 +1020,8 @@ paths: description: Created schema: $ref: '#/definitions/internal_handlers.AddJobOut' + security: + - Bearer: [] summary: Create and run a job on an agent tags: - jobs @@ -1202,6 +1228,8 @@ paths: items: $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter' type: array + security: + - Bearer: [] summary: List interpreters tags: - scripts @@ -1223,6 +1251,8 @@ paths: description: Created schema: $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter' + security: + - Bearer: [] summary: Create interpreter tags: - scripts @@ -1238,6 +1268,8 @@ paths: responses: "204": description: No Content + security: + - Bearer: [] summary: Delete interpreter tags: - scripts @@ -1256,6 +1288,8 @@ paths: description: OK schema: $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter' + security: + - Bearer: [] summary: Get interpreter tags: - scripts @@ -1282,6 +1316,8 @@ paths: description: OK schema: $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.ScriptInterpreter' + security: + - Bearer: [] summary: Update interpreter tags: - scripts @@ -1304,6 +1340,8 @@ paths: description: Created schema: $ref: '#/definitions/internal_handlers.RunScriptOut' + security: + - Bearer: [] summary: Run a script on an agent tags: - scripts diff --git a/backend/internal/ansible/executor.go b/backend/internal/ansible/executor.go index 29294fc..0e7adae 100644 --- a/backend/internal/ansible/executor.go +++ b/backend/internal/ansible/executor.go @@ -12,10 +12,10 @@ import ( // Executor handles running Ansible playbooks type Executor struct { - workDir string + workDir string grpcServerHost string grpcServerPort string - backendURL string + backendURL string } // ExecutorConfig holds configuration for the Executor @@ -23,26 +23,26 @@ type ExecutorConfig struct { WorkDir string GRPCServerHost string GRPCServerPort string - BackendURL string + BackendURL string } // NewExecutor creates a new Ansible executor func NewExecutor(cfg ExecutorConfig) *Executor { return &Executor{ - workDir: cfg.WorkDir, + workDir: cfg.WorkDir, grpcServerHost: cfg.GRPCServerHost, grpcServerPort: cfg.GRPCServerPort, - backendURL: cfg.BackendURL, + backendURL: cfg.BackendURL, } } // DeployResult holds the result of a deployment type DeployResult struct { - Host string - Success bool - Stdout string - Stderr string - Err error + Host string + Success bool + Stdout string + Stderr string + Err error } // WorkDir returns the work directory path @@ -51,7 +51,11 @@ func (e *Executor) WorkDir() string { } // Deploy runs Ansible playbook for the given inventory -func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType string) ([]DeployResult, error) { +func (e *Executor) Deploy( + ctx context.Context, + inventoryPath string, + deployType string, +) ([]DeployResult, error) { playbookName := "binary_deploy.yml" if deployType == "docker" { playbookName = "docker_deploy.yml" @@ -84,7 +88,11 @@ func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType } // DeployParallel runs Ansible playbook for multiple inventories in parallel -func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string, deployType string) (map[string][]DeployResult, error) { +func (e *Executor) DeployParallel( + ctx context.Context, + inventoryPaths []string, + deployType string, +) (map[string][]DeployResult, error) { var wg sync.WaitGroup results := make(map[string][]DeployResult) errCh := make(chan error, len(inventoryPaths)) diff --git a/backend/internal/grpcsrv/collector/collector.go b/backend/internal/grpcsrv/collector/collector.go index 17036e8..ab711ea 100644 --- a/backend/internal/grpcsrv/collector/collector.go +++ b/backend/internal/grpcsrv/collector/collector.go @@ -82,7 +82,10 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error { // If no ClickHouse, just consume the stream without storing if !c.logRepo.IsConnected() { - log.Printf("Warning: ClickHouse not connected yet, consuming logs without storing for agent %s", agentName) + log.Printf( + "Warning: ClickHouse not connected yet, consuming logs without storing for agent %s", + agentName, + ) for { _, err := stream.Recv() if err == io.EOF { @@ -120,7 +123,12 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error { return nil } if err := c.logRepo.InsertBatch(stream.Context(), batch); err != nil { - log.Printf("Failed to insert batch for agent %s, service %s: %v", agentName, service, err) + log.Printf( + "Failed to insert batch for agent %s, service %s: %v", + agentName, + service, + err, + ) return err } log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service) diff --git a/backend/internal/grpcsrv/commander/commander.go b/backend/internal/grpcsrv/commander/commander.go index 2bc35a9..804d9d8 100644 --- a/backend/internal/grpcsrv/commander/commander.go +++ b/backend/internal/grpcsrv/commander/commander.go @@ -95,7 +95,9 @@ func (self *Agent) WaitJob(jid int64) (*models.Job, error) { return &result.fc, result.err } -func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]) error { +func (self *Commander) Stream( + bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], +) error { md, ok := metadata.FromIncomingContext(bidi.Context()) if !ok { return fmt.Errorf("no metadata in context") @@ -164,7 +166,12 @@ func (self *Agent) send() error { // self.jobs[] } -func newAgent(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], jobber Jobber, aid string, label string) Agent { +func newAgent( + bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], + jobber Jobber, + aid string, + label string, +) Agent { return Agent{ bidi: bidi, in: make(chan *proto.Command), diff --git a/backend/internal/handlers/agent_deploy.go b/backend/internal/handlers/agent_deploy.go index 1352d73..7ade880 100644 --- a/backend/internal/handlers/agent_deploy.go +++ b/backend/internal/handlers/agent_deploy.go @@ -38,7 +38,7 @@ func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup { WorkDir: workDir, GRPCServerHost: "0.0.0.0", // TODO: make configurable GRPCServerPort: grpcPort, - BackendURL: backendURL, + BackendURL: backendURL, }) // Write playbooks on init diff --git a/backend/internal/handlers/agent_register.go b/backend/internal/handlers/agent_register.go index 0881fcc..3384719 100644 --- a/backend/internal/handlers/agent_register.go +++ b/backend/internal/handlers/agent_register.go @@ -104,7 +104,7 @@ func (arg *AgentRegistrationGroup) Register(c *gin.Context) { } type RegisterRequest struct { - CSR string `json:"csr" binding:"required"` + CSR string `json:"csr" binding:"required"` Token string `json:"token" binding:"required"` } diff --git a/backend/internal/handlers/agents.go b/backend/internal/handlers/agents.go index cc29142..e4dac65 100644 --- a/backend/internal/handlers/agents.go +++ b/backend/internal/handlers/agents.go @@ -1,9 +1,10 @@ package handlers import ( + "net/http" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector" "github.com/gin-gonic/gin" - "net/http" ) type AgentsGroup struct { diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 28329df..621e80d 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -90,6 +90,7 @@ func (ag *AuthGroup) RegisterUser(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/token [post] func (ag *AuthGroup) CreateToken(c *gin.Context) { var tc repository.TokenCreate @@ -113,6 +114,7 @@ func (ag *AuthGroup) CreateToken(c *gin.Context) { // @Produce json // @Success 200 {object} repository.Tokens // @Failure 401 {object} map[string]string +// @Security Bearer // @Router /auth/validate [get] func (ag *AuthGroup) ValidateToken(c *gin.Context) { tokenVal, exists := c.Get(string(tokenContextKey)) @@ -137,6 +139,7 @@ func (ag *AuthGroup) ValidateToken(c *gin.Context) { // @Produce json // @Success 200 {array} repository.Tokens // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/tokens [get] func (ag *AuthGroup) ListTokens(c *gin.Context) { tokens, err := ag.Repo.ListTokens() @@ -155,6 +158,7 @@ func (ag *AuthGroup) ListTokens(c *gin.Context) { // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/tokens/:login [delete] func (ag *AuthGroup) DeleteToken(c *gin.Context) { login := c.Param("login") @@ -182,6 +186,7 @@ func (ag *AuthGroup) DeleteToken(c *gin.Context) { // @Success 200 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/token [delete] func (ag *AuthGroup) DeleteMyToken(c *gin.Context) { tokenVal, exists := c.Get(string(tokenContextKey)) @@ -213,6 +218,7 @@ func (ag *AuthGroup) DeleteMyToken(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login/activate [post] func (ag *AuthGroup) ActivateUser(c *gin.Context) { login := c.Param("login") @@ -242,6 +248,7 @@ func (ag *AuthGroup) ActivateUser(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login/deactivate [post] func (ag *AuthGroup) DeactivateUser(c *gin.Context) { login := c.Param("login") @@ -269,6 +276,7 @@ func (ag *AuthGroup) DeactivateUser(c *gin.Context) { // @Produce json // @Success 200 {array} repository.Tokens // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/inactive [get] func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) { tokens, err := ag.Repo.ListInactiveTokens() @@ -289,6 +297,7 @@ func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login [get] func (ag *AuthGroup) GetUser(c *gin.Context) { login := c.Param("login") @@ -321,6 +330,7 @@ func (ag *AuthGroup) GetUser(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login [put] func (ag *AuthGroup) UpdateUser(c *gin.Context) { login := c.Param("login") @@ -358,6 +368,7 @@ func (ag *AuthGroup) UpdateUser(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login/permissions [put] func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) { login := c.Param("login") @@ -395,6 +406,7 @@ func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security Bearer // @Router /auth/users/:login/password [put] func (ag *AuthGroup) ResetUserPassword(c *gin.Context) { login := c.Param("login") diff --git a/backend/internal/handlers/cors.go b/backend/internal/handlers/cors.go index ab266c6..66c6b7d 100644 --- a/backend/internal/handlers/cors.go +++ b/backend/internal/handlers/cors.go @@ -20,8 +20,10 @@ func CorsMiddleware(origincfg string) gin.HandlerFunc { } c.Writer.Header().Set("Access-Control-Allow-Origin", origin) // c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, Authorization") - c.Writer.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH, DELETE, PUT") + c.Writer.Header(). + Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, Authorization") + c.Writer.Header(). + Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH, DELETE, PUT") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) diff --git a/backend/internal/handlers/jobs.go b/backend/internal/handlers/jobs.go index acf8de5..9288bd1 100644 --- a/backend/internal/handlers/jobs.go +++ b/backend/internal/handlers/jobs.go @@ -23,10 +23,10 @@ func NewJobsHandlers(cmder *commander.Commander, svc *service.ScriptService) Job } type AddJobIn struct { - Command string `json:"command" binding:"required"` + Command string `json:"command" binding:"required"` InterpreterID int64 `json:"interpreter_id"` Stdin *string `json:"stdin"` - AgentID string `json:"agent_id" binding:"required"` + AgentID string `json:"agent_id" binding:"required"` } type AddJobOut struct { ID int64 `json:"id"` @@ -45,6 +45,7 @@ type AddJobOut struct { // @Produce json // @Param body body AddJobIn true "Job request" // @Success 201 {object} AddJobOut +// @Security Bearer // @Router /jobs [post] func (self *JobsHandlers) AddJob(c *gin.Context) { err := func() error { @@ -63,7 +64,11 @@ func (self *JobsHandlers) AddJob(c *gin.Context) { command = []string{"sh", "-c", in.Command} } else { var err error - command, err = self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.Command) + command, err = self.svc.ResolveCommand( + c.Request.Context(), + in.InterpreterID, + in.Command, + ) if err != nil { return err } diff --git a/backend/internal/handlers/logs.go b/backend/internal/handlers/logs.go index 89d1f52..d8f8b95 100644 --- a/backend/internal/handlers/logs.go +++ b/backend/internal/handlers/logs.go @@ -20,10 +20,10 @@ func NewLogHandlers(logRepo *repository.LogRepository) *LogHandlers { type InsertLogRequest struct { Timestamp time.Time `json:"timestamp"` - Level string `json:"level" binding:"required"` - Service string `json:"service" binding:"required"` - Agent string `json:"agent" binding:"required"` - Message string `json:"message" binding:"required"` + Level string `json:"level" binding:"required"` + Service string `json:"service" binding:"required"` + Agent string `json:"agent" binding:"required"` + Message string `json:"message" binding:"required"` } // @Summary Insert log entry @@ -105,13 +105,13 @@ func (lh *LogHandlers) InsertBatch(c *gin.Context) { } type SearchLogsRequest struct { - Level string `form:"level"` - Service string `form:"service"` - Agent string `form:"agent"` + Level string `form:"level"` + Service string `form:"service"` + Agent string `form:"agent"` DateFrom string `form:"date_from"` DateTo string `form:"date_to"` - Limit int `form:"limit"` - Offset int `form:"offset"` + Limit int `form:"limit"` + Offset int `form:"offset"` } // @Summary Search logs diff --git a/backend/internal/handlers/scripts.go b/backend/internal/handlers/scripts.go index 6dbd295..4bae34d 100644 --- a/backend/internal/handlers/scripts.go +++ b/backend/internal/handlers/scripts.go @@ -22,9 +22,9 @@ func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) S } type RunScriptIn struct { - AgentID string `json:"agent_id" binding:"required"` + AgentID string `json:"agent_id" binding:"required"` InterpreterID int64 `json:"interpreter_id" binding:"required"` - ScriptText string `json:"script_text" binding:"required"` + ScriptText string `json:"script_text" binding:"required"` Stdin *string `json:"stdin"` } @@ -45,6 +45,7 @@ type RunScriptOut struct { // @Produce json // @Param body body RunScriptIn true "Script request" // @Success 201 {object} RunScriptOut +// @Security Bearer // @Router /scripts/run [post] func (self *ScriptHandlers) RunScript(c *gin.Context) { err := func() error { @@ -53,7 +54,11 @@ func (self *ScriptHandlers) RunScript(c *gin.Context) { return err } - command, err := self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText) + command, err := self.svc.ResolveCommand( + c.Request.Context(), + in.InterpreterID, + in.ScriptText, + ) if err != nil { return err } @@ -98,6 +103,7 @@ func (self *ScriptHandlers) RunScript(c *gin.Context) { // @Tags scripts // @Produce json // @Success 200 {array} repository.ScriptInterpreter +// @Security Bearer // @Router /scripts/interpreters [get] func (self *ScriptHandlers) ListInterpreters(c *gin.Context) { interpreters, err := self.svc.List(c.Request.Context()) @@ -116,6 +122,7 @@ func (self *ScriptHandlers) ListInterpreters(c *gin.Context) { // @Produce json // @Param body body repository.ScriptInterpreterCreate true "Interpreter definition" // @Success 201 {object} repository.ScriptInterpreter +// @Security Bearer // @Router /scripts/interpreters [post] func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) { var in repository.ScriptInterpreterCreate @@ -139,6 +146,7 @@ func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) { // @Produce json // @Param id path int true "Interpreter ID" // @Success 200 {object} repository.ScriptInterpreter +// @Security Bearer // @Router /scripts/interpreters/:id [get] func (self *ScriptHandlers) GetInterpreter(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -164,6 +172,7 @@ func (self *ScriptHandlers) GetInterpreter(c *gin.Context) { // @Param id path int true "Interpreter ID" // @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields" // @Success 200 {object} repository.ScriptInterpreter +// @Security Bearer // @Router /scripts/interpreters/:id [put] func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -192,6 +201,7 @@ func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) { // @Tags scripts // @Param id path int true "Interpreter ID" // @Success 204 +// @Security Bearer // @Router /scripts/interpreters/:id [delete] func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) diff --git a/backend/internal/repository/job_repository.go b/backend/internal/repository/job_repository.go index 84015e8..acef903 100644 --- a/backend/internal/repository/job_repository.go +++ b/backend/internal/repository/job_repository.go @@ -23,7 +23,11 @@ func (r *JobRepository) Init(ctx context.Context) error { return err } -func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) { +func (r *JobRepository) InitJob( + ctx context.Context, + agentID string, + job models.JobForInsert, +) (int64, error) { commandJSON, err := json.Marshal(job.Command) if err != nil { return 0, fmt.Errorf("marshal command: %w", err) @@ -34,9 +38,12 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models. stdinVal = job.Stdin } - result, err := r.DB.ExecContext(ctx, + result, err := r.DB.ExecContext( + ctx, `INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`, - agentID, string(commandJSON), stdinVal, + agentID, + string(commandJSON), + stdinVal, ) if err != nil { return 0, err @@ -45,10 +52,18 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models. return result.LastInsertId() } -func (r *JobRepository) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) { - result, err := r.DB.ExecContext(ctx, +func (r *JobRepository) UpdateJobInDB( + ctx context.Context, + jid int64, + msg models.JobForUpdate, +) (models.Job, error) { + result, err := r.DB.ExecContext( + ctx, `UPDATE jobs SET stdout = ?, stderr = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - msg.Stdout, msg.Stderr, msg.Status, jid, + msg.Stdout, + msg.Stderr, + msg.Status, + jid, ) if err != nil { return models.Job{}, err @@ -81,10 +96,10 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job, return models.Job{}, err } - if err := json.Unmarshal([]byte(commandJSON), &job.JobForInsert.Command); err != nil { + if err := json.Unmarshal([]byte(commandJSON), &job.Command); err != nil { return models.Job{}, fmt.Errorf("unmarshal command: %w", err) } - job.JobForInsert.Stdin = stdinVal + job.Stdin = stdinVal return job, nil } diff --git a/backend/internal/repository/log_repository.go b/backend/internal/repository/log_repository.go index 60221da..3851448 100644 --- a/backend/internal/repository/log_repository.go +++ b/backend/internal/repository/log_repository.go @@ -84,13 +84,13 @@ func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry } type LogFilter struct { - Level string - Service string - Agent string + Level string + Service string + Agent string DateFrom time.Time DateTo time.Time - Limit int - Offset int + Limit int + Offset int } func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) { @@ -157,7 +157,13 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage logs := make([]storage.LogEntry, 0) for rows.Next() { var log storage.LogEntry - if err := rows.Scan(&log.Timestamp, &log.Level, &log.Service, &log.Agent, &log.Message); err != nil { + if err := rows.Scan( + &log.Timestamp, + &log.Level, + &log.Service, + &log.Agent, + &log.Message, + ); err != nil { return nil, err } logs = append(logs, log) diff --git a/backend/internal/repository/models.go b/backend/internal/repository/models.go index 7ba44b8..83fbc03 100644 --- a/backend/internal/repository/models.go +++ b/backend/internal/repository/models.go @@ -2,23 +2,23 @@ package repository // Tokens represents a user record with info and permissions. type Tokens struct { - ID int64 `json:"id"` - Name string `json:"name"` - LastName string `json:"last_name"` - Login string `json:"login"` - Token string `json:"token"` - PermissionView bool `json:"permission_view"` - PermissionManage bool `json:"permission_manage_agent"` - PermissionAdmin bool `json:"permission_admin"` - IsActive bool `json:"is_active"` + ID int64 `json:"id"` + Name string `json:"name"` + LastName string `json:"last_name"` + Login string `json:"login"` + Token string `json:"token"` + PermissionView bool `json:"permission_view"` + PermissionManage bool `json:"permission_manage_agent"` + PermissionAdmin bool `json:"permission_admin"` + IsActive bool `json:"is_active"` } // TokenCreate is the request body for creating a new user. type TokenCreate struct { - Name string `json:"name" binding:"required"` - LastName string `json:"last_name" binding:"required"` - Login string `json:"login" binding:"required"` - Password string `json:"password" binding:"required"` + Name string `json:"name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` PermissionView bool `json:"permission_view"` PermissionManage bool `json:"permission_manage_agent"` PermissionAdmin bool `json:"permission_admin"` @@ -27,10 +27,10 @@ type TokenCreate struct { // UserRegister is the request body for public user registration (all permissions false). type UserRegister struct { - Name string `json:"name" binding:"required"` + Name string `json:"name" binding:"required"` LastName string `json:"last_name" binding:"required"` - Login string `json:"login" binding:"required"` - Password string `json:"password" binding:"required"` + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` } // TokenUpdate is the request body for updating an existing user. @@ -59,7 +59,7 @@ type BatchActionRequest struct { // LoginRequest is the request body for login. type LoginRequest struct { - Login string `json:"login" binding:"required"` + Login string `json:"login" binding:"required"` Password string `json:"password" binding:"required"` } @@ -117,14 +117,14 @@ const ( // AgentDeployConfig represents the configuration for deploying an agent to a server // @Description Configuration for deploying HellreigN agent to a single server type AgentDeployConfig struct { - User string `json:"user" binding:"required" example:"admin" description:"SSH username"` - IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"` - Port int `json:"port" example:"22" description:"SSH port (default: 22)"` - AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"` - SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"` - Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"` - DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"` - AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"` + User string `json:"user" binding:"required" example:"admin" description:"SSH username"` + IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"` + Port int `json:"port" example:"22" description:"SSH port (default: 22)"` + AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"` + SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"` + Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"` + DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"` + AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"` } // DeployAgentsRequest represents the request body for deploying agents to multiple servers @@ -137,15 +137,15 @@ type DeployAgentsRequest struct { // @Description Response containing deployment results and registration tokens type DeployResponse struct { Message string `json:"message" example:"Deployment completed"` - Results []DeployResult `json:"results" description:"Deployment results for each server"` + Results []DeployResult `json:"results" description:"Deployment results for each server"` } // DeployResult represents the result of deploying to a single server // @Description Result of deploying to a single server type DeployResult struct { - IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"` - AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"` - Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"` - Success bool `json:"success" example:"true" description:"Whether deployment succeeded"` - Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"` + IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"` + AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"` + Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"` + Success bool `json:"success" example:"true" description:"Whether deployment succeeded"` + Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"` } diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 842cac7..6eaa524 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -50,8 +50,15 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) { result, err := r.DB.Exec( `INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tc.Name, tc.LastName, tc.Login, string(hashed), token, - tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, tc.IsActive, + tc.Name, + tc.LastName, + tc.Login, + string(hashed), + token, + tc.PermissionView, + tc.PermissionManage, + tc.PermissionAdmin, + tc.IsActive, ) if err != nil { return "", err @@ -79,7 +86,11 @@ func (r *Repository) RegisterUser(ur UserRegister) (string, error) { result, err := r.DB.Exec( `INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0)`, - ur.Name, ur.LastName, ur.Login, string(hashed), token, + ur.Name, + ur.LastName, + ur.Login, + string(hashed), + token, ) if err != nil { return "", err @@ -450,7 +461,11 @@ func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissio result, err := r.DB.Exec( `UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`, - newView, newManage, newAdmin, newActive, login, + newView, + newManage, + newAdmin, + newActive, + login, ) if err != nil { return err diff --git a/backend/internal/repository/script_interpreter_repository.go b/backend/internal/repository/script_interpreter_repository.go index 6a5e807..d190c52 100644 --- a/backend/internal/repository/script_interpreter_repository.go +++ b/backend/internal/repository/script_interpreter_repository.go @@ -20,9 +20,9 @@ type ScriptInterpreter struct { } type ScriptInterpreterCreate struct { - Name string `json:"name" binding:"required"` + Name string `json:"name" binding:"required"` Label string `json:"label" binding:"required"` - Argv []string `json:"argv" binding:"required"` + Argv []string `json:"argv" binding:"required"` } type ScriptInterpreterUpdate struct { @@ -44,7 +44,10 @@ func (r *ScriptInterpreterRepo) Init(ctx context.Context) error { return err } -func (r *ScriptInterpreterRepo) Create(ctx context.Context, in ScriptInterpreterCreate) (*ScriptInterpreter, error) { +func (r *ScriptInterpreterRepo) Create( + ctx context.Context, + in ScriptInterpreterCreate, +) (*ScriptInterpreter, error) { argvJSON, err := json.Marshal(in.Argv) if err != nil { return nil, err @@ -71,7 +74,8 @@ func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptI var argvJSON string var createdAt, updatedAt string - err := r.DB.QueryRowContext(ctx, + 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) @@ -103,7 +107,14 @@ func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]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 { + 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 { @@ -116,7 +127,11 @@ func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, return interpreters, rows.Err() } -func (r *ScriptInterpreterRepo) Update(ctx context.Context, id int64, in ScriptInterpreterUpdate) (*ScriptInterpreter, error) { +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 diff --git a/backend/internal/service/script_service.go b/backend/internal/service/script_service.go index 7437230..b43bc04 100644 --- a/backend/internal/service/script_service.go +++ b/backend/internal/service/script_service.go @@ -17,7 +17,11 @@ func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService { // 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) { +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 @@ -33,11 +37,17 @@ func (self *ScriptService) ResolveCommand(ctx context.Context, interpreterID int return argv, nil } -func (self *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) { +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) { +func (self *ScriptService) GetByID( + ctx context.Context, + id int64, +) (*repository.ScriptInterpreter, error) { return self.repo.GetByID(ctx, id) } @@ -45,7 +55,11 @@ func (self *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpr return self.repo.List(ctx) } -func (self *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) { +func (self *ScriptService) Update( + ctx context.Context, + id int64, + in repository.ScriptInterpreterUpdate, +) (*repository.ScriptInterpreter, error) { return self.repo.Update(ctx, id, in) } diff --git a/backend/internal/storage/clickhouse.go b/backend/internal/storage/clickhouse.go index 5759f10..fd1bceb 100644 --- a/backend/internal/storage/clickhouse.go +++ b/backend/internal/storage/clickhouse.go @@ -43,7 +43,11 @@ func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) { } // OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff. -func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay time.Duration) (*sql.DB, error) { +func OpenClickHouseWithRetry( + cfg ClickHouseConfig, + maxRetries int, + initialDelay time.Duration, +) (*sql.DB, error) { var lastErr error delay := initialDelay @@ -53,10 +57,20 @@ func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay return db, nil } lastErr = err - log.Printf("ClickHouse connection attempt %d/%d failed: %v, retrying in %v...", i+1, maxRetries, err, delay) + log.Printf( + "ClickHouse connection attempt %d/%d failed: %v, retrying in %v...", + i+1, + maxRetries, + err, + delay, + ) time.Sleep(delay) delay *= 2 } - return nil, fmt.Errorf("clickhouse connection failed after %d attempts: %w", maxRetries, lastErr) + return nil, fmt.Errorf( + "clickhouse connection failed after %d attempts: %w", + maxRetries, + lastErr, + ) }