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" "github.com/Wikid82/charon/backend/internal/util" ) 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 checkErr := s.checkLAPIAvailable(ctx); checkErr != nil { return ConsoleEnrollmentStatus{}, checkErr } if ensureErr := s.ensureCAPIRegistered(ctx); ensureErr != nil { return ConsoleEnrollmentStatus{}, ensureErr } 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().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", util.SanitizeForLog(redactedErr)).WithField("correlation_id", rec.LastCorrelationID).WithField("tenant", util.SanitizeForLog(tenant)).WithField("output", util.SanitizeForLog(redactedOut)).Warn("crowdsec console enrollment failed") return s.statusFromModel(rec), fmt.Errorf("%s", userMessage) } logger.Log().WithField("correlation_id", rec.LastCorrelationID).WithField("output", util.SanitizeForLog(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", util.SanitizeForLog(tenant)).WithField("agent", util.SanitizeForLog(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 5 times with exponential backoff (3s, 6s, 12s, 24s) to handle LAPI initialization timing. // Total wait time: ~45 seconds max. func (s *ConsoleEnrollmentService) checkLAPIAvailable(ctx context.Context) error { maxRetries := 5 baseDelay := 3 * 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, 5*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 { // Exponential backoff: 3s, 6s, 12s, 24s delay := baseDelay * time.Duration(1<") } // extractCscliErrorMessage extracts the meaningful error message from cscli output. // CrowdSec outputs error messages in formats like: // - "level=error msg=\"...\"" // - "ERRO[...] ..." // - Plain error text // // It also translates common CrowdSec errors into user-friendly messages. func extractCscliErrorMessage(output string) string { output = strings.TrimSpace(output) if output == "" { return "" } lowerOutput := strings.ToLower(output) // Check for specific error patterns and provide actionable messages errorPatterns := map[string]string{ "token is expired": "Enrollment token has expired. Please generate a new token from crowdsec.net console.", "token is invalid": "Enrollment token is invalid. Please verify the token from crowdsec.net console.", "already enrolled": "Agent is already enrolled. Use force=true to re-enroll.", "lapi is not reachable": "Cannot reach Local API. Ensure CrowdSec is running and LAPI is initialized.", "capi is not reachable": "Cannot reach Central API. Check network connectivity to crowdsec.net.", "connection refused": "CrowdSec Local API refused connection. Ensure CrowdSec is running.", "no such file or directory": "CrowdSec configuration file not found. Run CrowdSec initialization first.", "permission denied": "Permission denied. Ensure the process has access to CrowdSec configuration.", } for pattern, message := range errorPatterns { if strings.Contains(lowerOutput, pattern) { return message } } // 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") }