20 Commits

Author SHA1 Message Date
d3m0k1d 1f6908900b chore: add handlers for rename dir ans scripts
ci-agent / build (push) Failing after 2m50s
2026-04-05 03:29:36 +03:00
d3m0k1d 534d6aa738 fix: create folder
ci-agent / build (push) Failing after 2m40s
2026-04-05 03:19:32 +03:00
d3m0k1d aae27fa5e0 chore: add logic for scripts
ci-agent / build (push) Failing after 2m58s
2026-04-05 02:18:34 +03:00
d3m0k1d 3e5e4815d9 chore: add k8s and docker as service to agent and update logic for ansible deploy
ci-agent / build (push) Failing after 2m35s
2026-04-05 01:43:38 +03:00
zero@thinky 428140ff15 feat(backend): add job metrics
ci-agent / build (push) Failing after 3m1s
2026-04-05 00:44:57 +03:00
zero@thinky 7be99f8e91 feat: big ahh commit
- agent+proto+backend: transfer service status
- agent: fix returning empty message on nonzero exit status
- backend: refactor collector+commander and handlers dependent on them: implement agent accounting via grpc stats handler
2026-04-05 00:44:56 +03:00
d3m0k1d b516a54c17 fixsess and logic for web ide
ci-agent / build (push) Failing after 2m42s
2026-04-04 23:56:28 +03:00
d3m0k1d 1e4e65bb84 fix: agent init
ci-agent / build (push) Failing after 2m41s
2026-04-04 21:22:37 +03:00
zero@thinky 3389df740c feat!(proto): change service monitor from stream to unary
ci-agent / build (push) Failing after 2m34s
2026-04-04 20:46:28 +03:00
d3m0k1d d535831fc1 fix: fcking activate account
ci-agent / build (push) Failing after 2m55s
2026-04-04 20:39:48 +03:00
d3m0k1d f8c413a498 fix: reg
ci-agent / build (push) Failing after 2m36s
2026-04-04 20:17:51 +03:00
zero@thinky 134777de10 feat(backend): add sqlite to dockerfile for manual intervention
ci-agent / build (push) Failing after 3m0s
2026-04-04 20:07:41 +03:00
zero@thinky 4ea1aec6e2 feat(backend): implement service monitor proto & connect it to http /agents
ci-agent / build (push) Failing after 2m30s
2026-04-04 20:01:30 +03:00
zero@thinky 1d75935a08 feat(proto): add service monitor
ci-agent / build (push) Failing after 2m30s
2026-04-04 19:56:08 +03:00
d3m0k1d 0f8b148279 fix: linter and docs
ci-agent / build (push) Failing after 2m50s
2026-04-04 19:44:16 +03:00
zero@thinky fe7e41e4af fix(commander): missing job id on errors
ci-agent / build (push) Failing after 3m4s
2026-04-04 19:32:04 +03:00
zero@thinky 81d8f71937 feat(backend): drop default on jobs 2026-04-04 19:32:04 +03:00
d3m0k1d a71fde67e4 fix: user reg
ci-agent / build (push) Failing after 3m0s
2026-04-04 18:49:05 +03:00
zero@thinky 398c688fed fix race
ci-agent / build (push) Failing after 2m42s
2026-04-04 18:15:45 +03:00
zero@thinky 958211198c feat(backend): add cors 2026-04-04 17:53:35 +03:00
45 changed files with 6667 additions and 779 deletions
+81
View File
@@ -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 <contact@d3m0k1d.ru>
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"]
+1 -1
View File
@@ -3,7 +3,7 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
go 1.26.1 go 1.26.1
require ( require (
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
github.com/hpcloud/tail v1.0.0 github.com/hpcloud/tail v1.0.0
github.com/samber/lo v1.53.0 github.com/samber/lo v1.53.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0
+19 -18
View File
@@ -3,22 +3,19 @@ package commander
import ( import (
"bytes" "bytes"
"errors" "errors"
"io"
"os/exec"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"io"
"os/exec"
) )
type CommandExecutor struct { type CommandExecutor struct{}
}
func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand, error) { func (*CommandExecutor) Execute(command *proto.Command) (fc *proto.FinishedCommand, err error) {
fc = new(proto.FinishedCommand)
fc.Id = command.Id
cmd := exec.Command(command.Command[0], command.Command[1:]...) cmd := exec.Command(command.Command[0], command.Command[1:]...)
var ( var stdin io.WriteCloser
stdin io.WriteCloser
err error
)
if command.Stdin != nil { if command.Stdin != nil {
stdin, err = cmd.StdinPipe() stdin, err = cmd.StdinPipe()
if err != nil { if err != nil {
@@ -50,16 +47,20 @@ func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand,
_, err := io.Copy(stderrbuf, stderr) _, err := io.Copy(stderrbuf, stderr)
return err return err
}) })
if err := cmd.Wait(); err != nil { if waitErr := cmd.Wait(); waitErr != nil {
return nil, err var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) {
return nil, waitErr
}
fc.Status = int32(exitErr.ExitCode())
} else {
fc.Status = int32(cmd.ProcessState.ExitCode())
} }
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
return nil, err return nil, err
} }
return &proto.FinishedCommand{ fc.Status = int32(cmd.ProcessState.ExitCode())
Id: command.Id, fc.Stdout = stdoutbuf.String()
Status: int32(cmd.ProcessState.ExitCode()), fc.Stderr = stderrbuf.String()
Stdout: stdoutbuf.String(), return
Stderr: stderrbuf.String(),
}, nil
} }
+89
View File
@@ -0,0 +1,89 @@
package docker
import (
"bufio"
"fmt"
"io"
"os/exec"
"syscall"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
)
var _ logsource.LogSource = new(DockerLogSource)
// DockerLogSource reads logs from a Docker container via `docker logs -f`.
type DockerLogSource struct {
cmd *exec.Cmd
stdout io.ReadCloser
stdoutscanner *bufio.Scanner
}
// ReadLine implements logsource.LogSource.
func (d *DockerLogSource) ReadLine() (string, error) {
if d.stdoutscanner.Scan() {
return d.stdoutscanner.Text(), nil
} else {
if d.stdoutscanner.Err() == nil {
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
}
return "", d.stdoutscanner.Err()
}
}
// Close implements logsource.LogSource.
func (d *DockerLogSource) Close() error {
_ = d.cmd.Process.Signal(syscall.SIGTERM)
return d.cmd.Wait()
}
// New creates a Docker log source for the given container.
// The container name is taken from cfg.Path (if set) or cfg.Name.
func New(cfg config.ServiceConfig) (*DockerLogSource, error) {
containerName := cfg.Name
if cfg.Path != nil && *cfg.Path != "" {
containerName = *cfg.Path
}
// docker logs -f --tail=0 --no-color <container_name>
// -f : follow new logs
// --tail=0 : skip existing logs
// --no-color: strip color codes for clean output
cmd := exec.Command("docker", "logs", "-f", "--tail=0", "--no-color", containerName) //nolint:gosec
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdout pipe for docker logs: %w", err)
}
// Also capture stderr since docker logs merges stdout and stderr from the container
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stderr pipe for docker logs: %w", err)
}
err = cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to start docker logs for container %q: %w", containerName, err)
}
// Use MultiReader to merge stdout and stderr
// Docker logs outputs container stdout+stderr to its own stdout, but we also
// capture the docker CLI's stderr separately in case of errors (e.g. container not found)
stdoutscanner := bufio.NewScanner(stdout)
// Start a goroutine to consume stderr (we don't send docker CLI stderr as logs,
// but we need to prevent the pipe from filling up)
go func() {
buf := make([]byte, 4096)
for {
_, err := stderr.Read(buf)
if err != nil {
return
}
}
}()
return &DockerLogSource{cmd, stdout, stdoutscanner}, nil
}
@@ -0,0 +1,95 @@
package kubernetes
import (
"bufio"
"fmt"
"io"
"os/exec"
"strings"
"syscall"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
)
var _ logsource.LogSource = new(KubernetesLogSource)
// KubernetesLogSource reads logs from a Kubernetes pod via `kubectl logs -f`.
type KubernetesLogSource struct {
cmd *exec.Cmd
stdout io.ReadCloser
stdoutscanner *bufio.Scanner
}
// ReadLine implements logsource.LogSource.
func (k *KubernetesLogSource) ReadLine() (string, error) {
if k.stdoutscanner.Scan() {
return k.stdoutscanner.Text(), nil
} else {
if k.stdoutscanner.Err() == nil {
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
}
return "", k.stdoutscanner.Err()
}
}
// Close implements logsource.LogSource.
func (k *KubernetesLogSource) Close() error {
_ = k.cmd.Process.Signal(syscall.SIGTERM)
return k.cmd.Wait()
}
// New creates a Kubernetes log source for the given pod.
// The pod identifier is taken from cfg.Path in the format "namespace/podname".
// If no namespace is specified (just "podname"), "default" namespace is used.
// If cfg.Path is nil or empty, cfg.Name is used as the pod name with "default" namespace.
func New(cfg config.ServiceConfig) (*KubernetesLogSource, error) {
podName := cfg.Name
namespace := "default"
if cfg.Path != nil && *cfg.Path != "" {
parts := strings.SplitN(*cfg.Path, "/", 2)
if len(parts) == 2 {
namespace = parts[0]
podName = parts[1]
} else {
podName = parts[0]
}
}
// kubectl logs -f <pod> -n <namespace> --tail=0 --no-color
// -f : follow new logs
// --tail=0 : skip existing logs
// --no-color: strip color codes for clean output
cmd := exec.Command("kubectl", "logs", "-f", podName, "-n", namespace, "--tail=0", "--no-color") //nolint:gosec
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdout pipe for kubectl logs: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stderr pipe for kubectl logs: %w", err)
}
err = cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to start kubectl logs for pod %q (ns: %q): %w", podName, namespace, err)
}
stdoutscanner := bufio.NewScanner(stdout)
// Consume stderr to prevent pipe from filling up
go func() {
buf := make([]byte, 4096)
for {
_, err := stderr.Read(buf)
if err != nil {
return
}
}
}()
return &KubernetesLogSource{cmd, stdout, stdoutscanner}, nil
}
+69
View File
@@ -14,14 +14,17 @@ import (
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/docker"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/kubernetes"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"github.com/samber/lo" "github.com/samber/lo"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
) )
@@ -110,6 +113,13 @@ func main() {
return ccli.HandleCommands(ctx, grpcAddr, creds) return ccli.HandleCommands(ctx, grpcAddr, creds)
}) })
// Start services update stream
if len(cfg.Services) > 0 {
wg.Go(func() error {
return reportServices(ctx, grpcAddr, creds, cfg.Label, cfg.Services, lgr)
})
}
// Start log collectors // Start log collectors
if len(cfg.Services) > 0 { if len(cfg.Services) > 0 {
wg.Go(func() error { wg.Go(func() error {
@@ -139,6 +149,16 @@ func main() {
if err != nil { if err != nil {
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err) return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
} }
case "docker":
src, err = docker.New(svc)
if err != nil {
return fmt.Errorf("failed to create docker source for container %q: %w", svc.Name, err)
}
case "kubernetes":
src, err = kubernetes.New(svc)
if err != nil {
return fmt.Errorf("failed to create kubernetes source for pod %q: %w", svc.Name, err)
}
default: default:
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name) return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
} }
@@ -301,3 +321,52 @@ func reconnectStream(
return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service) return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service)
} }
// reportServices periodically sends service status updates to the backend via gRPC.
// For now, all configured services are reported as "up" every 5 seconds.
func reportServices(
ctx context.Context,
grpcAddr string,
creds credentials.TransportCredentials,
label string,
services []config.ServiceConfig,
lgr *logger.Logger,
) error {
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
if err != nil {
return fmt.Errorf("failed to connect for services report: %w", err)
}
defer conn.Close()
ccli := proto.NewCollectorClient(conn)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Send immediately on start, then every 5 seconds
for {
svcUpdates := make([]*proto.ServicesUpdate_ServiceUpdate, 0, len(services))
for _, svc := range services {
svcUpdates = append(svcUpdates, &proto.ServicesUpdate_ServiceUpdate{
Name: svc.Name,
Status: "up",
})
}
md := metadata.New(map[string]string{"whoami": label})
_, err := ccli.ReportServices(
metadata.NewOutgoingContext(ctx, md),
&proto.ServicesUpdate{Services: svcUpdates},
)
if err != nil {
lgr.Warn("Failed to report services", "err", err)
} else {
lgr.Debug("Services reported successfully", "count", len(services))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
+65 -12
View File
@@ -82,19 +82,29 @@ func main() {
}() }()
} }
// Initialize Collector gRPC service // Initialize Collector (log streaming) with its own ConnTracker
coll := collector.New(logRepo) collTracker := collector.NewConnTracker()
coll := collector.New(logRepo, collTracker)
cmdr := commander.New(jobRepo) // Initialize ConnTracker for Commander agent lifecycle
cmdTracker := commander.NewConnTracker()
cmdr := commander.New(jobRepo, cmdTracker)
// Initialize script interpreter repository and service // Initialize script interpreter repository and service
scriptRepo := repository.NewScriptInterpreterRepo(db) scriptRepo := repository.NewScriptInterpreterRepo(db)
if err := scriptRepo.Init(context.Background()); err != nil { if err := scriptRepo.Init(context.Background()); err != nil {
log.Printf("Warning: failed to initialize script interpreters table: %v", err) 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) scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker)
jobsHandlers := handlers.NewJobsHandlers(cmdr, scriptSvc) jobsHandlers := handlers.NewJobsHandlers(cmdTracker, scriptSvc,
os.Getenv("WHEREAMI"), /* our address for redirects */
jobRepo,
)
// Initialize script management service and handlers
scriptManageSvc := service.NewScriptService(h.Repo)
scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptManageSvc, cmdr)
agents := handlers.NewAgentsGroup(h, coll) agents := handlers.NewAgentsGroup(h, coll)
auth := handlers.AuthGroup{Handlers: h} auth := handlers.AuthGroup{Handlers: h}
@@ -130,6 +140,7 @@ func main() {
} }
router := gin.Default() router := gin.Default()
router.Use(handlers.CorsMiddleware("http://127.0.0.1:5173;http://localhost:5173"))
docs.SwaggerInfo.BasePath = "/api/v1" docs.SwaggerInfo.BasePath = "/api/v1"
docs.SwaggerInfo.Title = "HellreigN" docs.SwaggerInfo.Title = "HellreigN"
docs.SwaggerInfo.Version = "1.0" docs.SwaggerInfo.Version = "1.0"
@@ -143,13 +154,14 @@ func main() {
authGroup := v1.Group("/auth") authGroup := v1.Group("/auth")
{ {
authGroup.POST("/login", auth.Login) authGroup.POST("/login", auth.Login)
authGroup.POST("/register", auth.RegisterUser)
} }
// Auth token management (requires auth) // Auth token management (requires auth)
authTokenGroup := v1.Group("/auth") authTokenGroup := v1.Group("/auth")
authTokenGroup.Use(auth.AuthMiddleware()) authTokenGroup.Use(auth.AuthMiddleware())
{ {
authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken) authTokenGroup.POST("/token", auth.CreateToken)
authTokenGroup.GET("/validate", auth.ValidateToken) authTokenGroup.GET("/validate", auth.ValidateToken)
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens) authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
authTokenGroup.DELETE("/token", auth.DeleteMyToken) authTokenGroup.DELETE("/token", auth.DeleteMyToken)
@@ -158,12 +170,28 @@ func main() {
// User management (admin only) - Full CRUD // User management (admin only) - Full CRUD
authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser) authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser)
authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser) authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser)
authTokenGroup.PUT("/users/:login/permissions", handlers.RequireAdmin(), auth.UpdateUserPermissions) authTokenGroup.PUT(
authTokenGroup.PUT("/users/:login/password", handlers.RequireAdmin(), auth.ResetUserPassword) "/users/:login/permissions",
handlers.RequireAdmin(),
auth.UpdateUserPermissions,
)
authTokenGroup.PUT(
"/users/:login/password",
handlers.RequireAdmin(),
auth.ResetUserPassword,
)
// User activation management (admin only) // User activation management (admin only)
authTokenGroup.POST("/users/:login/activate", handlers.RequireAdmin(), auth.ActivateUser) authTokenGroup.POST(
authTokenGroup.POST("/users/:login/deactivate", handlers.RequireAdmin(), auth.DeactivateUser) "/users/:login/activate",
handlers.RequireAdmin(),
auth.ActivateUser,
)
authTokenGroup.POST(
"/users/:login/deactivate",
handlers.RequireAdmin(),
auth.DeactivateUser,
)
authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers) authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers)
} }
@@ -179,6 +207,9 @@ func main() {
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin()) jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
{ {
jobsGroup.POST("", jobsHandlers.AddJob) jobsGroup.POST("", jobsHandlers.AddJob)
jobsGroup.POST("/:id/wait", jobsHandlers.WaitJob)
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
jobsGroup.POST("/check_cmd", jobsHandlers.CheckCmd)
} }
// Agent registration // Agent registration
@@ -221,6 +252,24 @@ func main() {
scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter) scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter)
scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter) scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter)
scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter) 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)
// Folder management
scriptsGroup.POST("/folder", scriptManageHandlers.CreateFolder)
scriptsGroup.DELETE("/folder", scriptManageHandlers.DeleteFolder)
// Rename script or folder
scriptsGroup.POST("/rename", scriptManageHandlers.Rename)
// Get script by path
scriptsGroup.GET("/by-path", scriptManageHandlers.GetScriptByPath)
} }
} }
@@ -260,7 +309,11 @@ func main() {
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
} }
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) grpcServer := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsConfig)),
grpc.StatsHandler(collTracker),
grpc.StatsHandler(cmdTracker),
)
proto.RegisterCommanderServer(grpcServer, cmdr) proto.RegisterCommanderServer(grpcServer, cmdr)
proto.RegisterCollectorServer(grpcServer, coll) proto.RegisterCollectorServer(grpcServer, coll)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM alpine:3.23.0 FROM alpine:3.23.0
RUN apk add --no-cache curl openssl bash ansible RUN apk add --no-cache curl openssl bash ansible sqlite
COPY --from=builder /app/backend/backend . COPY --from=builder /app/backend/backend .
COPY --from=builder /app/backend/scripts /etc/hellreign/scripts COPY --from=builder /app/backend/scripts /etc/hellreign/scripts
+1535 -125
View File
File diff suppressed because it is too large Load Diff
+1535 -125
View File
File diff suppressed because it is too large Load Diff
+997 -96
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,9 +3,10 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
go 1.26.1 go 1.26.1
require ( require (
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/samber/lo v1.53.0
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
+2
View File
@@ -138,6 +138,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+57 -2
View File
@@ -7,15 +7,20 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
) )
// ErrUnknownDeployType is returned when an unsupported deployment type is specified
var ErrUnknownDeployType = fmt.Errorf("unknown deploy type, expected 'docker' or 'binary'")
// Executor handles running Ansible playbooks // Executor handles running Ansible playbooks
type Executor struct { type Executor struct {
workDir string workDir string
grpcServerHost string grpcServerHost string
grpcServerPort string grpcServerPort string
backendURL string backendURL string
giteaReleasesURL string
} }
// ExecutorConfig holds configuration for the Executor // ExecutorConfig holds configuration for the Executor
@@ -24,6 +29,7 @@ type ExecutorConfig struct {
GRPCServerHost string GRPCServerHost string
GRPCServerPort string GRPCServerPort string
BackendURL string BackendURL string
GiteaReleasesURL string
} }
// NewExecutor creates a new Ansible executor // NewExecutor creates a new Ansible executor
@@ -33,6 +39,7 @@ func NewExecutor(cfg ExecutorConfig) *Executor {
grpcServerHost: cfg.GRPCServerHost, grpcServerHost: cfg.GRPCServerHost,
grpcServerPort: cfg.GRPCServerPort, grpcServerPort: cfg.GRPCServerPort,
backendURL: cfg.BackendURL, backendURL: cfg.BackendURL,
giteaReleasesURL: cfg.GiteaReleasesURL,
} }
} }
@@ -50,8 +57,47 @@ func (e *Executor) WorkDir() string {
return e.workDir return e.workDir
} }
// GRPCURL returns the gRPC server URL (host:port)
func (e *Executor) GRPCURL() string {
return e.grpcServerHost + ":" + e.grpcServerPort
}
// CheckDockerCollection verifies that the community.docker Ansible collection is installed.
// Returns an error if the collection is not found.
func (e *Executor) CheckDockerCollection() error {
cmd := exec.Command("ansible-galaxy", "collection", "list", "community.docker")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("community.docker collection not found: %s", stderr.String())
}
// ansible-galaxy collection list returns output like:
// # /usr/share/ansible/collections/ansible_collections
// Collection Version
// ---------------- -------
// community.docker 3.10.0
//
// If the collection is not installed, it won't appear in the output.
if !strings.Contains(stdout.String(), "community.docker") {
return fmt.Errorf("community.docker collection is not installed. Run: ansible-galaxy collection install community.docker")
}
return nil
}
// Deploy runs Ansible playbook for the given inventory // 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) {
if deployType != "docker" && deployType != "binary" {
return nil, fmt.Errorf("invalid deploy type %q: %w", deployType, ErrUnknownDeployType)
}
playbookName := "binary_deploy.yml" playbookName := "binary_deploy.yml"
if deployType == "docker" { if deployType == "docker" {
playbookName = "docker_deploy.yml" playbookName = "docker_deploy.yml"
@@ -62,6 +108,8 @@ func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType
cmd := exec.CommandContext(ctx, "ansible-playbook", cmd := exec.CommandContext(ctx, "ansible-playbook",
"-i", inventoryPath, "-i", inventoryPath,
"-e", fmt.Sprintf("backend_url=%s", e.backendURL), "-e", fmt.Sprintf("backend_url=%s", e.backendURL),
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
"-e", fmt.Sprintf("gitea_releases_url=%s", e.giteaReleasesURL),
playbookPath, playbookPath,
) )
@@ -84,8 +132,13 @@ func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType
} }
// DeployParallel runs Ansible playbook for multiple inventories in parallel // 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 var wg sync.WaitGroup
var mu sync.Mutex
results := make(map[string][]DeployResult) results := make(map[string][]DeployResult)
errCh := make(chan error, len(inventoryPaths)) errCh := make(chan error, len(inventoryPaths))
@@ -97,7 +150,9 @@ func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string,
if err != nil { if err != nil {
errCh <- err errCh <- err
} }
mu.Lock()
results[p] = res results[p] = res
mu.Unlock()
}(path) }(path)
} }
+8 -9
View File
@@ -18,6 +18,7 @@ type InventoryHost struct {
Password string Password string
DeployType string DeployType string
Token string Token string
GRPCURL string
} }
// Inventory represents an Ansible inventory file // Inventory represents an Ansible inventory file
@@ -25,15 +26,13 @@ type Inventory struct {
Hosts []InventoryHost Hosts []InventoryHost
} }
const inventoryTemplateText = `{{ range .Hosts }} const inventoryTemplateText = `{{- range $i, $host := .Hosts }}
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh {{ $host.Name }} ansible_host={{ $host.IP }} ansible_port={{ $host.Port }} ansible_user={{ $host.User }} ansible_connection=ssh{{ if eq $host.AuthMethod "key" }} ansible_ssh_private_key_file={{ $host.SSHKey }}{{ end }}{{ if eq $host.AuthMethod "password" }} ansible_ssh_pass={{ $host.Password }}{{ end }}
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }} deploy_type={{ $host.DeployType }}
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }} agent_token={{ $host.Token }}
deploy_type={{ .DeployType }} agent_label={{ $host.Name }}
agent_token={{ .Token }} grpc_url={{ $host.GRPCURL }}
agent_label={{ .Name }} {{ end -}}`
{{ end }}`
// GenerateInventory generates an Ansible inventory file from the given hosts // GenerateInventory generates an Ansible inventory file from the given hosts
func GenerateInventory(hosts []InventoryHost, outputPath string) error { func GenerateInventory(hosts []InventoryHost, outputPath string) error {
+39 -12
View File
@@ -1,6 +1,7 @@
package ansible 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 installs a systemd unit for automatic restart.
const BinaryDeployPlaybook = `--- const BinaryDeployPlaybook = `---
- name: Deploy HellreigN Agent (Binary) - name: Deploy HellreigN Agent (Binary)
hosts: all hosts: all
@@ -11,8 +12,8 @@ const BinaryDeployPlaybook = `---
backend_url: "{{ backend_url }}" backend_url: "{{ backend_url }}"
install_dir: /opt/hellreign install_dir: /opt/hellreign
bin_name: hellreign-agent bin_name: hellreign-agent
service_name: hellreign-agent
cert_dir: "{{ install_dir }}/certs" cert_dir: "{{ install_dir }}/certs"
gitea_releases_url: "{{ gitea_releases_url | default('https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download') }}"
tasks: tasks:
- name: Create installation directory - name: Create installation directory
@@ -29,7 +30,7 @@ const BinaryDeployPlaybook = `---
- name: Download HellreigN Agent binary - name: Download HellreigN Agent binary
get_url: get_url:
url: "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download/{{ bin_name }}" url: "{{ gitea_releases_url }}/{{ bin_name }}"
dest: "{{ install_dir }}/{{ bin_name }}" dest: "{{ install_dir }}/{{ bin_name }}"
mode: '0755' mode: '0755'
@@ -37,18 +38,23 @@ const BinaryDeployPlaybook = `---
copy: copy:
content: | content: |
backend_url: "{{ backend_url }}" backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
label: "{{ agent_label }}" label: "{{ agent_label }}"
registration_token: "{{ agent_token }}" registration_token: "{{ agent_token }}"
cert_dir: "{{ cert_dir }}" cert_dir: "{{ cert_dir }}"
services:
- name: system
type: journald
dest: "{{ install_dir }}/config.yml" dest: "{{ install_dir }}/config.yml"
mode: '0644' mode: '0644'
- name: Create systemd service file - name: Create systemd unit file
copy: copy:
content: | content: |
[Unit] [Unit]
Description=HellreigN Agent Description=HellreigN Agent
After=network.target After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
@@ -56,12 +62,10 @@ const BinaryDeployPlaybook = `---
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=CONFIG_FILE={{ install_dir }}/config.yml Environment=CONFIG_FILE={{ install_dir }}/config.yml
StandardOutput=journal
StandardError=journal
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
dest: /etc/systemd/system/{{ service_name }}.service dest: /etc/systemd/system/hellreign-agent.service
mode: '0644' mode: '0644'
- name: Reload systemd daemon - name: Reload systemd daemon
@@ -70,12 +74,20 @@ const BinaryDeployPlaybook = `---
- name: Enable and start HellreigN Agent service - name: Enable and start HellreigN Agent service
systemd: systemd:
name: "{{ service_name }}" name: hellreign-agent
enabled: yes enabled: yes
state: started state: started
- name: Wait for agent to start
pause:
seconds: 3
- name: Verify HellreigN Agent is running
command: systemctl is-active --quiet hellreign-agent
changed_when: false
` `
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment // DockerDeployPlaybook returns the Ansible playbook for Docker deployment.
const DockerDeployPlaybook = `--- const DockerDeployPlaybook = `---
- name: Deploy HellreigN Agent (Docker) - name: Deploy HellreigN Agent (Docker)
hosts: all hosts: all
@@ -84,9 +96,12 @@ const DockerDeployPlaybook = `---
agent_label: "{{ agent_label }}" agent_label: "{{ agent_label }}"
agent_token: "{{ agent_token }}" agent_token: "{{ agent_token }}"
backend_url: "{{ backend_url }}" backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
container_name: hellreign-agent-{{ agent_label }} container_name: hellreign-agent-{{ agent_label }}
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest" image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
install_dir: /opt/hellreign
cert_dir: /etc/hellreign-agent/certs cert_dir: /etc/hellreign-agent/certs
config_dir: /etc/hellreign-agent
tasks: tasks:
- name: Install Docker (if not present) - name: Install Docker (if not present)
@@ -108,6 +123,12 @@ const DockerDeployPlaybook = `---
state: directory state: directory
mode: '0755' mode: '0755'
- name: Create configuration directory
file:
path: "{{ config_dir }}"
state: directory
mode: '0755'
- name: Pull HellreigN Agent image - name: Pull HellreigN Agent image
community.docker.docker_image: community.docker.docker_image:
name: "{{ image }}" name: "{{ image }}"
@@ -117,10 +138,15 @@ const DockerDeployPlaybook = `---
copy: copy:
content: | content: |
backend_url: "{{ backend_url }}" backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
label: "{{ agent_label }}" label: "{{ agent_label }}"
registration_token: "{{ agent_token }}" registration_token: "{{ agent_token }}"
cert_dir: "{{ cert_dir }}" cert_dir: "{{ cert_dir }}"
dest: "{{ cert_dir }}/config.yml" services:
- name: "{{ agent_label }}"
type: docker
path: "{{ container_name }}"
dest: "{{ config_dir }}/config.yml"
mode: '0644' mode: '0644'
- name: Create and run HellreigN Agent container - name: Create and run HellreigN Agent container
@@ -131,6 +157,7 @@ const DockerDeployPlaybook = `---
restart_policy: always restart_policy: always
volumes: volumes:
- "{{ cert_dir }}:/etc/hellreign-agent/certs" - "{{ cert_dir }}:/etc/hellreign-agent/certs"
- "{{ config_dir }}/config.yml:/etc/hellreign-agent/config.yml:ro"
env: env:
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml CONFIG_FILE: /etc/hellreign-agent/config.yml
` `
+2 -3
View File
@@ -1,5 +1,4 @@
package ansible package ansible
const BaseInvTemplate = ` // This package contains embedded Ansible templates for playbooks and inventory generation.
// All templates are defined in playbooks.go and inventory.go.
`
+22 -43
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"sync"
"time" "time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
@@ -13,26 +12,19 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
) )
// Collector handles log streaming from connected agents.
type Collector struct { type Collector struct {
proto.UnimplementedCollectorServer proto.UnimplementedCollectorServer
logRepo *repository.LogRepository logRepo *repository.LogRepository
agents map[string]*Agent tracker *ConnTracker
mu sync.RWMutex
batchSize int batchSize int
flushInterval time.Duration flushInterval time.Duration
} }
type Agent struct { func New(logRepo *repository.LogRepository, tracker *ConnTracker) *Collector {
ID string
Label string
Services []string
ConnectedAt time.Time
}
func New(logRepo *repository.LogRepository) *Collector {
return &Collector{ return &Collector{
logRepo: logRepo, logRepo: logRepo,
agents: make(map[string]*Agent), tracker: tracker,
batchSize: 100, batchSize: 100,
flushInterval: 2 * time.Second, flushInterval: 2 * time.Second,
} }
@@ -56,33 +48,24 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
service := serviceVals[0] service := serviceVals[0]
servicesVals := md["services"] agent := &Agent{
var services []string
if len(servicesVals) > 0 {
services = servicesVals
}
// Register agent
c.mu.Lock()
c.agents[agentName] = &Agent{
ID: agentName, ID: agentName,
Label: agentName, Label: agentName,
Services: services, Services: make([]Service, 0),
ConnectedAt: time.Now(), ConnectedAt: time.Now(),
} }
c.mu.Unlock()
defer func() { c.tracker.Register(agent)
c.mu.Lock() defer c.tracker.Unregister(agent.ID)
delete(c.agents, agentName)
c.mu.Unlock()
}()
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service) log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
// If no ClickHouse, just consume the stream without storing // If no ClickHouse, just consume the stream without storing
if !c.logRepo.IsConnected() { 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 { for {
_, err := stream.Recv() _, err := stream.Recv()
if err == io.EOF { if err == io.EOF {
@@ -120,7 +103,12 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
return nil return nil
} }
if err := c.logRepo.InsertBatch(stream.Context(), batch); err != 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 return err
} }
log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service) log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service)
@@ -131,7 +119,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
for { for {
select { select {
case <-stream.Context().Done(): case <-stream.Context().Done():
// Context cancelled, flush remaining
_ = flush() _ = flush()
return stream.Context().Err() return stream.Context().Err()
case <-ticker.C: case <-ticker.C:
@@ -154,7 +141,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
case err := <-errCh: case err := <-errCh:
if err == io.EOF { if err == io.EOF {
// Client closed stream
return flush() return flush()
} }
return fmt.Errorf("failed to receive: %w", err) return fmt.Errorf("failed to receive: %w", err)
@@ -162,19 +148,12 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
} }
// GetAgent delegates to the tracker.
func (c *Collector) GetAgent(name string) (*Agent, bool) { func (c *Collector) GetAgent(name string) (*Agent, bool) {
c.mu.RLock() return c.tracker.GetAgent(name)
defer c.mu.RUnlock()
a, ok := c.agents[name]
return a, ok
} }
// Agents delegates to the tracker.
func (c *Collector) Agents() []*Agent { func (c *Collector) Agents() []*Agent {
c.mu.RLock() return c.tracker.Agents()
defer c.mu.RUnlock()
result := make([]*Agent, 0, len(c.agents))
for _, a := range c.agents {
result = append(result, a)
}
return result
} }
@@ -0,0 +1,38 @@
package collector
import (
"context"
"fmt"
"log"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"google.golang.org/grpc/metadata"
)
// ReportServices handles a unary service status update from an agent.
// Agents send their current services list, which is stored in the collector.
func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdate) (*proto.ServicesUpdateResp, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("no metadata in context")
}
whoamiVals := md["whoami"]
if len(whoamiVals) == 0 {
return nil, fmt.Errorf("whoami metadata missing")
}
agentName := whoamiVals[0]
services := make([]Service, 0, len(req.Services))
for _, s := range req.Services {
services = append(services, Service{s.Name, s.Status})
}
if ok := c.tracker.UpdateServices(agentName, services); ok {
log.Printf("Updated services for agent %s: %v", agentName, services)
} else {
log.Printf("Warning: received services update for unknown agent %s", agentName)
}
return &proto.ServicesUpdateResp{}, nil
}
@@ -0,0 +1,111 @@
package collector
import (
"context"
"log"
"sync"
"time"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
)
// ConnTracker tracks connected Collector agents and handles cleanup on disconnect.
// It implements grpc.StatsHandler for disconnect detection.
type ConnTracker struct {
mu sync.RWMutex
agents map[string]*Agent
}
func NewConnTracker() *ConnTracker {
return &ConnTracker{
agents: make(map[string]*Agent),
}
}
// Register adds an agent to the tracker. Called by Collector.Stream().
func (t *ConnTracker) Register(agent *Agent) {
t.mu.Lock()
t.agents[agent.ID] = agent
t.mu.Unlock()
log.Printf("[collector] agent registered: %s", agent.ID)
}
// Unregister removes an agent from the tracker.
func (t *ConnTracker) Unregister(id string) {
t.mu.Lock()
delete(t.agents, id)
t.mu.Unlock()
log.Printf("[collector] agent unregistered: %s", id)
}
// GetAgent returns the agent for the given ID.
func (t *ConnTracker) GetAgent(id string) (*Agent, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
a, ok := t.agents[id]
return a, ok
}
// Agents returns all connected agents.
func (t *ConnTracker) Agents() []*Agent {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Agent, 0, len(t.agents))
for _, a := range t.agents {
result = append(result, a)
}
return result
}
// grpc.StatsHandler implementation.
func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
return ctx
}
func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
return ctx
}
func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
switch s.(type) {
case *stats.ConnEnd:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return
}
whoamiVals := md["whoami"]
if len(whoamiVals) == 0 {
return
}
t.Unregister(whoamiVals[0])
}
}
// UpdateServices updates the services list for the given agent.
func (t *ConnTracker) UpdateServices(id string, services []Service) bool {
t.mu.Lock()
defer t.mu.Unlock()
agent, ok := t.agents[id]
if !ok {
return false
}
agent.Services = services
return true
}
// Service represents a named service with its current status.
type Service struct {
Name, Status string
}
// Agent represents a connected agent streaming logs to the collector.
type Agent struct {
ID string
Label string
Services []Service
ConnectedAt time.Time
}
+130 -72
View File
@@ -12,27 +12,30 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
) )
// Commander handles command execution on connected agents.
type Commander struct { type Commander struct {
proto.UnimplementedCommanderServer proto.UnimplementedCommanderServer
agents map[string]Agent tracker *ConnTracker
mu sync.RWMutex
jobber Jobber jobber Jobber
} }
// Jobber persists job state.
type Jobber interface { type Jobber interface {
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
} }
func New(jobber Jobber) *Commander { func New(jobber Jobber, tracker *ConnTracker) *Commander {
return &Commander{ return &Commander{
agents: make(map[string]Agent),
jobber: jobber, jobber: jobber,
tracker: tracker,
} }
} }
// Agent represents a connected agent with an active bidirectional stream.
type Agent struct { type Agent struct {
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command] bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
in chan *proto.Command in chan *proto.Command
@@ -41,10 +44,11 @@ type Agent struct {
ctx context.Context ctx context.Context
aid string aid string
Token string // agent id Token string
Label string Label string
Services []string Services []string
} }
type JobOut struct { type JobOut struct {
fc models.Job fc models.Job
err error err error
@@ -54,54 +58,93 @@ type Job struct {
out chan JobOut out chan JobOut
} }
func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) { // ConnTracker tracks connected agents and handles cleanup on disconnect.
// It implements grpc.StatsHandler for disconnect detection.
type ConnTracker struct {
mu sync.RWMutex
agents map[string]*Agent
}
// GetAgentByLabel searches for an agent by its human-readable label.
func (self *ConnTracker) GetAgentByLabel(label string) (agent Agent, ok bool) {
self.mu.RLock() self.mu.RLock()
defer self.mu.RUnlock() defer self.mu.RUnlock()
agent, ok = self.agents[aid] for _, a := range self.agents {
if a.Label == label {
return *a, true
}
}
return return
} }
func (self *Commander) Agents() []Agent { func NewConnTracker() *ConnTracker {
self.mu.RLock() return &ConnTracker{
defer self.mu.RUnlock() agents: make(map[string]*Agent),
result := make([]Agent, 0, len(self.agents)) }
for _, a := range self.agents { }
func (t *ConnTracker) Register(aid string, agent *Agent) {
t.mu.Lock()
t.agents[aid] = agent
t.mu.Unlock()
log.Printf("[conntracker] agent registered: %s", aid)
}
func (t *ConnTracker) Unregister(aid string) {
t.mu.Lock()
delete(t.agents, aid)
t.mu.Unlock()
log.Printf("[conntracker] agent unregistered: %s", aid)
}
func (t *ConnTracker) GetAgent(aid string) (*Agent, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
a, ok := t.agents[aid]
return a, ok
}
func (t *ConnTracker) Agents() []*Agent {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Agent, 0, len(t.agents))
for _, a := range t.agents {
result = append(result, a) result = append(result, a)
} }
return result return result
} }
func (self *Commander) removeAgent(aid string) { // grpc.StatsHandler implementation.
self.mu.Lock()
defer self.mu.Unlock() func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
delete(self.agents, aid) return ctx
} }
func (self *Agent) AddJob(job models.JobForInsert) (int64, error) { func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
log.Printf("[DEBUG] AddJob: agent=%s, command=%v", self.aid, job.Command)
jid, err := self.jobber.InitJob(self.ctx, self.aid, job) func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
if err != nil { return ctx
log.Printf("[DEBUG] AddJob: InitJob failed: %v", err)
return 0, err
}
log.Printf("[DEBUG] AddJob: InitJob returned jid=%d, sending to self.in channel", jid)
self.in <- &proto.Command{
Id: jid,
Command: job.Command,
Stdin: job.Stdin,
}
log.Printf("[DEBUG] AddJob: sent to self.in channel successfully")
return jid, err
} }
func (self *Agent) WaitJob(jid int64) (*models.Job, error) { func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, waiting on self.jobs[%d].out", self.aid, jid, jid) switch s.(type) {
result := <-self.jobs[jid].out case *stats.ConnEnd:
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, received result", self.aid, jid) md, ok := metadata.FromIncomingContext(ctx)
return &result.fc, result.err if !ok {
return
}
aidVals := md["agentid"]
if len(aidVals) == 0 {
return
}
t.Unregister(aidVals[0])
}
} }
func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]) error { // Stream handles a new agent connection and runs the send/recv loops.
func (c *Commander) Stream(
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command],
) error {
md, ok := metadata.FromIncomingContext(bidi.Context()) md, ok := metadata.FromIncomingContext(bidi.Context())
if !ok { if !ok {
return fmt.Errorf("no metadata in context") return fmt.Errorf("no metadata in context")
@@ -113,80 +156,95 @@ func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedComman
aid := aidVals[0] aid := aidVals[0]
var label string var label string
labelVals := md["label"] if vals := md["label"]; len(vals) > 0 {
if len(labelVals) > 0 { label = vals[0]
label = labelVals[0]
} }
agent := newAgent(bidi, self.jobber, aid, label) agent := NewAgent(bidi.Context(), c.jobber, aid, label)
self.mu.Lock() agent.bidi = bidi
self.agents[aid] = agent
self.mu.Unlock() c.tracker.Register(aid, agent)
defer c.tracker.Unregister(aid)
defer self.removeAgent(aid)
return agent.run() return agent.run()
} }
func (self *Agent) run() error { // GetAgent returns the agent by ID. Delegates to the tracker.
func (c *Commander) GetAgent(aid string) (*Agent, bool) {
return c.tracker.GetAgent(aid)
}
func (a *Agent) AddJob(job models.JobForInsert) (int64, error) {
jid, err := a.jobber.InitJob(a.ctx, a.aid, job)
if err != nil {
return 0, err
}
a.jobs[jid] = newJob()
a.in <- &proto.Command{
Id: jid,
Command: job.Command,
Stdin: job.Stdin,
}
return jid, nil
}
func (a *Agent) WaitJob(jid int64) (*models.Job, error) {
result := <-a.jobs[jid].out
return &result.fc, result.err
}
func (a *Agent) run() error {
wg := new(errgroup.Group) wg := new(errgroup.Group)
wg.Go(self.recv) wg.Go(a.recv)
wg.Go(self.send) wg.Go(a.send)
return wg.Wait() return wg.Wait()
} }
func (self *Agent) recv() error { func (a *Agent) recv() error {
for { for {
job, err := func() (job models.Job, err error) { job, err := func() (job models.Job, err error) {
msg, err := self.bidi.Recv() msg, err := a.bidi.Recv()
if err != nil { if err != nil {
return return
} }
log.Printf("[DEBUG] recv: agent=%s, received finished job id=%d", self.aid, msg.Id) return a.jobber.UpdateJobInDB(a.ctx, msg.Id, models.JobForUpdate{
return self.jobber.UpdateJobInDB(self.ctx, msg.Id, models.JobForUpdate{
Stdout: msg.Stdout, Stdout: msg.Stdout,
Stderr: msg.Stderr, Stderr: msg.Stderr,
Status: msg.Status, Status: msg.Status,
}) })
}() }()
if err == io.EOF { if err == io.EOF {
log.Printf("[DEBUG] recv: agent=%s, EOF received", self.aid)
return nil return nil
} }
if err != nil { out := a.jobs[job.ID].out
log.Printf("[DEBUG] recv: agent=%s, error: %v", self.aid, err)
}
out := self.jobs[job.ID].out
out <- JobOut{ out <- JobOut{
fc: job, fc: job,
err: err, err: err,
} }
close(out) close(out)
log.Printf("[DEBUG] recv: agent=%s, sent result for job id=%d", self.aid, job.ID)
} }
} }
func (self *Agent) send() error { func (a *Agent) send() error {
for job := range self.in { for job := range a.in {
log.Printf("[DEBUG] send: agent=%s, job id=%d, command=%v", self.aid, job.Id, job.Command) if err := a.bidi.Send(job); err != nil {
self.jobs[job.Id] = newJob()
if err := self.bidi.Send(job); err != nil {
log.Printf("[DEBUG] send: agent=%s, failed to send job id=%d: %v", self.aid, job.Id, err)
return err return err
} }
log.Printf("[DEBUG] send: agent=%s, sent job id=%d to agent", self.aid, job.Id)
} }
log.Printf("[DEBUG] send: agent=%s, self.in channel closed", self.aid)
return io.EOF return io.EOF
// self.jobs[]
} }
func newAgent(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], jobber Jobber, aid string, label string) Agent { func NewAgent(
return Agent{ ctx context.Context,
bidi: bidi, jobber Jobber,
in: make(chan *proto.Command), aid string,
label string,
) *Agent {
return &Agent{
in: make(chan *proto.Command, 10),
jobs: make(map[int64]Job), jobs: make(map[int64]Job),
jobber: jobber, jobber: jobber,
ctx: bidi.Context(), ctx: ctx,
aid: aid, aid: aid,
Label: label, Label: label,
Token: aid, Token: aid,
+67 -5
View File
@@ -29,22 +29,33 @@ func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
grpcPort = "9001" grpcPort = "9001"
} }
grpcHost := os.Getenv("GRPC_SERVER_HOST")
if grpcHost == "" {
grpcHost = "0.0.0.0"
}
backendURL := os.Getenv("BACKEND_URL") backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" { if backendURL == "" {
backendURL = "http://localhost:8080" backendURL = "http://localhost:8080"
} }
giteaURL := os.Getenv("GITEA_RELEASES_URL")
if giteaURL == "" {
giteaURL = "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download"
}
exec := ansible.NewExecutor(ansible.ExecutorConfig{ exec := ansible.NewExecutor(ansible.ExecutorConfig{
WorkDir: workDir, WorkDir: workDir,
GRPCServerHost: "0.0.0.0", // TODO: make configurable GRPCServerHost: grpcHost,
GRPCServerPort: grpcPort, GRPCServerPort: grpcPort,
BackendURL: backendURL, BackendURL: backendURL,
GiteaReleasesURL: giteaURL,
}) })
// Write playbooks on init // Write playbooks on init
if err := exec.WriteAllPlaybooks(); err != nil { if err := exec.WriteAllPlaybooks(); err != nil {
// Log but don't fail - playbooks can be written later // Log the error - deployment will fail later if playbooks can't be written
_ = err fmt.Fprintf(os.Stderr, "WARNING: failed to write Ansible playbooks: %v\n", err)
} }
return &AgentDeployGroup{ return &AgentDeployGroup{
@@ -72,6 +83,48 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
return return
} }
// Validate auth credentials for each server
for i, server := range req.Servers {
switch server.AuthMethod {
case repository.AuthMethodKey:
if server.SSHKey == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): sshKey is required when authMethod is 'key'", i, server.IP),
})
return
}
case repository.AuthMethodPassword:
if server.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): password is required when authMethod is 'password'", i, server.IP),
})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): invalid authMethod %q, expected 'key' or 'password'", i, server.IP, server.AuthMethod),
})
return
}
}
// Pre-flight check: verify community.docker collection is available for docker deployments
needsDockerCollection := false
for _, server := range req.Servers {
if server.DeployType == repository.DeployTypeDocker {
needsDockerCollection = true
break
}
}
if needsDockerCollection {
if err := adg.executor.CheckDockerCollection(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Docker deployment requires 'community.docker' Ansible collection: %v", err),
})
return
}
}
// Create work directory // Create work directory
workDir := adg.executor.WorkDir() workDir := adg.executor.WorkDir()
if err := os.MkdirAll(workDir, 0755); err != nil { if err := os.MkdirAll(workDir, 0755); err != nil {
@@ -117,11 +170,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
Password: server.Password, Password: server.Password,
DeployType: string(server.DeployType), DeployType: string(server.DeployType),
Token: token, Token: token,
GRPCURL: adg.executor.GRPCURL(),
}, },
} }
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i)) inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil { if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
// Rollback: delete the token we just created
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
IP: server.IP, IP: server.IP,
AgentLabel: server.AgentLabel, AgentLabel: server.AgentLabel,
@@ -135,10 +191,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
// Run Ansible playbook for this server // Run Ansible playbook for this server
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType)) deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
// Clean up inventory file // Clean up inventory file (log error but don't fail deployment)
os.Remove(inventoryPath) if cleanupErr := os.Remove(inventoryPath); cleanupErr != nil {
fmt.Fprintf(os.Stderr, "WARNING: failed to remove inventory file %s: %v\n", inventoryPath, cleanupErr)
}
if err != nil { if err != nil {
// Rollback: delete the token since deployment failed
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
IP: server.IP, IP: server.IP,
AgentLabel: server.AgentLabel, AgentLabel: server.AgentLabel,
@@ -154,6 +214,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
if len(deployResults) > 0 && !deployResults[0].Success { if len(deployResults) > 0 && !deployResults[0].Success {
success = false success = false
errMsg = deployResults[0].Stderr errMsg = deployResults[0].Stderr
// Rollback: delete the token since ansible playbook reported failure
_ = adg.Repo.DeleteRegistrationToken(token)
} }
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
+14 -6
View File
@@ -1,9 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"net/http"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"net/http"
) )
type AgentsGroup struct { type AgentsGroup struct {
@@ -15,17 +17,19 @@ func NewAgentsGroup(h *Handlers, coll *collector.Collector) AgentsGroup {
return AgentsGroup{Handlers: h, collector: coll} return AgentsGroup{Handlers: h, collector: coll}
} }
// AgentInfo represents a connected agent's current status.
type AgentInfo struct { type AgentInfo struct {
Token string `json:"token"` Token string `json:"token" example:"agent-001"` // Unique agent identifier
Label string `json:"label"` Label string `json:"label" example:"web-server-1"` // Human-readable label
Services []string `json:"services"` Services []string `json:"services" example:"nginx:running,redis:up"` // List of services with status (format: "name:status")
ConnectedAt string `json:"connected_at"` ConnectedAt string `json:"connected_at" example:"2026-04-04 10:30:00"` // Time when agent connected (RFC3339-like)
} }
// @Summary Get connected agents // @Summary Get connected agents
// @Description Returns a list of all agents currently connected via Collector (log streaming) // @Description Returns a list of all agents currently connected via Collector (log streaming)
// @Tags agents // @Tags agents
// @Security Bearer // @Security Bearer
// @Accept json
// @Produce json // @Produce json
// @Success 200 {array} AgentInfo // @Success 200 {array} AgentInfo
// @Router /agents [get] // @Router /agents [get]
@@ -33,10 +37,14 @@ func (ag *AgentsGroup) List(c *gin.Context) {
agents := make([]AgentInfo, 0) agents := make([]AgentInfo, 0)
for _, agent := range ag.collector.Agents() { for _, agent := range ag.collector.Agents() {
services := make([]string, 0, len(agent.Services))
for _, s := range agent.Services {
services = append(services, fmt.Sprintf("%s:%s", s.Name, s.Status))
}
agents = append(agents, AgentInfo{ agents = append(agents, AgentInfo{
Token: agent.ID, Token: agent.ID,
Label: agent.Label, Label: agent.Label,
Services: agent.Services, Services: services,
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"), ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
}) })
} }
+47
View File
@@ -2,6 +2,8 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -49,6 +51,39 @@ func (ag *AuthGroup) Login(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// RegisterUser registers a new user with all permissions set to false.
// @Summary Register user
// @Description Registers a new user with login, password, name, last name. All permissions are set to false.
// @Tags auth
// @Accept json
// @Param request body repository.UserRegister true "Registration data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /auth/register [post]
func (ag *AuthGroup) RegisterUser(c *gin.Context) {
var req repository.UserRegister
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
id, err := ag.Repo.RegisterUser(req)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
c.JSON(http.StatusConflict, gin.H{"error": "login already exists"})
return
}
log.Printf("[register] failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to register user: %v", err)})
return
}
log.Printf("[register] user registered: id=%s login=%s", id, req.Login)
c.JSON(http.StatusOK, gin.H{"message": "user registered"})
}
// CreateToken creates a new user. // CreateToken creates a new user.
// @Summary Create user // @Summary Create user
// @Description Creates a new user with permissions // @Description Creates a new user with permissions
@@ -59,6 +94,7 @@ func (ag *AuthGroup) Login(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string // @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/token [post] // @Router /auth/token [post]
func (ag *AuthGroup) CreateToken(c *gin.Context) { func (ag *AuthGroup) CreateToken(c *gin.Context) {
var tc repository.TokenCreate var tc repository.TokenCreate
@@ -82,6 +118,7 @@ func (ag *AuthGroup) CreateToken(c *gin.Context) {
// @Produce json // @Produce json
// @Success 200 {object} repository.Tokens // @Success 200 {object} repository.Tokens
// @Failure 401 {object} map[string]string // @Failure 401 {object} map[string]string
// @Security Bearer
// @Router /auth/validate [get] // @Router /auth/validate [get]
func (ag *AuthGroup) ValidateToken(c *gin.Context) { func (ag *AuthGroup) ValidateToken(c *gin.Context) {
tokenVal, exists := c.Get(string(tokenContextKey)) tokenVal, exists := c.Get(string(tokenContextKey))
@@ -106,6 +143,7 @@ func (ag *AuthGroup) ValidateToken(c *gin.Context) {
// @Produce json // @Produce json
// @Success 200 {array} repository.Tokens // @Success 200 {array} repository.Tokens
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/tokens [get] // @Router /auth/tokens [get]
func (ag *AuthGroup) ListTokens(c *gin.Context) { func (ag *AuthGroup) ListTokens(c *gin.Context) {
tokens, err := ag.Repo.ListTokens() tokens, err := ag.Repo.ListTokens()
@@ -124,6 +162,7 @@ func (ag *AuthGroup) ListTokens(c *gin.Context) {
// @Success 200 {object} map[string]string // @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/tokens/:login [delete] // @Router /auth/tokens/:login [delete]
func (ag *AuthGroup) DeleteToken(c *gin.Context) { func (ag *AuthGroup) DeleteToken(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -151,6 +190,7 @@ func (ag *AuthGroup) DeleteToken(c *gin.Context) {
// @Success 200 {object} map[string]string // @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string // @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/token [delete] // @Router /auth/token [delete]
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) { func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
tokenVal, exists := c.Get(string(tokenContextKey)) tokenVal, exists := c.Get(string(tokenContextKey))
@@ -182,6 +222,7 @@ func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/activate [post] // @Router /auth/users/:login/activate [post]
func (ag *AuthGroup) ActivateUser(c *gin.Context) { func (ag *AuthGroup) ActivateUser(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -211,6 +252,7 @@ func (ag *AuthGroup) ActivateUser(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/deactivate [post] // @Router /auth/users/:login/deactivate [post]
func (ag *AuthGroup) DeactivateUser(c *gin.Context) { func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -238,6 +280,7 @@ func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
// @Produce json // @Produce json
// @Success 200 {array} repository.Tokens // @Success 200 {array} repository.Tokens
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/inactive [get] // @Router /auth/users/inactive [get]
func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) { func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
tokens, err := ag.Repo.ListInactiveTokens() tokens, err := ag.Repo.ListInactiveTokens()
@@ -258,6 +301,7 @@ func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login [get] // @Router /auth/users/:login [get]
func (ag *AuthGroup) GetUser(c *gin.Context) { func (ag *AuthGroup) GetUser(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -290,6 +334,7 @@ func (ag *AuthGroup) GetUser(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login [put] // @Router /auth/users/:login [put]
func (ag *AuthGroup) UpdateUser(c *gin.Context) { func (ag *AuthGroup) UpdateUser(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -327,6 +372,7 @@ func (ag *AuthGroup) UpdateUser(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/permissions [put] // @Router /auth/users/:login/permissions [put]
func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) { func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
@@ -364,6 +410,7 @@ func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/password [put] // @Router /auth/users/:login/password [put]
func (ag *AuthGroup) ResetUserPassword(c *gin.Context) { func (ag *AuthGroup) ResetUserPassword(c *gin.Context) {
login := c.Param("login") login := c.Param("login")
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func CorsMiddleware(origincfg string) gin.HandlerFunc {
origins := strings.Split(origincfg, ";")
if origins[0] == "" {
panic("zero cors origins wtf is your config")
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if !lo.Contains(origins, origin) {
origin = origins[0]
}
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")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+176 -37
View File
@@ -1,32 +1,48 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os/exec"
"strconv"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type JobsHandlers struct { type JobsHandlers struct {
cmder *commander.Commander tracker *commander.ConnTracker
svc *service.ScriptService svc *service.ScriptService
whereami string
jobRepo *repository.JobRepository
} }
func NewJobsHandlers(cmder *commander.Commander, svc *service.ScriptService) JobsHandlers { func NewJobsHandlers(tracker *commander.ConnTracker, svc *service.ScriptService, whereami string, jobRepo *repository.JobRepository) JobsHandlers {
return JobsHandlers{cmder: cmder, svc: svc} return JobsHandlers{tracker: tracker, svc: svc, whereami: whereami, jobRepo: jobRepo}
} }
// AddJobIn is the request body for creating a job.
type AddJobIn struct { type AddJobIn struct {
Command string `json:"command" binding:"required"` Command string `json:"command" binding:"required"`
InterpreterID int64 `json:"interpreter_id"` InterpreterID int64 `json:"interpreter_id"`
Stdin *string `json:"stdin"` Stdin *string `json:"stdin"`
AgentID string `json:"agent_id" binding:"required"` AgentID string `json:"agent_id" binding:"required"`
} }
// AddJobOut is the response body for a submitted job.
type AddJobOut struct { type AddJobOut struct {
ID int64 `json:"id"`
Command []string `json:"command"`
WaitURL string `json:"wait_url"`
}
// JobResult is the response body for a completed job.
type JobResult struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Command []string `json:"command"` Command []string `json:"command"`
Stdin *string `json:"stdin"` Stdin *string `json:"stdin"`
@@ -35,61 +51,98 @@ type AddJobOut struct {
Status int32 `json:"status"` Status int32 `json:"status"`
} }
// AddJob creates and executes a job on a target agent. // WaitJobIn is the request body for waiting on a job.
// @Summary Create and run a job on an agent type WaitJobIn struct {
// @Description Sends a command to the specified agent, waits for execution, and returns the result AgentID string `json:"agent_id" binding:"required"`
}
// AddJob submits a job to an agent and returns a wait_url for the result.
// @Summary Submit a job to an agent
// @Description Sends a command to the specified agent and returns a URL to wait for the result
// @Tags jobs // @Tags jobs
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body AddJobIn true "Job request" // @Param body body AddJobIn true "Job request"
// @Success 201 {object} AddJobOut // @Success 201 {object} AddJobOut
// @Router /jobs [post] // @Router /jobs [post]
func (self *JobsHandlers) AddJob(c *gin.Context) { func (h *JobsHandlers) AddJob(c *gin.Context) {
log.Printf("[DEBUG] AddJob handler: request received")
err := func() error {
var in AddJobIn var in AddJobIn
if err := c.Bind(&in); err != nil { if err := c.Bind(&in); err != nil {
log.Printf("[DEBUG] AddJob handler: bind failed: %v", err) c.Error(err)
return err return
} }
log.Printf("[DEBUG] AddJob handler: agent_id=%s, command=%s, interpreter_id=%d", in.AgentID, in.Command, in.InterpreterID)
agent, ok := self.cmder.GetAgent(in.AgentID) agent, ok := h.tracker.GetAgent(in.AgentID)
if !ok { if !ok {
log.Printf("[DEBUG] AddJob handler: agent %s not found", in.AgentID)
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return fmt.Errorf("agent not found") c.Error(fmt.Errorf("agent not found"))
return
} }
log.Printf("[DEBUG] AddJob handler: agent found, resolving command")
var command []string command, err := resolveCommand(c, h.svc, in.InterpreterID, in.Command)
if in.InterpreterID == 0 {
command = []string{"sh", "-c", in.Command}
} else {
var err error
command, err = self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.Command)
if err != nil { if err != nil {
log.Printf("[DEBUG] AddJob handler: ResolveCommand failed: %v", err) c.Error(err)
return err return
}
} }
log.Printf("[DEBUG] AddJob handler: calling agent.AddJob with command=%v", command)
jid, err := agent.AddJob(models.JobForInsert{ jid, err := agent.AddJob(models.JobForInsert{
Command: command, Command: command,
Stdin: in.Stdin, Stdin: in.Stdin,
}) })
if err != nil { if err != nil {
log.Printf("[DEBUG] AddJob handler: agent.AddJob failed: %v", err) c.Error(err)
return err return
} }
log.Printf("[DEBUG] AddJob handler: agent.AddJob returned jid=%d, calling WaitJob", jid)
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
c.JSON(http.StatusCreated, AddJobOut{
ID: jid,
Command: command,
WaitURL: waitURL,
})
}
// WaitJob waits for a submitted job to complete (long-poll).
// If the job is already done, returns immediately.
// @Summary Wait for job result
// @Description Long-polls for a job result. Returns immediately if the job is already finished.
// @Tags jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Param body body WaitJobIn true "Agent reference"
// @Success 200 {object} JobResult
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /jobs/{id}/wait [post]
func (h *JobsHandlers) WaitJob(c *gin.Context) {
jid, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job id"})
return
}
var in WaitJobIn
if err := c.Bind(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
agent, ok := h.tracker.GetAgent(in.AgentID)
if !ok {
c.Status(http.StatusNotFound)
c.Error(fmt.Errorf("agent not found"))
return
}
job, err := agent.WaitJob(jid) job, err := agent.WaitJob(jid)
if err != nil { if err != nil {
log.Printf("[DEBUG] AddJob handler: agent.WaitJob failed: %v", err) c.Error(err)
return err return
} }
log.Printf("[DEBUG] AddJob handler: agent.WaitJob returned job id=%d, status=%d", job.ID, job.Status)
c.JSON(http.StatusCreated, AddJobOut{ c.JSON(http.StatusOK, JobResult{
ID: job.ID, ID: job.ID,
Command: job.Command, Command: job.Command,
Stdin: job.Stdin, Stdin: job.Stdin,
@@ -97,10 +150,96 @@ func (self *JobsHandlers) AddJob(c *gin.Context) {
Stderr: job.Stderr, Stderr: job.Stderr,
Status: job.Status, Status: job.Status,
}) })
log.Printf("[DEBUG] AddJob handler: response sent") }
return nil
}() func resolveCommand(c *gin.Context, svc *service.ScriptService, interpID int64, cmd string) ([]string, error) {
if interpID == 0 {
return []string{"sh", "-c", cmd}, nil
}
command, err := svc.ResolveCommand(c.Request.Context(), interpID, cmd)
if err != nil {
return nil, err
}
return command, nil
}
// @Summary Check command path
// @Description Validates that a command binary exists on the system
// @Tags jobs
// @Accept json
// @Param body body CheckCmdIn true "Command to check"
// @Success 200 {object} CheckCmdOut
// @Failure 404 {object} map[string]string
// @Router /jobs/check_cmd [post]
func (h *JobsHandlers) CheckCmd(c *gin.Context) {
var in struct {
Command string `json:"command" binding:"required"`
}
if err := c.Bind(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if _, err := exec.LookPath(in.Command); err != nil {
if errors.Is(err, exec.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "command not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, CheckCmdOut{Exists: true})
}
type CheckCmdIn struct {
Command string `json:"command" binding:"required" example:"bash"`
}
type CheckCmdOut struct {
Exists bool `json:"exists"`
}
// JobMetricsOut is the response body for the job metrics endpoint.
type JobMetricsOut struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
Pending int `json:"pending"`
Period string `json:"period"`
}
// GetJobMetrics returns job success metrics over a parameterized period.
// @Summary Get job metrics
// @Description Returns total, successful, failed, and pending job counts over the given period
// @Tags jobs
// @Produce json
// @Param period query string false "Time period (e.g. 1h, 24h, 7d)" default(24h)
// @Success 200 {object} JobMetricsOut
// @Failure 400 {object} map[string]string
// @Security Bearer
// @Router /jobs/metrics [get]
func (h *JobsHandlers) GetJobMetrics(c *gin.Context) {
periodStr := c.DefaultQuery("period", "24h")
period, err := time.ParseDuration(periodStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid period, use Go duration format (e.g. 1h, 24h, 7d)"})
return
}
since := time.Now().Add(-period)
metrics, err := h.jobRepo.GetJobMetrics(c.Request.Context(), since)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return
} }
c.JSON(http.StatusOK, JobMetricsOut{
Total: metrics.Total,
Success: metrics.Success,
Failed: metrics.Failed,
Pending: metrics.Pending,
Period: periodStr,
})
} }
+42 -30
View File
@@ -14,11 +14,27 @@ import (
type ScriptHandlers struct { type ScriptHandlers struct {
svc *service.ScriptService svc *service.ScriptService
cmder *commander.Commander tracker *commander.ConnTracker
} }
func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) ScriptHandlers { func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker) ScriptHandlers {
return ScriptHandlers{svc: svc, cmder: cmder} return ScriptHandlers{svc: svc, tracker: tracker}
}
type RunScriptIn struct {
AgentID string `json:"agent_id" binding:"required"`
InterpreterID int64 `json:"interpreter_id" binding:"required"`
ScriptText string `json:"script_text" binding:"required"`
Stdin *string `json:"stdin"`
}
type RunScriptOut struct {
ID int64 `json:"id"`
Command []string `json:"command"`
Stdin *string `json:"stdin"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int32 `json:"status"`
} }
// RunScript executes a script on a target agent. // RunScript executes a script on a target agent.
@@ -29,26 +45,25 @@ func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) S
// @Produce json // @Produce json
// @Param body body RunScriptIn true "Script request" // @Param body body RunScriptIn true "Script request"
// @Success 201 {object} RunScriptOut // @Success 201 {object} RunScriptOut
// @Security Bearer
// @Router /scripts/run [post] // @Router /scripts/run [post]
func (self *ScriptHandlers) RunScript(c *gin.Context) { func (h *ScriptHandlers) RunScript(c *gin.Context) {
err := func() error { err := func() error {
type RunScriptIn struct {
AgentID string `json:"agent_id" binding:"required"`
InterpreterID int64 `json:"interpreter_id" binding:"required"`
ScriptText string `json:"script_text" binding:"required"`
Stdin *string `json:"stdin"`
}
var in RunScriptIn var in RunScriptIn
if err := c.Bind(&in); err != nil { if err := c.Bind(&in); err != nil {
return err return err
} }
command, err := self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText) command, err := h.svc.ResolveCommand(
c.Request.Context(),
in.InterpreterID,
in.ScriptText,
)
if err != nil { if err != nil {
return err return err
} }
agent, ok := self.cmder.GetAgent(in.AgentID) agent, ok := h.tracker.GetAgent(in.AgentID)
if !ok { if !ok {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return fmt.Errorf("agent not found") return fmt.Errorf("agent not found")
@@ -67,14 +82,6 @@ func (self *ScriptHandlers) RunScript(c *gin.Context) {
return err return err
} }
type RunScriptOut struct {
ID int64 `json:"id"`
Command []string `json:"command"`
Stdin *string `json:"stdin"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int32 `json:"status"`
}
c.JSON(http.StatusCreated, RunScriptOut{ c.JSON(http.StatusCreated, RunScriptOut{
ID: job.ID, ID: job.ID,
Command: job.Command, Command: job.Command,
@@ -96,9 +103,10 @@ func (self *ScriptHandlers) RunScript(c *gin.Context) {
// @Tags scripts // @Tags scripts
// @Produce json // @Produce json
// @Success 200 {array} repository.ScriptInterpreter // @Success 200 {array} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters [get] // @Router /scripts/interpreters [get]
func (self *ScriptHandlers) ListInterpreters(c *gin.Context) { func (h *ScriptHandlers) ListInterpreters(c *gin.Context) {
interpreters, err := self.svc.List(c.Request.Context()) interpreters, err := h.svc.List(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -114,15 +122,16 @@ func (self *ScriptHandlers) ListInterpreters(c *gin.Context) {
// @Produce json // @Produce json
// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition" // @Param body body repository.ScriptInterpreterCreate true "Interpreter definition"
// @Success 201 {object} repository.ScriptInterpreter // @Success 201 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters [post] // @Router /scripts/interpreters [post]
func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) { func (h *ScriptHandlers) CreateInterpreter(c *gin.Context) {
var in repository.ScriptInterpreterCreate var in repository.ScriptInterpreterCreate
if err := c.BindJSON(&in); err != nil { if err := c.BindJSON(&in); err != nil {
c.Error(err) c.Error(err)
return return
} }
si, err := self.svc.Create(c.Request.Context(), in) si, err := h.svc.Create(c.Request.Context(), in)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -137,15 +146,16 @@ func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path int true "Interpreter ID" // @Param id path int true "Interpreter ID"
// @Success 200 {object} repository.ScriptInterpreter // @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters/:id [get] // @Router /scripts/interpreters/:id [get]
func (self *ScriptHandlers) GetInterpreter(c *gin.Context) { func (h *ScriptHandlers) GetInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
si, err := self.svc.GetByID(c.Request.Context(), id) si, err := h.svc.GetByID(c.Request.Context(), id)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -162,8 +172,9 @@ func (self *ScriptHandlers) GetInterpreter(c *gin.Context) {
// @Param id path int true "Interpreter ID" // @Param id path int true "Interpreter ID"
// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields" // @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields"
// @Success 200 {object} repository.ScriptInterpreter // @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters/:id [put] // @Router /scripts/interpreters/:id [put]
func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) { func (h *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
@@ -176,7 +187,7 @@ func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
return return
} }
si, err := self.svc.Update(c.Request.Context(), id, in) si, err := h.svc.Update(c.Request.Context(), id, in)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -190,15 +201,16 @@ func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
// @Tags scripts // @Tags scripts
// @Param id path int true "Interpreter ID" // @Param id path int true "Interpreter ID"
// @Success 204 // @Success 204
// @Security Bearer
// @Router /scripts/interpreters/:id [delete] // @Router /scripts/interpreters/:id [delete]
func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) { func (h *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
if err := self.svc.Delete(c.Request.Context(), id); err != nil { if err := h.svc.Delete(c.Request.Context(), id); err != nil {
c.Error(err) c.Error(err)
return return
} }
+534
View File
@@ -0,0 +1,534 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"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
}
// Validate path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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
}
// Validate path if it's being updated
if req.Path != nil {
if err := validateScriptPath(*req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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"`
}
// CreateFolderRequest is the request body for creating a script folder.
type CreateFolderRequest struct {
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path (e.g. 'deploy/nginx')"`
}
// DeleteFolderRequest is the request body for deleting a script folder.
type DeleteFolderRequest struct {
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path to delete"`
}
// RenameRequest is the request body for renaming a script or folder.
type RenameRequest struct {
OldPath string `json:"old_path" binding:"required" example:"deploy/nginx" description:"Current path"`
NewPath string `json:"new_path" binding:"required" example:"deploy/nginx-v2" description:"New path"`
}
// Rename renames a script or all scripts under a folder path.
// @Summary Rename script or folder
// @Description Renames a single script or all scripts under a folder prefix
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body RenameRequest true "Rename request"
// @Success 200 {object} map[string]interface{} "Rename result with count of renamed scripts"
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Security Bearer
// @Router /scripts/rename [post]
func (sh *ScriptHandlersGroup) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate new path
if err := validateScriptPath(req.NewPath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid new path: %v", err)})
return
}
// Validate old path
if err := validateScriptPath(req.OldPath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid old path: %v", err)})
return
}
// Get all scripts
allScripts, err := sh.svc.Repo.ListScripts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
return
}
// Find scripts to rename: exact match or folder prefix
prefix := req.OldPath + "/"
var toRename []repository.Script
for _, script := range allScripts {
if script.Path == req.OldPath || strings.HasPrefix(script.Path, prefix) {
toRename = append(toRename, script)
}
}
if len(toRename) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no scripts found with this path"})
return
}
// Rename each script
renamedCount := 0
for _, script := range toRename {
newPath := req.NewPath + strings.TrimPrefix(script.Path, req.OldPath)
// Check if new path already exists (excluding the scripts we're renaming)
for _, existing := range allScripts {
if existing.ID != script.ID && existing.Path == newPath {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("path '%s' already exists", newPath)})
return
}
}
_, err := sh.svc.Repo.UpdateScript(script.ID, repository.ScriptUpdate{
Path: &newPath,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to rename %s: %v", script.Path, err)})
return
}
renamedCount++
}
c.JSON(http.StatusOK, gin.H{
"message": "renamed",
"old_path": req.OldPath,
"new_path": req.NewPath,
"renamed_count": renamedCount,
})
}
// CreateFolder creates a virtual folder in the script tree.
// @Summary Create folder
// @Description Creates a virtual folder by creating a placeholder script with the folder path
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body CreateFolderRequest true "Folder path"
// @Success 201 {object} map[string]string "Folder created"
// @Security Bearer
// @Router /scripts/folder [post]
func (sh *ScriptHandlersGroup) CreateFolder(c *gin.Context) {
var req CreateFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate folder path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
return
}
// Create a placeholder script with the folder path to ensure the folder exists in the tree
// The placeholder uses ".folder" as content and interpreter_id 0 (will be resolved at runtime)
_, err := sh.svc.Repo.CreateScript(repository.ScriptCreate{
Path: req.Path,
Content: "",
InterpreterID: 0,
})
if err != nil {
if isUniqueConstraint(err) {
c.JSON(http.StatusConflict, gin.H{"error": "folder with this path already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "folder created", "path": req.Path})
}
// DeleteFolder deletes all scripts under a given path prefix.
// @Summary Delete folder
// @Description Deletes all scripts that start with the given folder path
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body DeleteFolderRequest true "Folder path"
// @Success 200 {object} map[string]interface{} "Folder deleted with count of deleted scripts"
// @Security Bearer
// @Router /scripts/folder [delete]
func (sh *ScriptHandlersGroup) DeleteFolder(c *gin.Context) {
var req DeleteFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate folder path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
return
}
// Get all scripts and filter by path prefix
allScripts, err := sh.svc.Repo.ListScripts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
return
}
prefix := req.Path + "/"
deletedCount := 0
for _, script := range allScripts {
// Delete scripts that are in this folder (path starts with prefix)
// or the folder placeholder itself (exact match)
if script.Path == req.Path || strings.HasPrefix(script.Path, prefix) {
if err := sh.svc.Repo.DeleteScript(script.ID); err != nil && !errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete script %s: %v", script.Path, err)})
return
}
deletedCount++
}
}
c.JSON(http.StatusOK, gin.H{"message": "folder deleted", "path": req.Path, "deleted_count": deletedCount})
}
// GetScriptByPath returns a script by its path.
// @Summary Get script by path
// @Description Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')
// @Tags scripts
// @Produce json
// @Param path query string true "Script path"
// @Success 200 {object} repository.Script
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Security Bearer
// @Router /scripts/by-path [get]
func (sh *ScriptHandlersGroup) GetScriptByPath(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"})
return
}
script, err := sh.svc.Repo.GetScriptByPath(path)
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)
}
// 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
}
// validateScriptPath validates that a script path is well-formed.
// Rules: non-empty, no leading slash, no double slashes, no trailing slash, no empty segments.
func validateScriptPath(path string) error {
if path == "" {
return fmt.Errorf("path cannot be empty")
}
if strings.HasPrefix(path, "/") {
return fmt.Errorf("path cannot start with '/'")
}
if strings.HasSuffix(path, "/") {
return fmt.Errorf("path cannot end with '/'")
}
if strings.Contains(path, "//") {
return fmt.Errorf("path cannot contain '//'")
}
// Check for empty segments (e.g. "a//b" already caught, but "a/ /b" should be allowed)
segments := strings.Split(path, "/")
for i, seg := range segments {
if strings.TrimSpace(seg) == "" {
return fmt.Errorf("path segment %d cannot be empty or whitespace", i+1)
}
}
return nil
}
+50 -8
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
@@ -23,7 +24,11 @@ func (r *JobRepository) Init(ctx context.Context) error {
return err 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) commandJSON, err := json.Marshal(job.Command)
if err != nil { if err != nil {
return 0, fmt.Errorf("marshal command: %w", err) return 0, fmt.Errorf("marshal command: %w", err)
@@ -34,9 +39,12 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.
stdinVal = job.Stdin 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)`, `INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`,
agentID, string(commandJSON), stdinVal, agentID,
string(commandJSON),
stdinVal,
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -45,10 +53,18 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.
return result.LastInsertId() return result.LastInsertId()
} }
func (r *JobRepository) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) { func (r *JobRepository) UpdateJobInDB(
result, err := r.DB.ExecContext(ctx, 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 = ?`, `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 { if err != nil {
return models.Job{}, err return models.Job{}, err
@@ -81,10 +97,36 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job,
return models.Job{}, err 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) return models.Job{}, fmt.Errorf("unmarshal command: %w", err)
} }
job.JobForInsert.Stdin = stdinVal job.Stdin = stdinVal
return job, nil return job, nil
} }
type JobMetrics struct {
Total int
Success int
Failed int
Pending int
}
// GetJobMetrics returns job success metrics for jobs updated since the given time.
// A successful job has status == 0, failed has status != 0, pending has status == 0 with empty stdout/stderr.
func (r *JobRepository) GetJobMetrics(ctx context.Context, since time.Time) (JobMetrics, error) {
var m JobMetrics
err := r.DB.QueryRowContext(ctx,
`SELECT
COUNT(*),
SUM(CASE WHEN status = 0 AND (stdout != '' OR stderr != '') THEN 1 ELSE 0 END),
SUM(CASE WHEN status != 0 THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 0 AND stdout = '' AND stderr = '' THEN 1 ELSE 0 END)
FROM jobs WHERE updated_at >= ?`,
since,
).Scan(&m.Total, &m.Success, &m.Failed, &m.Pending)
if err != nil {
return JobMetrics{}, err
}
return m, nil
}
@@ -157,7 +157,13 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
logs := make([]storage.LogEntry, 0) logs := make([]storage.LogEntry, 0)
for rows.Next() { for rows.Next() {
var log storage.LogEntry 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 return nil, err
} }
logs = append(logs, log) logs = append(logs, log)
+42
View File
@@ -25,6 +25,14 @@ type TokenCreate struct {
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
} }
// UserRegister is the request body for public user registration (all permissions false).
type UserRegister 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"`
}
// TokenUpdate is the request body for updating an existing user. // TokenUpdate is the request body for updating an existing user.
type TokenUpdate struct { type TokenUpdate struct {
Name string `json:"name"` Name string `json:"name"`
@@ -141,3 +149,37 @@ type DeployResult struct {
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"` Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"` 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"`
}
+193 -9
View File
@@ -3,6 +3,8 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log"
"strconv" "strconv"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
@@ -50,8 +52,15 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
result, err := r.DB.Exec( result, err := r.DB.Exec(
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active) `INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tc.Name, tc.LastName, tc.Login, string(hashed), token, tc.Name,
tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, tc.IsActive, tc.LastName,
tc.Login,
string(hashed),
token,
tc.PermissionView,
tc.PermissionManage,
tc.PermissionAdmin,
tc.IsActive,
) )
if err != nil { if err != nil {
return "", err return "", err
@@ -64,6 +73,39 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
return strconv.FormatInt(id, 10), nil return strconv.FormatInt(id, 10), nil
} }
// RegisterUser inserts a new user with all permissions set to false and is_active=false.
func (r *Repository) RegisterUser(ur UserRegister) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(ur.Password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
token, err := utils.RandomToken()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
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,
)
if err != nil {
return "", fmt.Errorf("insert user: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return "", fmt.Errorf("get last insert id: %w", err)
}
log.Printf("[register] user created: id=%s login=%s", strconv.FormatInt(id, 10), ur.Login)
return strconv.FormatInt(id, 10), nil
}
// Login authenticates by login/password, generates a new token, and returns LoginResponse. // Login authenticates by login/password, generates a new token, and returns LoginResponse.
func (r *Repository) Login(login, password string) (*LoginResponse, error) { func (r *Repository) Login(login, password string) (*LoginResponse, error) {
var t Tokens var t Tokens
@@ -118,11 +160,11 @@ func (r *Repository) Login(login, password string) (*LoginResponse, error) {
func (r *Repository) GetToken(token string) (*Tokens, error) { func (r *Repository) GetToken(token string) (*Tokens, error) {
var t Tokens var t Tokens
err := r.DB.QueryRow( err := r.DB.QueryRow(
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin `SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
FROM tokens WHERE token = ?`, FROM tokens WHERE token = ?`,
token, token,
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token, ).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin) &t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -136,7 +178,7 @@ func (r *Repository) GetToken(token string) (*Tokens, error) {
// ListTokens returns all users without password and token. // ListTokens returns all users without password and token.
func (r *Repository) ListTokens() ([]Tokens, error) { func (r *Repository) ListTokens() ([]Tokens, error) {
rows, err := r.DB.Query( rows, err := r.DB.Query(
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin `SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin, is_active
FROM tokens`, FROM tokens`,
) )
if err != nil { if err != nil {
@@ -148,7 +190,7 @@ func (r *Repository) ListTokens() ([]Tokens, error) {
for rows.Next() { for rows.Next() {
var t Tokens var t Tokens
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login, if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil { &t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
return nil, err return nil, err
} }
tokens = append(tokens, t) tokens = append(tokens, t)
@@ -257,6 +299,12 @@ func (r *Repository) MarkRegistrationTokenUsed(token string) error {
return nil return nil
} }
// DeleteRegistrationToken deletes a registration token (used for rollback on deployment failure).
func (r *Repository) DeleteRegistrationToken(token string) error {
_, err := r.DB.Exec(`DELETE FROM registration_tokens WHERE token = ?`, token)
return err
}
// ActivateToken activates a user by token value. // ActivateToken activates a user by token value.
func (r *Repository) ActivateToken(token string) error { func (r *Repository) ActivateToken(token string) error {
result, err := r.DB.Exec( result, err := r.DB.Exec(
@@ -302,12 +350,13 @@ func (r *Repository) ActivateUserByLogin(login string) error {
login, login,
) )
if err != nil { if err != nil {
return err return fmt.Errorf("activate exec: %w", err)
} }
affected, err := result.RowsAffected() affected, err := result.RowsAffected()
if err != nil { if err != nil {
return err return fmt.Errorf("rows affected: %w", err)
} }
log.Printf("[activate] login=%s affected=%d", login, affected)
if affected == 0 { if affected == 0 {
return ErrNotFound return ErrNotFound
} }
@@ -422,7 +471,11 @@ func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissio
result, err := r.DB.Exec( result, err := r.DB.Exec(
`UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`, `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 { if err != nil {
return err return err
@@ -460,3 +513,134 @@ func (r *Repository) UpdatePassword(login string, newPassword string) error {
} }
return nil 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
}
@@ -44,7 +44,10 @@ func (r *ScriptInterpreterRepo) Init(ctx context.Context) error {
return err 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) argvJSON, err := json.Marshal(in.Argv)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,7 +74,8 @@ func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptI
var argvJSON string var argvJSON string
var createdAt, updatedAt 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 = ?`, `SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`,
id, id,
).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt) ).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() { for rows.Next() {
var si ScriptInterpreter var si ScriptInterpreter
var argvJSON, createdAt, updatedAt string 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 return nil, err
} }
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil { 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() 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) si, err := r.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
+160 -23
View File
@@ -3,52 +3,189 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"strings"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
) )
// ScriptService handles script CRUD, tree building, and interpreter resolution.
type ScriptService struct { type ScriptService struct {
repo *repository.ScriptInterpreterRepo Repo *repository.Repository
InterpreterRepo *repository.ScriptInterpreterRepo
} }
func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService { // NewScriptService creates a new ScriptService with both script and interpreter repos.
return &ScriptService{repo: repo} func NewScriptService(repo *repository.Repository) *ScriptService {
return &ScriptService{Repo: repo}
} }
// ResolveCommand builds the full argv[] by prepending the interpreter's argv // NewScriptServiceWithInterpreters creates a ScriptService with interpreter support.
// to the script text (as the last argument). func NewScriptServiceWithInterpreters(repo *repository.Repository, interpRepo *repository.ScriptInterpreterRepo) *ScriptService {
func (self *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) { return &ScriptService{Repo: repo, InterpreterRepo: interpRepo}
interpreter, err := self.repo.GetByID(ctx, interpreterID) }
// 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").
// Scripts with empty content and interpreter_id=0 are treated as folder placeholders.
func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
scripts, err := s.Repo.ListScripts()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(interpreter.Argv) == 0 { root := make(map[string]*treeNode)
return nil, fmt.Errorf("interpreter %q has empty argv", interpreter.Name)
for _, sc := range scripts {
parts := strings.Split(sc.Path, "/")
// A script with empty content and interpreter_id=0 is a folder placeholder
isPlaceholder := sc.Content == "" && sc.InterpreterID == 0
// Walk through path parts, creating folders as needed
currentMap := root
for i, part := range parts {
isLastPart := i == len(parts)-1
isFile := isLastPart && !isPlaceholder
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
} else if isFile {
// Node already exists but was created as a folder (e.g. by another script's path).
// Convert it to a file if it was a folder placeholder.
existing := currentMap[part]
if existing.typ == "folder" {
id := sc.ID
content := sc.Content
interpreterID := sc.InterpreterID
existing.typ = "file"
existing.id = &id
existing.content = &content
existing.interpreterID = &interpreterID
}
}
currentMap = currentMap[part].children
}
} }
argv := make([]string, len(interpreter.Argv)+1) return buildTreeSlice(root), nil
copy(argv, interpreter.Argv)
argv[len(argv)-1] = scriptText
return argv, nil
} }
func (self *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) { // buildTreeSlice converts a map of treeNodes to a sorted slice of ScriptTreeNode.
return self.repo.Create(ctx, in) func buildTreeSlice(m map[string]*treeNode) []repository.ScriptTreeNode {
result := make([]repository.ScriptTreeNode, 0, len(m))
for _, node := range m {
result = append(result, toScriptTreeNode(node))
} }
func (self *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) { // Sort: folders first, then files, alphabetically within each group
return self.repo.GetByID(ctx, id) 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) List(ctx context.Context) ([]repository.ScriptInterpreter, error) { // toScriptTreeNode converts a treeNode to a ScriptTreeNode with recursively converted children.
return self.repo.List(ctx) func toScriptTreeNode(node *treeNode) repository.ScriptTreeNode {
result := repository.ScriptTreeNode{
Name: node.name,
Type: node.typ,
Children: []repository.ScriptTreeNode{},
} }
func (self *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) { if node.typ == "file" {
return self.repo.Update(ctx, id, in) result.ID = node.id
result.Content = node.content
result.InterpreterID = node.interpreterID
} else {
result.Children = buildTreeSlice(node.children)
} }
func (self *ScriptService) Delete(ctx context.Context, id int64) error { return result
return self.repo.Delete(ctx, id) }
// 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
}
// 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)
}
// 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)
} }
+17 -3
View File
@@ -43,7 +43,11 @@ func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) {
} }
// OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff. // 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 var lastErr error
delay := initialDelay delay := initialDelay
@@ -53,10 +57,20 @@ func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay
return db, nil return db, nil
} }
lastErr = err 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) time.Sleep(delay)
delay *= 2 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,
)
} }
+12
View File
@@ -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 = ` const CreateLogsTable = `
CREATE TABLE IF NOT EXISTS logs ( CREATE TABLE IF NOT EXISTS logs (
timestamp DateTime64(3) DEFAULT now(), timestamp DateTime64(3) DEFAULT now(),
+11 -1
View File
@@ -3,6 +3,7 @@ package storage
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"strings" "strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -37,7 +38,16 @@ func Open(path string) (*sql.DB, error) {
} }
// Migration: add is_active column if it doesn't exist // Migration: add is_active column if it doesn't exist
_, _ = db.Exec(AddIsActiveColumn) if _, err := db.Exec(AddIsActiveColumn); err != nil {
log.Printf("[sqlite] WARNING: failed to add is_active column: %v", err)
} else {
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 return db, nil
} }
+1
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
) )
// TOOD: fuck
func RandomToken() (string, error) { func RandomToken() (string, error) {
token := make([]byte, 32) token := make([]byte, 32)
if _, err := rand.Read(token); err != nil { if _, err := rand.Read(token); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
backend_url: http://backend:8080 backend_url: http://backend:8080
grpc_url: backend:9001 grpc_url: backend:9001
label: test-agent-1 label: test-agent-1
registration_token: "156616b56774d59ba53f1eb4b096488bb5f755bbf5b737d93a42bb1b583ad7fb" registration_token: "58b1cd3857774f690e4534ec222af4ec08eaae8cd5577614365f2b19c78d03d6"
cert_dir: /etc/hellreign-agent/certs cert_dir: /etc/hellreign-agent/certs
services: services:
- name: system - name: system
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE [jobs_new_17f2f1dd010f] (
[id] INTEGER PRIMARY KEY,
[agent_id] TEXT NOT NULL,
[command] TEXT NOT NULL,
[stdin] TEXT,
[stdout] TEXT,
[stderr] TEXT,
[status] INTEGER,
[created_at] FLOAT DEFAULT CURRENT_TIMESTAMP,
[updated_at] FLOAT DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO [jobs_new_17f2f1dd010f] ([rowid], [id], [agent_id], [command], [stdin], [stdout], [stderr], [status], [created_at], [updated_at])
SELECT [rowid], [id], [agent_id], [command], [stdin], [stdout], [stderr], [status], [created_at], [updated_at] FROM [jobs];
DROP TABLE [jobs];
ALTER TABLE [jobs_new_17f2f1dd010f] RENAME TO [jobs];
+11
View File
@@ -6,6 +6,16 @@ option go_package="gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto";
service Collector { service Collector {
rpc Stream(stream CollectorRequest) returns (CollectorResponse); rpc Stream(stream CollectorRequest) returns (CollectorResponse);
rpc ReportServices(ServicesUpdate) returns (ServicesUpdateResp);
}
message ServicesUpdateResp {
}
message ServicesUpdate {
message ServiceUpdate {
string name = 1;
string status = 2;
}
repeated ServiceUpdate services = 1;
} }
message CollectorRequest { message CollectorRequest {
@@ -31,3 +41,4 @@ message FinishedCommand {
string stdout = 3; string stdout = 3;
string stderr = 4; string stderr = 4;
} }
+176 -31
View File
@@ -21,6 +21,86 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
type ServicesUpdateResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ServicesUpdateResp) Reset() {
*x = ServicesUpdateResp{}
mi := &file_hellreign_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ServicesUpdateResp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServicesUpdateResp) ProtoMessage() {}
func (x *ServicesUpdateResp) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ServicesUpdateResp.ProtoReflect.Descriptor instead.
func (*ServicesUpdateResp) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{0}
}
type ServicesUpdate struct {
state protoimpl.MessageState `protogen:"open.v1"`
Services []*ServicesUpdate_ServiceUpdate `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ServicesUpdate) Reset() {
*x = ServicesUpdate{}
mi := &file_hellreign_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ServicesUpdate) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServicesUpdate) ProtoMessage() {}
func (x *ServicesUpdate) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ServicesUpdate.ProtoReflect.Descriptor instead.
func (*ServicesUpdate) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{1}
}
func (x *ServicesUpdate) GetServices() []*ServicesUpdate_ServiceUpdate {
if x != nil {
return x.Services
}
return nil
}
type CollectorRequest struct { type CollectorRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
@@ -30,7 +110,7 @@ type CollectorRequest struct {
func (x *CollectorRequest) Reset() { func (x *CollectorRequest) Reset() {
*x = CollectorRequest{} *x = CollectorRequest{}
mi := &file_hellreign_proto_msgTypes[0] mi := &file_hellreign_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -42,7 +122,7 @@ func (x *CollectorRequest) String() string {
func (*CollectorRequest) ProtoMessage() {} func (*CollectorRequest) ProtoMessage() {}
func (x *CollectorRequest) ProtoReflect() protoreflect.Message { func (x *CollectorRequest) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[0] mi := &file_hellreign_proto_msgTypes[2]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -55,7 +135,7 @@ func (x *CollectorRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CollectorRequest.ProtoReflect.Descriptor instead. // Deprecated: Use CollectorRequest.ProtoReflect.Descriptor instead.
func (*CollectorRequest) Descriptor() ([]byte, []int) { func (*CollectorRequest) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{0} return file_hellreign_proto_rawDescGZIP(), []int{2}
} }
func (x *CollectorRequest) GetMessage() string { func (x *CollectorRequest) GetMessage() string {
@@ -73,7 +153,7 @@ type CollectorResponse struct {
func (x *CollectorResponse) Reset() { func (x *CollectorResponse) Reset() {
*x = CollectorResponse{} *x = CollectorResponse{}
mi := &file_hellreign_proto_msgTypes[1] mi := &file_hellreign_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -85,7 +165,7 @@ func (x *CollectorResponse) String() string {
func (*CollectorResponse) ProtoMessage() {} func (*CollectorResponse) ProtoMessage() {}
func (x *CollectorResponse) ProtoReflect() protoreflect.Message { func (x *CollectorResponse) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[1] mi := &file_hellreign_proto_msgTypes[3]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -98,7 +178,7 @@ func (x *CollectorResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CollectorResponse.ProtoReflect.Descriptor instead. // Deprecated: Use CollectorResponse.ProtoReflect.Descriptor instead.
func (*CollectorResponse) Descriptor() ([]byte, []int) { func (*CollectorResponse) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{1} return file_hellreign_proto_rawDescGZIP(), []int{3}
} }
type Command struct { type Command struct {
@@ -112,7 +192,7 @@ type Command struct {
func (x *Command) Reset() { func (x *Command) Reset() {
*x = Command{} *x = Command{}
mi := &file_hellreign_proto_msgTypes[2] mi := &file_hellreign_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -124,7 +204,7 @@ func (x *Command) String() string {
func (*Command) ProtoMessage() {} func (*Command) ProtoMessage() {}
func (x *Command) ProtoReflect() protoreflect.Message { func (x *Command) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[2] mi := &file_hellreign_proto_msgTypes[4]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -137,7 +217,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use Command.ProtoReflect.Descriptor instead. // Deprecated: Use Command.ProtoReflect.Descriptor instead.
func (*Command) Descriptor() ([]byte, []int) { func (*Command) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{2} return file_hellreign_proto_rawDescGZIP(), []int{4}
} }
func (x *Command) GetId() int64 { func (x *Command) GetId() int64 {
@@ -173,7 +253,7 @@ type FinishedCommand struct {
func (x *FinishedCommand) Reset() { func (x *FinishedCommand) Reset() {
*x = FinishedCommand{} *x = FinishedCommand{}
mi := &file_hellreign_proto_msgTypes[3] mi := &file_hellreign_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -185,7 +265,7 @@ func (x *FinishedCommand) String() string {
func (*FinishedCommand) ProtoMessage() {} func (*FinishedCommand) ProtoMessage() {}
func (x *FinishedCommand) ProtoReflect() protoreflect.Message { func (x *FinishedCommand) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[3] mi := &file_hellreign_proto_msgTypes[5]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -198,7 +278,7 @@ func (x *FinishedCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use FinishedCommand.ProtoReflect.Descriptor instead. // Deprecated: Use FinishedCommand.ProtoReflect.Descriptor instead.
func (*FinishedCommand) Descriptor() ([]byte, []int) { func (*FinishedCommand) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{3} return file_hellreign_proto_rawDescGZIP(), []int{5}
} }
func (x *FinishedCommand) GetId() int64 { func (x *FinishedCommand) GetId() int64 {
@@ -229,11 +309,69 @@ func (x *FinishedCommand) GetStderr() string {
return "" return ""
} }
type ServicesUpdate_ServiceUpdate struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ServicesUpdate_ServiceUpdate) Reset() {
*x = ServicesUpdate_ServiceUpdate{}
mi := &file_hellreign_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ServicesUpdate_ServiceUpdate) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServicesUpdate_ServiceUpdate) ProtoMessage() {}
func (x *ServicesUpdate_ServiceUpdate) ProtoReflect() protoreflect.Message {
mi := &file_hellreign_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ServicesUpdate_ServiceUpdate.ProtoReflect.Descriptor instead.
func (*ServicesUpdate_ServiceUpdate) Descriptor() ([]byte, []int) {
return file_hellreign_proto_rawDescGZIP(), []int{1, 0}
}
func (x *ServicesUpdate_ServiceUpdate) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ServicesUpdate_ServiceUpdate) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
var File_hellreign_proto protoreflect.FileDescriptor var File_hellreign_proto protoreflect.FileDescriptor
const file_hellreign_proto_rawDesc = "" + const file_hellreign_proto_rawDesc = "" +
"\n" + "\n" +
"\x0fhellreign.proto\x12\x04chat\",\n" + "\x0fhellreign.proto\x12\x04chat\"\x14\n" +
"\x12ServicesUpdateResp\"\x8d\x01\n" +
"\x0eServicesUpdate\x12>\n" +
"\bservices\x18\x01 \x03(\v2\".chat.ServicesUpdate.ServiceUpdateR\bservices\x1a;\n" +
"\rServiceUpdate\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\",\n" +
"\x10CollectorRequest\x12\x18\n" + "\x10CollectorRequest\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\"\x13\n" + "\amessage\x18\x01 \x01(\tR\amessage\"\x13\n" +
"\x11CollectorResponse\"X\n" + "\x11CollectorResponse\"X\n" +
@@ -246,9 +384,10 @@ const file_hellreign_proto_rawDesc = "" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
"\x06status\x18\x02 \x01(\x05R\x06status\x12\x16\n" + "\x06status\x18\x02 \x01(\x05R\x06status\x12\x16\n" +
"\x06stdout\x18\x03 \x01(\tR\x06stdout\x12\x16\n" + "\x06stdout\x18\x03 \x01(\tR\x06stdout\x12\x16\n" +
"\x06stderr\x18\x04 \x01(\tR\x06stderr2H\n" + "\x06stderr\x18\x04 \x01(\tR\x06stderr2\x8a\x01\n" +
"\tCollector\x12;\n" + "\tCollector\x12;\n" +
"\x06Stream\x12\x16.chat.CollectorRequest\x1a\x17.chat.CollectorResponse(\x012?\n" + "\x06Stream\x12\x16.chat.CollectorRequest\x1a\x17.chat.CollectorResponse(\x01\x12@\n" +
"\x0eReportServices\x12\x14.chat.ServicesUpdate\x1a\x18.chat.ServicesUpdateResp2?\n" +
"\tCommander\x122\n" + "\tCommander\x122\n" +
"\x06Stream\x12\x15.chat.FinishedCommand\x1a\r.chat.Command(\x010\x01B0Z.gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/protob\x06proto3" "\x06Stream\x12\x15.chat.FinishedCommand\x1a\r.chat.Command(\x010\x01B0Z.gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/protob\x06proto3"
@@ -264,23 +403,29 @@ func file_hellreign_proto_rawDescGZIP() []byte {
return file_hellreign_proto_rawDescData return file_hellreign_proto_rawDescData
} }
var file_hellreign_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_hellreign_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_hellreign_proto_goTypes = []any{ var file_hellreign_proto_goTypes = []any{
(*CollectorRequest)(nil), // 0: chat.CollectorRequest (*ServicesUpdateResp)(nil), // 0: chat.ServicesUpdateResp
(*CollectorResponse)(nil), // 1: chat.CollectorResponse (*ServicesUpdate)(nil), // 1: chat.ServicesUpdate
(*Command)(nil), // 2: chat.Command (*CollectorRequest)(nil), // 2: chat.CollectorRequest
(*FinishedCommand)(nil), // 3: chat.FinishedCommand (*CollectorResponse)(nil), // 3: chat.CollectorResponse
(*Command)(nil), // 4: chat.Command
(*FinishedCommand)(nil), // 5: chat.FinishedCommand
(*ServicesUpdate_ServiceUpdate)(nil), // 6: chat.ServicesUpdate.ServiceUpdate
} }
var file_hellreign_proto_depIdxs = []int32{ var file_hellreign_proto_depIdxs = []int32{
0, // 0: chat.Collector.Stream:input_type -> chat.CollectorRequest 6, // 0: chat.ServicesUpdate.services:type_name -> chat.ServicesUpdate.ServiceUpdate
3, // 1: chat.Commander.Stream:input_type -> chat.FinishedCommand 2, // 1: chat.Collector.Stream:input_type -> chat.CollectorRequest
1, // 2: chat.Collector.Stream:output_type -> chat.CollectorResponse 1, // 2: chat.Collector.ReportServices:input_type -> chat.ServicesUpdate
2, // 3: chat.Commander.Stream:output_type -> chat.Command 5, // 3: chat.Commander.Stream:input_type -> chat.FinishedCommand
2, // [2:4] is the sub-list for method output_type 3, // 4: chat.Collector.Stream:output_type -> chat.CollectorResponse
0, // [0:2] is the sub-list for method input_type 0, // 5: chat.Collector.ReportServices:output_type -> chat.ServicesUpdateResp
0, // [0:0] is the sub-list for extension type_name 4, // 6: chat.Commander.Stream:output_type -> chat.Command
0, // [0:0] is the sub-list for extension extendee 4, // [4:7] is the sub-list for method output_type
0, // [0:0] is the sub-list for field type_name 1, // [1:4] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
} }
func init() { file_hellreign_proto_init() } func init() { file_hellreign_proto_init() }
@@ -288,14 +433,14 @@ func file_hellreign_proto_init() {
if File_hellreign_proto != nil { if File_hellreign_proto != nil {
return return
} }
file_hellreign_proto_msgTypes[2].OneofWrappers = []any{} file_hellreign_proto_msgTypes[4].OneofWrappers = []any{}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hellreign_proto_rawDesc), len(file_hellreign_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hellreign_proto_rawDesc), len(file_hellreign_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 4, NumMessages: 7,
NumExtensions: 0, NumExtensions: 0,
NumServices: 2, NumServices: 2,
}, },
+40 -1
View File
@@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
Collector_Stream_FullMethodName = "/chat.Collector/Stream" Collector_Stream_FullMethodName = "/chat.Collector/Stream"
Collector_ReportServices_FullMethodName = "/chat.Collector/ReportServices"
) )
// CollectorClient is the client API for Collector service. // CollectorClient is the client API for Collector service.
@@ -27,6 +28,7 @@ const (
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type CollectorClient interface { type CollectorClient interface {
Stream(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[CollectorRequest, CollectorResponse], error) Stream(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[CollectorRequest, CollectorResponse], error)
ReportServices(ctx context.Context, in *ServicesUpdate, opts ...grpc.CallOption) (*ServicesUpdateResp, error)
} }
type collectorClient struct { type collectorClient struct {
@@ -50,11 +52,22 @@ func (c *collectorClient) Stream(ctx context.Context, opts ...grpc.CallOption) (
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Collector_StreamClient = grpc.ClientStreamingClient[CollectorRequest, CollectorResponse] type Collector_StreamClient = grpc.ClientStreamingClient[CollectorRequest, CollectorResponse]
func (c *collectorClient) ReportServices(ctx context.Context, in *ServicesUpdate, opts ...grpc.CallOption) (*ServicesUpdateResp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ServicesUpdateResp)
err := c.cc.Invoke(ctx, Collector_ReportServices_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CollectorServer is the server API for Collector service. // CollectorServer is the server API for Collector service.
// All implementations must embed UnimplementedCollectorServer // All implementations must embed UnimplementedCollectorServer
// for forward compatibility. // for forward compatibility.
type CollectorServer interface { type CollectorServer interface {
Stream(grpc.ClientStreamingServer[CollectorRequest, CollectorResponse]) error Stream(grpc.ClientStreamingServer[CollectorRequest, CollectorResponse]) error
ReportServices(context.Context, *ServicesUpdate) (*ServicesUpdateResp, error)
mustEmbedUnimplementedCollectorServer() mustEmbedUnimplementedCollectorServer()
} }
@@ -68,6 +81,9 @@ type UnimplementedCollectorServer struct{}
func (UnimplementedCollectorServer) Stream(grpc.ClientStreamingServer[CollectorRequest, CollectorResponse]) error { func (UnimplementedCollectorServer) Stream(grpc.ClientStreamingServer[CollectorRequest, CollectorResponse]) error {
return status.Error(codes.Unimplemented, "method Stream not implemented") return status.Error(codes.Unimplemented, "method Stream not implemented")
} }
func (UnimplementedCollectorServer) ReportServices(context.Context, *ServicesUpdate) (*ServicesUpdateResp, error) {
return nil, status.Error(codes.Unimplemented, "method ReportServices not implemented")
}
func (UnimplementedCollectorServer) mustEmbedUnimplementedCollectorServer() {} func (UnimplementedCollectorServer) mustEmbedUnimplementedCollectorServer() {}
func (UnimplementedCollectorServer) testEmbeddedByValue() {} func (UnimplementedCollectorServer) testEmbeddedByValue() {}
@@ -96,13 +112,36 @@ func _Collector_Stream_Handler(srv interface{}, stream grpc.ServerStream) error
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Collector_StreamServer = grpc.ClientStreamingServer[CollectorRequest, CollectorResponse] type Collector_StreamServer = grpc.ClientStreamingServer[CollectorRequest, CollectorResponse]
func _Collector_ReportServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ServicesUpdate)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CollectorServer).ReportServices(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Collector_ReportServices_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CollectorServer).ReportServices(ctx, req.(*ServicesUpdate))
}
return interceptor(ctx, in, info, handler)
}
// Collector_ServiceDesc is the grpc.ServiceDesc for Collector service. // Collector_ServiceDesc is the grpc.ServiceDesc for Collector service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
var Collector_ServiceDesc = grpc.ServiceDesc{ var Collector_ServiceDesc = grpc.ServiceDesc{
ServiceName: "chat.Collector", ServiceName: "chat.Collector",
HandlerType: (*CollectorServer)(nil), HandlerType: (*CollectorServer)(nil),
Methods: []grpc.MethodDesc{}, Methods: []grpc.MethodDesc{
{
MethodName: "ReportServices",
Handler: _Collector_ReportServices_Handler,
},
},
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
StreamName: "Stream", StreamName: "Stream",