- Created `qa-test-output-after-fix.txt` and `qa-test-output.txt` to log results of certificate page authentication tests. - Added `build.sh` for deterministic backend builds in CI, utilizing `go list` for efficiency. - Introduced `codeql_scan.sh` for CodeQL database creation and analysis for Go and JavaScript/TypeScript. - Implemented `dockerfile_check.sh` to validate Dockerfiles for base image and package manager mismatches. - Added `sourcery_precommit_wrapper.sh` to facilitate Sourcery CLI usage in pre-commit hooks.
356 lines
10 KiB
Go
356 lines
10 KiB
Go
package crowdsec
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
const (
|
|
consoleStatusNotEnrolled = "not_enrolled"
|
|
consoleStatusEnrolling = "enrolling"
|
|
consoleStatusEnrolled = "enrolled"
|
|
consoleStatusFailed = "failed"
|
|
|
|
defaultEnrollTimeout = 45 * time.Second
|
|
)
|
|
|
|
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_.\-]{1,64}$`)
|
|
var enrollmentTokenPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,64}$`)
|
|
|
|
// EnvCommandExecutor executes commands with optional environment overrides.
|
|
type EnvCommandExecutor interface {
|
|
ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error)
|
|
}
|
|
|
|
// SecureCommandExecutor is the production executor that avoids leaking args by passing secrets via env.
|
|
type SecureCommandExecutor struct{}
|
|
|
|
// ExecuteWithEnv runs the command with provided env merged onto the current environment.
|
|
func (r *SecureCommandExecutor) ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(os.Environ(), formatEnv(env)...)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func formatEnv(env map[string]string) []string {
|
|
if len(env) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(env))
|
|
for k, v := range env {
|
|
result = append(result, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ConsoleEnrollRequest captures enrollment input.
|
|
type ConsoleEnrollRequest struct {
|
|
EnrollmentKey string
|
|
Tenant string
|
|
AgentName string
|
|
Force bool
|
|
}
|
|
|
|
// ConsoleEnrollmentStatus is the safe, redacted status view.
|
|
type ConsoleEnrollmentStatus struct {
|
|
Status string `json:"status"`
|
|
Tenant string `json:"tenant"`
|
|
AgentName string `json:"agent_name"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
|
EnrolledAt *time.Time `json:"enrolled_at,omitempty"`
|
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
|
KeyPresent bool `json:"key_present"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
}
|
|
|
|
// ConsoleEnrollmentService manages console enrollment lifecycle and persistence.
|
|
type ConsoleEnrollmentService struct {
|
|
db *gorm.DB
|
|
exec EnvCommandExecutor
|
|
dataDir string
|
|
key []byte
|
|
nowFn func() time.Time
|
|
mu sync.Mutex
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewConsoleEnrollmentService constructs a service using the supplied secret material for encryption.
|
|
func NewConsoleEnrollmentService(db *gorm.DB, executor EnvCommandExecutor, dataDir, secret string) *ConsoleEnrollmentService {
|
|
return &ConsoleEnrollmentService{
|
|
db: db,
|
|
exec: executor,
|
|
dataDir: dataDir,
|
|
key: deriveKey(secret),
|
|
nowFn: time.Now,
|
|
timeout: defaultEnrollTimeout,
|
|
}
|
|
}
|
|
|
|
// Status returns the current enrollment state.
|
|
func (s *ConsoleEnrollmentService) Status(ctx context.Context) (ConsoleEnrollmentStatus, error) {
|
|
rec, err := s.load(ctx)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
// Enroll performs an enrollment attempt. It is idempotent when already enrolled unless Force is set.
|
|
func (s *ConsoleEnrollmentService) Enroll(ctx context.Context, req ConsoleEnrollRequest) (ConsoleEnrollmentStatus, error) {
|
|
agent := strings.TrimSpace(req.AgentName)
|
|
if agent == "" {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("agent_name required")
|
|
}
|
|
if !namePattern.MatchString(agent) {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("agent_name may only include letters, numbers, dot, dash, underscore")
|
|
}
|
|
tenant := strings.TrimSpace(req.Tenant)
|
|
if tenant != "" && !namePattern.MatchString(tenant) {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("tenant may only include letters, numbers, dot, dash, underscore")
|
|
}
|
|
token, err := normalizeEnrollmentKey(req.EnrollmentKey)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
if s.exec == nil {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("executor unavailable")
|
|
}
|
|
|
|
if err := s.ensureCAPIRegistered(ctx); err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rec, err := s.load(ctx)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
if rec.Status == consoleStatusEnrolling {
|
|
return s.statusFromModel(rec), fmt.Errorf("enrollment already in progress")
|
|
}
|
|
if rec.Status == consoleStatusEnrolled && !req.Force {
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
now := s.nowFn().UTC()
|
|
rec.Status = consoleStatusEnrolling
|
|
rec.AgentName = agent
|
|
rec.Tenant = tenant
|
|
rec.LastAttemptAt = &now
|
|
rec.LastError = ""
|
|
rec.LastCorrelationID = uuid.NewString()
|
|
|
|
encryptedKey, err := s.encrypt(token)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("protect secret: %w", err)
|
|
}
|
|
rec.EncryptedEnrollKey = encryptedKey
|
|
|
|
if err := s.db.WithContext(ctx).Save(rec).Error; err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
|
defer cancel()
|
|
|
|
args := []string{"console", "enroll", "--name", agent}
|
|
if _, err := os.Stat(filepath.Join(s.dataDir, "config.yaml")); err == nil {
|
|
args = append([]string{"-c", filepath.Join(s.dataDir, "config.yaml")}, args...)
|
|
}
|
|
args = append(args, token)
|
|
|
|
logger.Log().WithField("tenant", tenant).WithField("agent", agent).WithField("correlation_id", rec.LastCorrelationID).Info("starting crowdsec console enrollment")
|
|
out, cmdErr := s.exec.ExecuteWithEnv(cmdCtx, "cscli", args, nil)
|
|
|
|
if cmdErr != nil {
|
|
rec.Status = consoleStatusFailed
|
|
rec.LastError = redactSecret(string(out)+": "+cmdErr.Error(), token)
|
|
_ = s.db.WithContext(ctx).Save(rec)
|
|
logger.Log().WithError(cmdErr).WithField("correlation_id", rec.LastCorrelationID).WithField("tenant", tenant).Warn("crowdsec console enrollment failed")
|
|
return s.statusFromModel(rec), fmt.Errorf("console enrollment failed: %s", rec.LastError)
|
|
}
|
|
|
|
complete := s.nowFn().UTC()
|
|
rec.Status = consoleStatusEnrolled
|
|
rec.EnrolledAt = &complete
|
|
rec.LastHeartbeatAt = &complete
|
|
rec.LastError = ""
|
|
if err := s.db.WithContext(ctx).Save(rec).Error; err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
logger.Log().WithField("tenant", tenant).WithField("agent", agent).WithField("correlation_id", rec.LastCorrelationID).Info("crowdsec console enrollment succeeded")
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) ensureCAPIRegistered(ctx context.Context) error {
|
|
credsPath := filepath.Join(s.dataDir, "online_api_credentials.yaml")
|
|
if _, err := os.Stat(credsPath); err == nil {
|
|
return nil
|
|
}
|
|
|
|
logger.Log().Info("registering with crowdsec capi")
|
|
args := []string{"capi", "register"}
|
|
if _, err := os.Stat(filepath.Join(s.dataDir, "config.yaml")); err == nil {
|
|
args = append([]string{"-c", filepath.Join(s.dataDir, "config.yaml")}, args...)
|
|
}
|
|
|
|
if _, err := s.exec.ExecuteWithEnv(ctx, "cscli", args, nil); err != nil {
|
|
return fmt.Errorf("capi register: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) load(ctx context.Context) (*models.CrowdsecConsoleEnrollment, error) {
|
|
var rec models.CrowdsecConsoleEnrollment
|
|
err := s.db.WithContext(ctx).First(&rec).Error
|
|
if err == nil {
|
|
return &rec, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
now := s.nowFn().UTC()
|
|
rec = models.CrowdsecConsoleEnrollment{
|
|
UUID: uuid.NewString(),
|
|
Status: consoleStatusNotEnrolled,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&rec).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &rec, nil
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) statusFromModel(rec *models.CrowdsecConsoleEnrollment) ConsoleEnrollmentStatus {
|
|
if rec == nil {
|
|
return ConsoleEnrollmentStatus{Status: consoleStatusNotEnrolled}
|
|
}
|
|
return ConsoleEnrollmentStatus{
|
|
Status: firstNonEmpty(rec.Status, consoleStatusNotEnrolled),
|
|
Tenant: rec.Tenant,
|
|
AgentName: rec.AgentName,
|
|
LastError: rec.LastError,
|
|
LastAttemptAt: rec.LastAttemptAt,
|
|
EnrolledAt: rec.EnrolledAt,
|
|
LastHeartbeatAt: rec.LastHeartbeatAt,
|
|
KeyPresent: rec.EncryptedEnrollKey != "",
|
|
CorrelationID: rec.LastCorrelationID,
|
|
}
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) encrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
block, err := aes.NewCipher(s.key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
|
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
|
}
|
|
|
|
// decrypt is only used in tests to validate encryption roundtrips.
|
|
func (s *ConsoleEnrollmentService) decrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
ciphertext, err := base64.StdEncoding.DecodeString(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
block, err := aes.NewCipher(s.key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonceSize := gcm.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
return "", fmt.Errorf("ciphertext too short")
|
|
}
|
|
nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|
plain, err := gcm.Open(nil, nonce, ct, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
func deriveKey(secret string) []byte {
|
|
if secret == "" {
|
|
secret = "charon-console-enroll-default"
|
|
}
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return sum[:]
|
|
}
|
|
|
|
func redactSecret(msg, secret string) string {
|
|
if secret == "" {
|
|
return msg
|
|
}
|
|
return strings.ReplaceAll(msg, secret, "<redacted>")
|
|
}
|
|
|
|
func normalizeEnrollmentKey(raw string) (string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("enrollment_key required")
|
|
}
|
|
if enrollmentTokenPattern.MatchString(trimmed) {
|
|
return trimmed, nil
|
|
}
|
|
|
|
parts := strings.Fields(trimmed)
|
|
if len(parts) == 0 {
|
|
return "", fmt.Errorf("invalid enrollment key")
|
|
}
|
|
if strings.EqualFold(parts[0], "sudo") {
|
|
parts = parts[1:]
|
|
}
|
|
|
|
if len(parts) == 4 && parts[0] == "cscli" && parts[1] == "console" && parts[2] == "enroll" {
|
|
token := parts[3]
|
|
if enrollmentTokenPattern.MatchString(token) {
|
|
return token, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("invalid enrollment key")
|
|
}
|