chore: clean .gitignore cache
This commit is contained in:
@@ -1,492 +0,0 @@
|
||||
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"
|
||||
consoleStatusPendingAcceptance = "pending_acceptance"
|
||||
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")
|
||||
}
|
||||
|
||||
// CRITICAL: Check that LAPI is running before attempting enrollment
|
||||
// Console enrollment requires an active LAPI connection to register with crowdsec.net
|
||||
if err := s.checkLAPIAvailable(ctx); err != nil {
|
||||
return ConsoleEnrollmentStatus{}, err
|
||||
}
|
||||
|
||||
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 already enrolled or pending acceptance, skip unless Force is set
|
||||
if (rec.Status == consoleStatusEnrolled || rec.Status == consoleStatusPendingAcceptance) && !req.Force {
|
||||
logger.Log().WithFields(map[string]any{
|
||||
"status": rec.Status,
|
||||
"agent_name": rec.AgentName,
|
||||
"tenant": rec.Tenant,
|
||||
}).Info("console enrollment skipped: already enrolled or pending acceptance - use force=true to re-enroll")
|
||||
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}
|
||||
|
||||
// Add tenant as a tag if provided
|
||||
if tenant != "" {
|
||||
args = append(args, "--tags", fmt.Sprintf("tenant:%s", tenant))
|
||||
}
|
||||
|
||||
// Add overwrite flag if force is requested
|
||||
if req.Force {
|
||||
args = append(args, "--overwrite")
|
||||
}
|
||||
|
||||
// Add config path
|
||||
configPath := s.findConfigPath()
|
||||
if configPath != "" {
|
||||
args = append([]string{"-c", configPath}, args...)
|
||||
}
|
||||
|
||||
// Token is the last positional argument
|
||||
args = append(args, token)
|
||||
|
||||
logger.Log().WithField("tenant", tenant).WithField("agent", agent).WithField("force", req.Force).WithField("correlation_id", rec.LastCorrelationID).WithField("config", configPath).Info("starting crowdsec console enrollment")
|
||||
out, cmdErr := s.exec.ExecuteWithEnv(cmdCtx, "cscli", args, nil)
|
||||
|
||||
// Log command output for debugging (redacting the token)
|
||||
redactedOut := redactSecret(string(out), token)
|
||||
if cmdErr != nil {
|
||||
rec.Status = consoleStatusFailed
|
||||
// Redact token from both output and error message
|
||||
redactedErr := redactSecret(cmdErr.Error(), token)
|
||||
// Extract the meaningful error message from cscli output
|
||||
userMessage := extractCscliErrorMessage(redactedOut)
|
||||
if userMessage == "" {
|
||||
userMessage = redactedOut
|
||||
}
|
||||
rec.LastError = userMessage
|
||||
_ = s.db.WithContext(ctx).Save(rec)
|
||||
logger.Log().WithField("error", redactedErr).WithField("correlation_id", rec.LastCorrelationID).WithField("tenant", tenant).WithField("output", redactedOut).Warn("crowdsec console enrollment failed")
|
||||
return s.statusFromModel(rec), fmt.Errorf("%s", userMessage)
|
||||
}
|
||||
|
||||
logger.Log().WithField("correlation_id", rec.LastCorrelationID).WithField("output", redactedOut).Debug("cscli console enroll command output")
|
||||
|
||||
// Enrollment request was sent successfully, but user must still accept it on crowdsec.net.
|
||||
// cscli console enroll returns exit code 0 when the request is sent, NOT when enrollment is complete.
|
||||
// The CrowdSec help explicitly states: "After running this command your will need to validate the enrollment in the webapp."
|
||||
complete := s.nowFn().UTC()
|
||||
rec.Status = consoleStatusPendingAcceptance
|
||||
rec.LastAttemptAt = &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 request sent - pending acceptance on crowdsec.net")
|
||||
return s.statusFromModel(rec), nil
|
||||
}
|
||||
|
||||
// checkLAPIAvailable verifies that CrowdSec Local API is running and reachable.
|
||||
// This is critical for console enrollment as the enrollment process requires LAPI.
|
||||
// It retries up to 3 times with 2-second delays to handle LAPI initialization timing.
|
||||
func (s *ConsoleEnrollmentService) checkLAPIAvailable(ctx context.Context) error {
|
||||
maxRetries := 3
|
||||
retryDelay := 2 * time.Second
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
args := []string{"lapi", "status"}
|
||||
configPath := s.findConfigPath()
|
||||
if configPath != "" {
|
||||
args = append([]string{"-c", configPath}, args...)
|
||||
}
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
out, err := s.exec.ExecuteWithEnv(checkCtx, "cscli", args, nil)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
logger.Log().WithField("config", configPath).Debug("LAPI check succeeded")
|
||||
return nil // LAPI is available
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if i < maxRetries-1 {
|
||||
logger.Log().WithError(err).WithField("attempt", i+1).WithField("output", string(out)).Debug("LAPI not ready, retrying")
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("CrowdSec Local API is not running after %d attempts - please wait for LAPI to initialize (typically 5-10 seconds after enabling CrowdSec): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func (s *ConsoleEnrollmentService) ensureCAPIRegistered(ctx context.Context) error {
|
||||
// Check for credentials in config subdirectory first (standard layout),
|
||||
// then fall back to dataDir root for backward compatibility
|
||||
credsPath := filepath.Join(s.dataDir, "config", "online_api_credentials.yaml")
|
||||
if _, err := os.Stat(credsPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
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"}
|
||||
configPath := s.findConfigPath()
|
||||
if configPath != "" {
|
||||
args = append([]string{"-c", configPath}, args...)
|
||||
}
|
||||
|
||||
out, err := s.exec.ExecuteWithEnv(ctx, "cscli", args, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capi register: %s: %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findConfigPath returns the path to the CrowdSec config file, checking
|
||||
// config subdirectory first (standard layout), then dataDir root.
|
||||
// Returns empty string if no config file is found.
|
||||
func (s *ConsoleEnrollmentService) findConfigPath() string {
|
||||
configPath := filepath.Join(s.dataDir, "config", "config.yaml")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
configPath = filepath.Join(s.dataDir, "config.yaml")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ClearEnrollment resets the enrollment state to allow fresh enrollment.
|
||||
// This does NOT unenroll from crowdsec.net - that must be done manually on the console.
|
||||
func (s *ConsoleEnrollmentService) ClearEnrollment(ctx context.Context) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
var rec models.CrowdsecConsoleEnrollment
|
||||
if err := s.db.WithContext(ctx).First(&rec).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil // Already cleared
|
||||
}
|
||||
return fmt.Errorf("failed to find enrollment record: %w", err)
|
||||
}
|
||||
|
||||
logger.Log().WithField("previous_status", rec.Status).Info("clearing console enrollment state")
|
||||
|
||||
// Delete the record
|
||||
if err := s.db.WithContext(ctx).Delete(&rec).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete enrollment record: %w", err)
|
||||
}
|
||||
|
||||
return 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
|
||||
}
|
||||
|
||||
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>")
|
||||
}
|
||||
|
||||
// extractCscliErrorMessage extracts the meaningful error message from cscli output.
|
||||
// CrowdSec outputs error messages in formats like:
|
||||
// - "level=error msg=\"...\""
|
||||
// - "ERRO[...] ..."
|
||||
// - Plain error text
|
||||
func extractCscliErrorMessage(output string) string {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to extract from level=error msg="..." format
|
||||
msgPattern := regexp.MustCompile(`msg="([^"]+)"`)
|
||||
if matches := msgPattern.FindStringSubmatch(output); len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
// Try to extract from ERRO[...] format - get text after the timestamp bracket
|
||||
erroPattern := regexp.MustCompile(`ERRO\[[^\]]*\]\s*(.+)`)
|
||||
if matches := erroPattern.FindStringSubmatch(output); len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
|
||||
// Try to find any line containing "error" or "failed" (case-insensitive)
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "invalid") {
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
|
||||
// If no pattern matched, return the first non-empty line (often the most relevant)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user