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, "") } // 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") }