chore: grpc + mtls working
ci-agent / build (push) Failing after 1m19s

This commit is contained in:
d3m0k1d
2026-04-04 03:55:37 +03:00
parent 28631865c8
commit a2c71da3a0
24 changed files with 1095 additions and 31 deletions
+9 -5
View File
@@ -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"]
+1
View File
@@ -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 (
+4
View File
@@ -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=
+32
View File
@@ -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
}
+49
View File
@@ -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)
}
+169
View File
@@ -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(&regResp); 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
}
+48
View File
@@ -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")
}
+84 -6
View File
@@ -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"))
}
+8 -8
View File
@@ -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"]
+111
View File
@@ -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": {
+111
View File
@@ -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": {
+70
View File
@@ -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:
+3 -1
View File
@@ -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
-2
View File
@@ -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=
@@ -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
+121
View File
@@ -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"
}
+21
View File
@@ -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"`
}
+59
View File
@@ -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
}
+11
View File
@@ -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(),
+157
View File
@@ -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
}
+2 -2
View File
@@ -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..."
+2 -3
View File
@@ -1,5 +1,4 @@
backend_url: http://backend:8080
label: test-agent-1
services:
- service1
- service2
registration_token: ""
cert_dir: /etc/hellreign-agent/certs
+6
View File
@@ -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
+11 -4
View File
@@ -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: