diff --git a/agent/dockerfile b/agent/dockerfile index 67ce9f4..4e71330 100644 --- a/agent/dockerfile +++ b/agent/dockerfile @@ -2,16 +2,20 @@ FROM golang:1.26.1 as builder WORKDIR /app -COPY go.mod go.sum ./ +COPY agent/ agent/ +COPY proto/ proto/ +WORKDIR /app/agent RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go mod download -COPY . . -ENV CGO_ENABLED=0 +COPY agent/ agent/ +COPY proto/ proto/ +WORKDIR /app/agent RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags "-s -w" -o agent ./main.go + go mod tidy && \ + CGO_ENABLED=0 go build -ldflags "-s -w" -o agent ./main.go FROM debian:bookworm-slim @@ -21,6 +25,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -COPY --from=builder /app/agent . +COPY --from=builder /app/agent/agent . CMD ["./agent"] diff --git a/agent/go.mod b/agent/go.mod index 934b326..99a5f1a 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -6,6 +6,7 @@ require ( gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d golang.org/x/sync v0.20.0 google.golang.org/grpc v1.80.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/agent/go.sum b/agent/go.sum index 607684f..2a417de 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -40,3 +40,7 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go new file mode 100644 index 0000000..4093af9 --- /dev/null +++ b/agent/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type AgentConfig struct { + BackendURL string `yaml:"backend_url"` + RegistrationToken string `yaml:"registration_token"` + Label string `yaml:"label"` + CertDir string `yaml:"cert_dir"` +} + +func Load(path string) (*AgentConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg AgentConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + if cfg.CertDir == "" { + cfg.CertDir = "/etc/hellreign-agent/certs" + } + + return &cfg, nil +} diff --git a/agent/internal/mtls/credentials.go b/agent/internal/mtls/credentials.go new file mode 100644 index 0000000..77a29f8 --- /dev/null +++ b/agent/internal/mtls/credentials.go @@ -0,0 +1,49 @@ +package mtls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "google.golang.org/grpc/credentials" +) + +// LoadMTLSCredentials loads client certificate and CA certificate for mTLS. +func LoadMTLSCredentials(caCertPEM, clientCertPEM, clientKeyPEM []byte) (credentials.TransportCredentials, error) { + cert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM) + if err != nil { + return nil, fmt.Errorf("load client key pair: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCertPEM) { + return nil, fmt.Errorf("failed to append CA certificate") + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + } + + return credentials.NewTLS(tlsConfig), nil +} + +// LoadMTLSCredentialsFromFiles loads mTLS credentials from file paths. +func LoadMTLSCredentialsFromFiles(caCertPath, clientCertPath, clientKeyPath string) (credentials.TransportCredentials, error) { + caCert, err := os.ReadFile(caCertPath) + if err != nil { + return nil, fmt.Errorf("read CA cert: %w", err) + } + clientCert, err := os.ReadFile(clientCertPath) + if err != nil { + return nil, fmt.Errorf("read client cert: %w", err) + } + clientKey, err := os.ReadFile(clientKeyPath) + if err != nil { + return nil, fmt.Errorf("read client key: %w", err) + } + + return LoadMTLSCredentials(caCert, clientCert, clientKey) +} diff --git a/agent/internal/registration/registration.go b/agent/internal/registration/registration.go new file mode 100644 index 0000000..416396e --- /dev/null +++ b/agent/internal/registration/registration.go @@ -0,0 +1,169 @@ +package registration + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "os" + "path/filepath" +) + +type Certs struct { + CACertPEM []byte + ClientCertPEM []byte + ClientKeyPEM []byte +} + +type RegisterRequest struct { + CSR string `json:"csr"` + Token string `json:"token"` +} + +type RegisterResponse struct { + CACert string `json:"ca_cert"` + ClientCert string `json:"client_cert"` +} + +type TokenResponse struct { + Token string `json:"token"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +// GenerateKeyAndCSR generates a new ECDSA private key and CSR for the agent. +func GenerateKeyAndCSR(label string) (*ecdsa.PrivateKey, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate key: %w", err) + } + + template := x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: label, + Organization: []string{"HellreigN Agent"}, + }, + SignatureAlgorithm: x509.ECDSAWithSHA256, + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key) + if err != nil { + return nil, nil, fmt.Errorf("create csr: %w", err) + } + + csrPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrDER, + }) + + return key, csrPEM, nil +} + +// Register sends CSR to backend and receives signed certificates. +func Register(backendURL, token string, csrPEM []byte) (*Certs, error) { + reqBody := RegisterRequest{CSR: string(csrPEM), Token: token} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/api/v1/agents/register", backendURL) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("register request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + json.NewDecoder(resp.Body).Decode(&errResp) + return nil, fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, errResp.Error) + } + + var regResp RegisterResponse + if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &Certs{ + CACertPEM: []byte(regResp.CACert), + ClientCertPEM: []byte(regResp.ClientCert), + }, nil +} + +// SaveCerts saves CA cert, client cert, and client key to the given directory. +func SaveCerts(certDir string, certs *Certs, key *ecdsa.PrivateKey) error { + if err := os.MkdirAll(certDir, 0700); err != nil { + return fmt.Errorf("create cert dir: %w", err) + } + + if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certs.CACertPEM, 0644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(certDir, "client.crt"), certs.ClientCertPEM, 0644); err != nil { + return err + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyDER, + }) + if err := os.WriteFile(filepath.Join(certDir, "client.key"), keyPEM, 0600); err != nil { + return err + } + + return nil +} + +// LoadCerts loads existing certificates and key from disk. +func LoadCerts(certDir string) (*Certs, *ecdsa.PrivateKey, error) { + caCert, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) + if err != nil { + return nil, nil, err + } + clientCert, err := os.ReadFile(filepath.Join(certDir, "client.crt")) + if err != nil { + return nil, nil, err + } + clientKeyPEM, err := os.ReadFile(filepath.Join(certDir, "client.key")) + if err != nil { + return nil, nil, err + } + + block, _ := pem.Decode(clientKeyPEM) + if block == nil { + return nil, nil, fmt.Errorf("decode client key") + } + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse client key: %w", err) + } + + return &Certs{ + CACertPEM: caCert, + ClientCertPEM: clientCert, + }, key, nil +} + +// CertsExist checks if all certificate files exist in the directory. +func CertsExist(certDir string) bool { + files := []string{"ca.crt", "client.crt", "client.key"} + for _, f := range files { + if _, err := os.Stat(filepath.Join(certDir, f)); err != nil { + return false + } + } + return true +} diff --git a/agent/main.go b/agent/main.go index 06ab7d0..bafa085 100644 --- a/agent/main.go +++ b/agent/main.go @@ -1 +1,49 @@ package main + +import ( + "log" + "os" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration" +) + +func main() { + cfgPath := os.Getenv("CONFIG_FILE") + if cfgPath == "" { + cfgPath = "/etc/hellreign-agent/config.yml" + } + + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Agent label: %s", cfg.Label) + + if cfg.RegistrationToken == "" { + log.Fatal("No registration token provided") + } + + // Generate key and CSR + key, csrPEM, err := registration.GenerateKeyAndCSR(cfg.Label) + if err != nil { + log.Fatalf("Failed to generate key and CSR: %v", err) + } + log.Println("Generated ECDSA key pair and CSR") + + // Register with backend + certs, err := registration.Register(cfg.BackendURL, cfg.RegistrationToken, csrPEM) + if err != nil { + log.Fatalf("Failed to register: %v", err) + } + log.Println("Successfully registered, received certificates") + + // Save certificates + if err := registration.SaveCerts(cfg.CertDir, certs, key); err != nil { + log.Fatalf("Failed to save certificates: %v", err) + } + log.Printf("Certificates saved to %s", cfg.CertDir) + + log.Println("Agent registration complete") +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 808558e..93b950e 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -2,15 +2,22 @@ package main import ( "context" + "crypto/tls" + "crypto/x509" "log" + "net" "os" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "github.com/gin-gonic/gin" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) @@ -39,17 +46,24 @@ func main() { h := handlers.New(db) agents := handlers.AgentsGroup{Handlers: h} auth := handlers.AuthGroup{Handlers: h} + agentReg := handlers.NewAgentRegistrationGroup(h) + + // Initialize registration tokens table + if err := h.Repo.InitRegistrationTokens(); err != nil { + log.Printf("Warning: failed to initialize registration tokens table: %v", err) + } // Create admin user from config if not exists if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" { if !h.Repo.ExistsByLogin(cfg.Admin.Admin_login) { _, err := h.Repo.CreateToken(repository.TokenCreate{ - Name: cfg.Admin.Admin_name, - LastName: cfg.Admin.Admin_last_name, - Login: cfg.Admin.Admin_login, - Password: cfg.Admin.Admin_password, - PermissionView: true, - PermissionAdmin: true, + Name: cfg.Admin.Admin_name, + LastName: cfg.Admin.Admin_last_name, + Login: cfg.Admin.Admin_login, + Password: cfg.Admin.Admin_password, + PermissionView: true, + PermissionManage: true, + PermissionAdmin: true, }) if err != nil { log.Printf("Warning: failed to create admin user: %v", err) @@ -93,6 +107,17 @@ func main() { agentsGroup.GET("", agents.List) } + // Agent registration + agentRegGroup := v1.Group("/agents") + { + agentRegGroup.POST("/register", agentReg.Register) + } + agentRegTokenGroup := v1.Group("/agents") + agentRegTokenGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent()) + { + agentRegTokenGroup.POST("/register-token", agentReg.CreateRegistrationToken) + } + // Logs (requires view permission) logsGroup := v1.Group("/logs") logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView()) @@ -126,5 +151,58 @@ func main() { } } + // Start gRPC server with mTLS in background + grpcPort := os.Getenv("GRPC_PORT") + if grpcPort == "" { + grpcPort = "9001" + } + + certDir := os.Getenv("SSL_CERT_DIR") + if certDir == "" { + certDir = "/var/lib/hellreign/ssl" + } + + certFile := certDir + "/server.crt" + keyFile := certDir + "/server.key" + caFile := certDir + "/ca.crt" + + // Load server cert + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("Failed to load server cert: %v", err) + } + + // Load CA cert for client verification + caCert, err := os.ReadFile(caFile) + if err != nil { + log.Fatalf("Failed to load CA cert: %v", err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: caCertPool, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: tls.VersionTLS12, + } + + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + cmdr := commander.New() + proto.RegisterCommanderServer(grpcServer, cmdr) + + lis, err := net.Listen("tcp", ":"+grpcPort) + if err != nil { + log.Fatalf("Failed to listen on gRPC port %s: %v", grpcPort, err) + } + + go func() { + log.Printf("gRPC server starting on port %s with mTLS", grpcPort) + if err := grpcServer.Serve(lis); err != nil { + log.Fatalf("gRPC server error: %v", err) + } + }() + defer grpcServer.GracefulStop() + log.Fatal(router.Run(":8080")) } diff --git a/backend/dockerfile b/backend/dockerfile index 1e7340d..4e9e2ca 100644 --- a/backend/dockerfile +++ b/backend/dockerfile @@ -2,7 +2,9 @@ FROM golang:1.26.1 as builder WORKDIR /app -COPY . . +COPY backend/ backend/ +COPY proto/ proto/ +WORKDIR /app/backend ENV CGO_ENABLED=0 ENV GIN_MODE=release RUN --mount=type=cache,target=/go/pkg/mod \ @@ -14,11 +16,9 @@ FROM alpine:3.23.0 RUN apk add --no-cache curl openssl bash -COPY --from=builder /app/backend . -#COPY --from=builder /app/scripts /etc/mnemosyne/scripts -#RUN chmod +x /etc/mnemosyne/scripts/generate-certs.sh +COPY --from=builder /app/backend/backend . +COPY --from=builder /app/backend/scripts /etc/hellreign/scripts +RUN chmod +x /etc/hellreign/scripts/generate-certs.sh -EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ] - -CMD ["./backend"] +# Generate certificates on container start +ENTRYPOINT ["/bin/sh", "-c", "/etc/hellreign/scripts/generate-certs.sh ${SSL_CERT_DIR:-/var/lib/hellreign/ssl} && exec ./backend"] diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 9a2e3c6..60f82ea 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -38,6 +38,80 @@ const docTemplate = `{ } } }, + "/agents/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agents" + ], + "summary": "Register agent", + "parameters": [ + { + "description": "CSR + token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterResponse" + } + } + } + } + }, + "/agents/register-token": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agents" + ], + "summary": "Create registration token", + "parameters": [ + { + "description": "Label", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/auth/login": { "post": { "description": "Authenticate with login and password, returns a token and permissions", @@ -545,6 +619,17 @@ const docTemplate = `{ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate": { "type": "object", "required": [ @@ -682,6 +767,32 @@ const docTemplate = `{ } } } + }, + "internal_handlers.RegisterRequest": { + "type": "object", + "required": [ + "csr", + "token" + ], + "properties": { + "csr": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "internal_handlers.RegisterResponse": { + "type": "object", + "properties": { + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 922388b..758c170 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -27,6 +27,80 @@ } } }, + "/agents/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agents" + ], + "summary": "Register agent", + "parameters": [ + { + "description": "CSR + token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterResponse" + } + } + } + } + }, + "/agents/register-token": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agents" + ], + "summary": "Create registration token", + "parameters": [ + { + "description": "Label", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/auth/login": { "post": { "description": "Authenticate with login and password, returns a token and permissions", @@ -534,6 +608,17 @@ } } }, + "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string" + } + } + }, "gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate": { "type": "object", "required": [ @@ -671,6 +756,32 @@ } } } + }, + "internal_handlers.RegisterRequest": { + "type": "object", + "required": [ + "csr", + "token" + ], + "properties": { + "csr": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "internal_handlers.RegisterResponse": { + "type": "object", + "properties": { + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 1c8fa86..c017e44 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -26,6 +26,13 @@ definitions: token: type: string type: object + gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest: + properties: + label: + type: string + required: + - label + type: object gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.TokenCreate: properties: last_name: @@ -118,6 +125,23 @@ definitions: required: - logs type: object + internal_handlers.RegisterRequest: + properties: + csr: + type: string + token: + type: string + required: + - csr + - token + type: object + internal_handlers.RegisterResponse: + properties: + ca_cert: + type: string + client_cert: + type: string + type: object info: contact: {} paths: @@ -136,6 +160,52 @@ paths: summary: Get connected agents tags: - agents + /agents/register: + post: + consumes: + - application/json + parameters: + - description: CSR + token + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.RegisterRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.RegisterResponse' + summary: Register agent + tags: + - agents + /agents/register-token: + post: + consumes: + - application/json + parameters: + - description: Label + in: body + name: request + required: true + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.RegistrationRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Create registration token + tags: + - agents /auth/login: post: consumes: diff --git a/backend/go.mod b/backend/go.mod index e73c1ce..002149b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.49.0 golang.org/x/sync v0.20.0 google.golang.org/grpc v1.80.0 gopkg.in/yaml.v3 v3.0.1 @@ -68,7 +69,6 @@ require ( go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.25.0 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect @@ -80,3 +80,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto diff --git a/backend/go.sum b/backend/go.sum index e78bda7..f229a25 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,3 @@ -gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e h1:Z/2Mjc9NU0CWIduj8Wd9ClnZt4dqmQUUXl1VlyGQe6U= -gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e/go.mod h1:1DByetpOnW2+AjM8ZWbJ1Xfzprus8fBie2AMUP/YHHA= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ= diff --git a/backend/internal/grpcsrv/commander/commander.go b/backend/internal/grpcsrv/commander/commander.go index 1ef35c3..d809ad0 100644 --- a/backend/internal/grpcsrv/commander/commander.go +++ b/backend/internal/grpcsrv/commander/commander.go @@ -17,6 +17,12 @@ type Commander struct { agents map[string]Agent } +func New() *Commander { + return &Commander{ + agents: make(map[string]Agent), + } +} + type Agent struct { bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command] in chan *proto.Command diff --git a/backend/internal/handlers/agent_register.go b/backend/internal/handlers/agent_register.go new file mode 100644 index 0000000..0881fcc --- /dev/null +++ b/backend/internal/handlers/agent_register.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "log" + "net/http" + "os" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils" + "github.com/gin-gonic/gin" +) + +type AgentRegistrationGroup struct { + *Handlers + certBundle *utils.CertBundle +} + +func NewAgentRegistrationGroup(h *Handlers) *AgentRegistrationGroup { + certDir := getCertDir() + bundle, err := utils.LoadCertBundle(certDir) + if err != nil { + log.Printf("[agent-reg] WARNING: cert bundle load failed: %v", err) + } + return &AgentRegistrationGroup{ + Handlers: h, + certBundle: bundle, + } +} + +// CreateRegistrationToken — админ создаёт токен для агента +// @Summary Create registration token +// @Tags agents +// @Accept json +// @Produce json +// @Param request body repository.RegistrationRequest true "Label" +// @Success 200 {object} map[string]string +// @Security Bearer +// @Router /agents/register-token [post] +func (arg *AgentRegistrationGroup) CreateRegistrationToken(c *gin.Context) { + var req repository.RegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token, err := arg.Repo.CreateRegistrationToken(req.Label) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"token": token}) +} + +// Register — агент шлёт CSR + token, получает сертификаты +// @Summary Register agent +// @Tags agents +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "CSR + token" +// @Success 200 {object} RegisterResponse +// @Router /agents/register [post] +func (arg *AgentRegistrationGroup) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if arg.certBundle == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "certificate bundle not available"}) + return + } + + regToken, err := arg.Repo.GetRegistrationToken(req.Token) + if err != nil { + if err == repository.ErrNotFound { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid registration token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify token"}) + return + } + + if regToken.Used { + c.JSON(http.StatusGone, gin.H{"error": "registration token already used"}) + return + } + + clientCertPEM, err := arg.certBundle.SignCSR([]byte(req.CSR), regToken.Label) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to sign CSR: " + err.Error()}) + return + } + + if err := arg.Repo.MarkRegistrationTokenUsed(req.Token); err != nil { + log.Printf("[agent-reg] WARNING: failed to mark token used: %v", err) + } + + c.JSON(http.StatusOK, RegisterResponse{ + CACert: string(arg.certBundle.GetCACertPEM()), + ClientCert: string(clientCertPEM), + }) +} + +type RegisterRequest struct { + CSR string `json:"csr" binding:"required"` + Token string `json:"token" binding:"required"` +} + +type RegisterResponse struct { + CACert string `json:"ca_cert"` + ClientCert string `json:"client_cert"` +} + +func getCertDir() string { + if d := os.Getenv("SSL_CERT_DIR"); d != "" { + return d + } + return "/var/lib/hellreign/ssl" +} diff --git a/backend/internal/repository/models.go b/backend/internal/repository/models.go index 4de72c2..c7cf39f 100644 --- a/backend/internal/repository/models.go +++ b/backend/internal/repository/models.go @@ -39,3 +39,24 @@ type LoginResponse struct { PermissionManage bool `json:"permission_manage_agent"` PermissionAdmin bool `json:"permission_admin"` } + +// RegistrationToken represents a one-time agent registration token. +type RegistrationToken struct { + ID int64 `json:"id"` + Token string `json:"token"` + Label string `json:"label"` + Used bool `json:"used"` + CreatedAt *string `json:"created_at"` + UsedAt *string `json:"used_at"` +} + +// RegistrationRequest is the request body for creating a registration token. +type RegistrationRequest struct { + Label string `json:"label" binding:"required"` +} + +// RegistrationResponse is returned when an agent registers. +type RegistrationResponse struct { + CACert string `json:"ca_cert"` + ClientCert string `json:"client_cert"` +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 8033e2c..7d1da55 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -185,3 +185,62 @@ func (r *Repository) ExistsByLogin(login string) bool { } return count > 0 } + +// InitRegistrationTokens creates the registration_tokens table if it does not exist. +func (r *Repository) InitRegistrationTokens() error { + _, err := r.DB.Exec(storage.CreateRegistrationTokensTable) + return err +} + +// CreateRegistrationToken inserts a new one-time registration token. +func (r *Repository) CreateRegistrationToken(label string) (string, error) { + token, err := utils.RandomToken() + if err != nil { + return "", err + } + + _, err = r.DB.Exec( + `INSERT INTO registration_tokens (token, label, used) VALUES (?, ?, 0)`, + token, label, + ) + if err != nil { + return "", err + } + return token, nil +} + +// GetRegistrationToken retrieves a registration token if it exists and is not used. +func (r *Repository) GetRegistrationToken(token string) (*RegistrationToken, error) { + var rt RegistrationToken + err := r.DB.QueryRow( + `SELECT id, token, label, used, created_at, used_at FROM registration_tokens WHERE token = ?`, + token, + ).Scan(&rt.ID, &rt.Token, &rt.Label, &rt.Used, &rt.CreatedAt, &rt.UsedAt) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &rt, nil +} + +// MarkRegistrationTokenUsed marks a registration token as used. +func (r *Repository) MarkRegistrationTokenUsed(token string) error { + result, err := r.DB.Exec( + `UPDATE registration_tokens SET used = 1, used_at = CURRENT_TIMESTAMP WHERE token = ? AND used = 0`, + token, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/storage/migrations.go b/backend/internal/storage/migrations.go index 15e9946..06d1989 100644 --- a/backend/internal/storage/migrations.go +++ b/backend/internal/storage/migrations.go @@ -14,6 +14,17 @@ const CreateSqlite = ` ); ` +const CreateRegistrationTokensTable = ` +CREATE TABLE IF NOT EXISTS registration_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + used BOOL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + used_at DATETIME +); +` + const CreateLogsTable = ` CREATE TABLE IF NOT EXISTS logs ( timestamp DateTime64(3) DEFAULT now(), diff --git a/backend/internal/utils/cert_helper.go b/backend/internal/utils/cert_helper.go new file mode 100644 index 0000000..614fc91 --- /dev/null +++ b/backend/internal/utils/cert_helper.go @@ -0,0 +1,157 @@ +package utils + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// CertBundle holds CA and server certificates loaded from disk. +type CertBundle struct { + CACert *x509.Certificate + CAKey *rsa.PrivateKey + ServerCert *x509.Certificate + ServerKey *rsa.PrivateKey +} + +// LoadCertBundle loads CA and server certificates from the given directory. +func LoadCertBundle(certDir string) (*CertBundle, error) { + caCertPEM, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) + if err != nil { + return nil, fmt.Errorf("read ca.crt: %w", err) + } + + caKeyPEM, err := os.ReadFile(filepath.Join(certDir, "ca.key")) + if err != nil { + return nil, fmt.Errorf("read ca.key: %w", err) + } + + serverCertPEM, err := os.ReadFile(filepath.Join(certDir, "server.crt")) + if err != nil { + return nil, fmt.Errorf("read server.crt: %w", err) + } + + serverKeyPEM, err := os.ReadFile(filepath.Join(certDir, "server.key")) + if err != nil { + return nil, fmt.Errorf("read server.key: %w", err) + } + + caCert := decodeCert(caCertPEM) + caKey, err := decodeRSAPrivateKey(caKeyPEM) + if err != nil { + return nil, fmt.Errorf("parse ca.key: %w", err) + } + serverCert := decodeCert(serverCertPEM) + serverKey, err := decodeRSAPrivateKey(serverKeyPEM) + if err != nil { + return nil, fmt.Errorf("parse server.key: %w", err) + } + + return &CertBundle{ + CACert: caCert, + CAKey: caKey, + ServerCert: serverCert, + ServerKey: serverKey, + }, nil +} + +// SignCSR signs a client CSR with the CA and returns the client certificate PEM. +func (b *CertBundle) SignCSR(csrPEM []byte, label string) ([]byte, error) { + csr := decodeCSR(csrPEM) + + // Verify CSR signature + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("invalid CSR signature: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generate serial: %w", err) + } + + now := time.Now() + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: label, + Organization: csr.Subject.Organization, + }, + NotBefore: now, + NotAfter: now.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, b.CACert, csr.PublicKey, b.CAKey) + if err != nil { + return nil, fmt.Errorf("create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + return certPEM, nil +} + +// GetCACertPEM returns the CA certificate as PEM bytes. +func (b *CertBundle) GetCACertPEM() []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: b.CACert.Raw, + }) +} + +func decodeCert(pemData []byte) *x509.Certificate { + block, _ := pem.Decode(pemData) + if block == nil { + return nil + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil + } + return cert +} + +func decodeRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + // Try PKCS1 fallback + key, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse PKCS1: %w", err) + } + return key.(*rsa.PrivateKey), nil + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not RSA, got %T", key) + } + return rsaKey, nil +} + +func decodeCSR(pemData []byte) *x509.CertificateRequest { + block, _ := pem.Decode(pemData) + if block == nil { + return nil + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil + } + return csr +} diff --git a/backend/scripts/generate-certs.sh b/backend/scripts/generate-certs.sh index ef3af7c..5339eb5 100644 --- a/backend/scripts/generate-certs.sh +++ b/backend/scripts/generate-certs.sh @@ -3,7 +3,7 @@ set -e -CERT_DIR="${1:-/etc/mnemosyne/ssl}" +CERT_DIR="${1:-/etc/HellreigN/ssl}" DAYS_VALID=365 echo "Generating CA and server certificates in ${CERT_DIR}..." @@ -26,7 +26,7 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/c openssl req -x509 -new -nodes -sha256 -days ${DAYS_VALID} \ -key "${CERT_DIR}/ca.key" \ -out "${CERT_DIR}/ca.crt" \ - -subj "/CN=Mnemosyne Root CA" + -subj "/CN=HellreigN Root CA" # Генерация серверного сертификата echo "Generating server certificate..." diff --git a/infra/agent/config.yml b/infra/agent/config.yml index 333d79d..76854ab 100644 --- a/infra/agent/config.yml +++ b/infra/agent/config.yml @@ -1,5 +1,4 @@ backend_url: http://backend:8080 label: test-agent-1 -services: - - service1 - - service2 +registration_token: "" +cert_dir: /etc/hellreign-agent/certs diff --git a/infra/backend/config.yml b/infra/backend/config.yml index d837afc..a6ed278 100644 --- a/infra/backend/config.yml +++ b/infra/backend/config.yml @@ -3,3 +3,9 @@ database: clickhouse_host: clickhouse:9000 clickhouse_user: default clickhouse_password: testpassword + clickhouse_database: hellreign +admin: + admin_name: Admin + admin_last_name: User + admin_login: admin + admin_password: admin123 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index f04ec9b..1310e54 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -18,13 +18,17 @@ services: backend: build: - context: ../backend - dockerfile: dockerfile + context: .. + dockerfile: backend/dockerfile container_name: hellreign-backend environment: CONFIG_FILE: /etc/hellreign/config.yml + SSL_CERT_DIR: /var/lib/hellreign/ssl + SERVER_SAN_DNS: localhost,backend + SERVER_SAN_IP: 127.0.0.1 ports: - "8080:8080" + - "9001:9001" volumes: - ./backend/config.yml:/etc/hellreign/config.yml:ro - backend_data:/var/lib/hellreign @@ -47,13 +51,14 @@ services: agent: build: - context: ../agent - dockerfile: dockerfile + context: .. + dockerfile: agent/dockerfile container_name: hellreign-agent environment: CONFIG_FILE: /etc/hellreign-agent/config.yml volumes: - ./agent/config.yml:/etc/hellreign-agent/config.yml:ro + - agent_certs:/etc/hellreign-agent/certs depends_on: - backend networks: @@ -64,6 +69,8 @@ volumes: driver: local backend_data: driver: local + agent_certs: + driver: local networks: hellreign: