Files
Charon/backend/internal/crowdsec/console_enroll.go
GitHub Actions 45102ae312 feat: Add CrowdSec console re-enrollment support
- Add logging when enrollment is silently skipped due to existing state
- Add DELETE /admin/crowdsec/console/enrollment endpoint to clear state
- Add re-enrollment UI section with guidance and crowdsec.net link
- Add useClearConsoleEnrollment hook for state clearing

Fixes silent idempotency bug where backend returned 200 OK without
actually executing cscli when status was already enrolled.
2025-12-16 03:39:08 +00:00

522 lines
16 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"
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]interface{}{
"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
}
// 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>")
}
// 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")
}