diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 84087130..567a3426 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -937,6 +937,29 @@ func (h *CrowdsecHandler) ConsoleStatus(c *gin.Context) { c.JSON(http.StatusOK, status) } +// DeleteConsoleEnrollment clears the local enrollment state to allow fresh enrollment. +// DELETE /api/v1/admin/crowdsec/console/enrollment +// Note: This does NOT unenroll from crowdsec.net - that must be done manually on the console. +func (h *CrowdsecHandler) DeleteConsoleEnrollment(c *gin.Context) { + if !h.isConsoleEnrollmentEnabled() { + c.JSON(http.StatusNotFound, gin.H{"error": "console enrollment disabled"}) + return + } + if h.Console == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "console enrollment service not available"}) + return + } + + ctx := c.Request.Context() + if err := h.Console.ClearEnrollment(ctx); err != nil { + logger.Log().WithError(err).Warn("failed to clear console enrollment state") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "enrollment state cleared"}) +} + // GetCachedPreset returns cached preview for a slug when available. func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) { if !h.isCerberusEnabled() { @@ -1474,6 +1497,7 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/admin/crowdsec/presets/cache/:slug", h.GetCachedPreset) rg.POST("/admin/crowdsec/console/enroll", h.ConsoleEnroll) rg.GET("/admin/crowdsec/console/status", h.ConsoleStatus) + rg.DELETE("/admin/crowdsec/console/enrollment", h.DeleteConsoleEnrollment) // Decision management endpoints (Banned IP Dashboard) rg.GET("/admin/crowdsec/decisions", h.ListDecisions) rg.GET("/admin/crowdsec/decisions/lapi", h.GetLAPIDecisions) diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 829e68d2..92f6c814 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -1009,3 +1009,166 @@ labels: require.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound, "expected 200 or 404, got %d", w.Code) } + +// ============================================ +// DeleteConsoleEnrollment Tests +// ============================================ + +func TestDeleteConsoleEnrollmentDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + // Feature flag not set, should return 404 + + h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Body.String(), "disabled") +} + +func TestDeleteConsoleEnrollmentServiceUnavailable(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true") + + // Create handler with nil Console service + db := OpenTestDB(t) + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &RealCommandExecutor{}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + Console: nil, // Explicitly nil + } + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Contains(t, w.Body.String(), "not available") +} + +func TestDeleteConsoleEnrollmentSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true") + + h, _ := setupTestConsoleEnrollment(t) + + // First create an enrollment record + rec := &models.CrowdsecConsoleEnrollment{ + UUID: "test-uuid", + Status: "enrolled", + AgentName: "test-agent", + Tenant: "test-tenant", + } + require.NoError(t, h.DB.Create(rec).Error) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + // Delete the enrollment + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "cleared") + + // Verify the record is gone + var count int64 + h.DB.Model(&models.CrowdsecConsoleEnrollment{}).Count(&count) + require.Equal(t, int64(0), count) +} + +func TestDeleteConsoleEnrollmentNoRecordSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true") + + h, _ := setupTestConsoleEnrollment(t) + + // Don't create any record - deletion should still succeed + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "cleared") +} + +func TestDeleteConsoleEnrollmentThenReenroll(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true") + + h, _ := setupTestConsoleEnrollment(t) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + // First enroll + body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent-1"}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // Check status shows pending_acceptance + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody) + r.ServeHTTP(w2, req2) + require.Equal(t, http.StatusOK, w2.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp)) + require.Equal(t, "pending_acceptance", resp["status"]) + require.Equal(t, "test-agent-1", resp["agent_name"]) + + // Delete enrollment + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody) + r.ServeHTTP(w3, req3) + require.Equal(t, http.StatusOK, w3.Code) + + // Check status shows not_enrolled + w4 := httptest.NewRecorder() + req4 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody) + r.ServeHTTP(w4, req4) + require.Equal(t, http.StatusOK, w4.Code) + var resp2 map[string]interface{} + require.NoError(t, json.Unmarshal(w4.Body.Bytes(), &resp2)) + require.Equal(t, "not_enrolled", resp2["status"]) + + // Re-enroll with NEW agent name - should work WITHOUT force + body2 := `{"enrollment_key": "newkey123456", "agent_name": "test-agent-2"}` + w5 := httptest.NewRecorder() + req5 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body2)) + req5.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w5, req5) + require.Equal(t, http.StatusOK, w5.Code) + + // Check status shows new agent name + w6 := httptest.NewRecorder() + req6 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody) + r.ServeHTTP(w6, req6) + require.Equal(t, http.StatusOK, w6.Code) + var resp3 map[string]interface{} + require.NoError(t, json.Unmarshal(w6.Body.Bytes(), &resp3)) + require.Equal(t, "pending_acceptance", resp3["status"]) + require.Equal(t, "test-agent-2", resp3["agent_name"]) +} diff --git a/backend/internal/crowdsec/console_enroll.go b/backend/internal/crowdsec/console_enroll.go index 2ed99ee0..cd77db51 100644 --- a/backend/internal/crowdsec/console_enroll.go +++ b/backend/internal/crowdsec/console_enroll.go @@ -160,6 +160,11 @@ func (s *ConsoleEnrollmentService) Enroll(ctx context.Context, req ConsoleEnroll } // 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 } @@ -339,6 +344,31 @@ func (s *ConsoleEnrollmentService) load(ctx context.Context) (*models.CrowdsecCo 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} diff --git a/backend/internal/crowdsec/console_enroll_test.go b/backend/internal/crowdsec/console_enroll_test.go index ec4305e9..1edbb719 100644 --- a/backend/internal/crowdsec/console_enroll_test.go +++ b/backend/internal/crowdsec/console_enroll_test.go @@ -1,12 +1,15 @@ package crowdsec import ( + "bytes" "context" "fmt" "strings" "testing" "time" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -772,3 +775,203 @@ func TestEnroll_RequiresLAPI(t *testing.T) { require.Contains(t, exec.calls[0].args, "lapi") require.Contains(t, exec.calls[0].args, "status") } + +// ============================================ +// ClearEnrollment Tests +// ============================================ + +func TestConsoleEnrollService_ClearEnrollment(t *testing.T) { + db := openConsoleTestDB(t) + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Create an enrollment record + rec := &models.CrowdsecConsoleEnrollment{ + UUID: "test-uuid", + Status: "enrolled", + AgentName: "test-agent", + Tenant: "test-tenant", + } + require.NoError(t, db.Create(rec).Error) + + // Verify record exists + var countBefore int64 + db.Model(&models.CrowdsecConsoleEnrollment{}).Count(&countBefore) + require.Equal(t, int64(1), countBefore) + + // Clear it + err := svc.ClearEnrollment(ctx) + require.NoError(t, err) + + // Verify it's gone + var countAfter int64 + db.Model(&models.CrowdsecConsoleEnrollment{}).Count(&countAfter) + assert.Equal(t, int64(0), countAfter) +} + +func TestConsoleEnrollService_ClearEnrollment_NoRecord(t *testing.T) { + db := openConsoleTestDB(t) + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Should not error when no record exists + err := svc.ClearEnrollment(ctx) + require.NoError(t, err) +} + +func TestConsoleEnrollService_ClearEnrollment_NilDB(t *testing.T) { + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(nil, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Should error when DB is nil + err := svc.ClearEnrollment(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "database not initialized") +} + +func TestConsoleEnrollService_ClearEnrollment_ThenReenroll(t *testing.T) { + db := openConsoleTestDB(t) + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // First enrollment + _, err := svc.Enroll(ctx, ConsoleEnrollRequest{ + EnrollmentKey: "abc123def4g", + AgentName: "agent-one", + }) + require.NoError(t, err) + + // Verify enrolled + status, err := svc.Status(ctx) + require.NoError(t, err) + require.Equal(t, consoleStatusPendingAcceptance, status.Status) + + // Clear enrollment + err = svc.ClearEnrollment(ctx) + require.NoError(t, err) + + // Verify status is now not_enrolled (new record will be created on next Status call) + status, err = svc.Status(ctx) + require.NoError(t, err) + require.Equal(t, consoleStatusNotEnrolled, status.Status) + + // Re-enroll with new key should work without force + _, err = svc.Enroll(ctx, ConsoleEnrollRequest{ + EnrollmentKey: "newkey12345", + AgentName: "agent-two", + Force: false, // Force NOT required after clear + }) + require.NoError(t, err) + + // Verify new enrollment + status, err = svc.Status(ctx) + require.NoError(t, err) + require.Equal(t, consoleStatusPendingAcceptance, status.Status) + require.Equal(t, "agent-two", status.AgentName) +} + +// ============================================ +// Logging When Skipped Tests +// ============================================ + +func TestConsoleEnrollService_LogsWhenSkipped(t *testing.T) { + db := openConsoleTestDB(t) + + // Use a test logger that captures output + logger := logrus.New() + var logBuf bytes.Buffer + logger.SetOutput(&logBuf) + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) + + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Create an existing enrollment + rec := &models.CrowdsecConsoleEnrollment{ + UUID: "test-uuid", + Status: "enrolled", + AgentName: "test-agent", + Tenant: "test-tenant", + } + require.NoError(t, db.Create(rec).Error) + + // Try to enroll without force - this should be skipped + status, err := svc.Enroll(ctx, ConsoleEnrollRequest{ + EnrollmentKey: "newkey12345", + AgentName: "new-agent", + Force: false, + }) + require.NoError(t, err) + + // Enrollment should be skipped - status remains enrolled + require.Equal(t, "enrolled", status.Status) + + // The actual logging is done via the logger package, which uses a global logger. + // We can't easily capture that here without modifying the package. + // Instead, we verify the behavior is correct by checking exec.callCount() + // - if skipped properly, we should see lapi + capi calls but NO enroll call + require.Equal(t, 2, exec.callCount(), "should only call lapi status and capi register, not enroll") +} + +func TestConsoleEnrollService_LogsWhenSkipped_PendingAcceptance(t *testing.T) { + db := openConsoleTestDB(t) + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Create an existing enrollment with pending_acceptance status + rec := &models.CrowdsecConsoleEnrollment{ + UUID: "test-uuid", + Status: consoleStatusPendingAcceptance, + AgentName: "test-agent", + Tenant: "test-tenant", + } + require.NoError(t, db.Create(rec).Error) + + // Try to enroll without force - this should also be skipped + status, err := svc.Enroll(ctx, ConsoleEnrollRequest{ + EnrollmentKey: "newkey12345", + AgentName: "new-agent", + Force: false, + }) + require.NoError(t, err) + + // Enrollment should be skipped - status remains pending_acceptance + require.Equal(t, consoleStatusPendingAcceptance, status.Status) + require.Equal(t, 2, exec.callCount(), "should only call lapi status and capi register, not enroll") +} + +func TestConsoleEnrollService_ForceOverridesSkip(t *testing.T) { + db := openConsoleTestDB(t) + exec := &stubEnvExecutor{} + svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "test-secret") + ctx := context.Background() + + // Create an existing enrollment + rec := &models.CrowdsecConsoleEnrollment{ + UUID: "test-uuid", + Status: "enrolled", + AgentName: "test-agent", + Tenant: "test-tenant", + } + require.NoError(t, db.Create(rec).Error) + + // Try to enroll WITH force - this should NOT be skipped + status, err := svc.Enroll(ctx, ConsoleEnrollRequest{ + EnrollmentKey: "newkey12345", + AgentName: "new-agent", + Force: true, + }) + require.NoError(t, err) + + // Force enrollment should proceed - status becomes pending_acceptance + require.Equal(t, consoleStatusPendingAcceptance, status.Status) + require.Equal(t, "new-agent", status.AgentName) + require.Equal(t, 3, exec.callCount(), "should call lapi status, capi register, AND enroll") +} diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 3d3390ce..f9cce946 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,21 +1,601 @@ # Investigation Report: CrowdSec Enrollment & Live Log Viewer Issues -**Date:** December 15, 2025 +**Date:** December 15, 2025 (Updated: December 16, 2025) **Investigator:** GitHub Copilot -**Status:** ✅ Issue A RESOLVED - Issue B Analysis Pending +**Status:** ✅ Analysis Complete - Re-Enrollment UX Options Evaluated --- -## Executive Summary (Updated December 16, 2025) +## 📋 CrowdSec Re-Enrollment UX Research (December 16, 2025) -This document covers TWO issues: +### CrowdSec CLI Capabilities -1. **CrowdSec Enrollment** ✅ **FIXED**: Shows success locally but engine doesn't appear in CrowdSec.net dashboard - - **Root Cause**: Code incorrectly set status to `enrolled` after `cscli console enroll` succeeded, but CrowdSec's help explicitly states users must "validate the enrollment in the webapp" - - **Fix Applied**: Changed status to `pending_acceptance` and updated frontend to inform users they must accept on app.crowdsec.net +**Available Console Commands (`cscli console --help`):** + +```text +Available Commands: + disable Disable a console option + enable Enable a console option + enroll Enroll this instance to https://app.crowdsec.net + status Shows status of the console options +``` + +**Enroll Command Flags (`cscli console enroll --help`):** + +```text +Flags: + -d, --disable strings Disable console options + -e, --enable strings Enable console options + -h, --help help for enroll + -n, --name string Name to display in the console + --overwrite Force enroll the instance ← KEY FLAG FOR RE-ENROLLMENT + -t, --tags strings Tags to display in the console +``` + +**Key Finding: NO "unenroll" or "disconnect" command exists in CrowdSec CLI.** + +The `disable --all` command only disables data sharing options (custom, tainted, manual, context, console_management) - it does NOT unenroll from the console. + +### Current Data Model Analysis + +**Model: `CrowdsecConsoleEnrollment`** ([crowdsec_console_enrollment.go](../../backend/internal/models/crowdsec_console_enrollment.go)): + +```go +type CrowdsecConsoleEnrollment struct { + ID uint // Primary key + UUID string // Unique identifier + Status string // not_enrolled, enrolling, pending_acceptance, enrolled, failed + Tenant string // Organization identifier + AgentName string // Display name in console + EncryptedEnrollKey string // ← KEY IS STORED (encrypted with AES-GCM) + LastError string // Error message if failed + LastCorrelationID string // For debugging + LastAttemptAt *time.Time + EnrolledAt *time.Time + LastHeartbeatAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +**✅ Current Implementation Already Stores Enrollment Key:** + +- The key is encrypted using AES-256-GCM with a key derived from a secret +- Stored in `EncryptedEnrollKey` field (excluded from JSON via `json:"-"`) +- Encryption implemented in `console_enroll.go` lines 377-409 + +### Enrollment Key Lifecycle (from crowdsec.net) + +1. **Generation**: User generates enrollment key on app.crowdsec.net +2. **Usage**: Key is used with `cscli console enroll ` to request enrollment +3. **Validation**: CrowdSec validates the key against their API +4. **Acceptance**: User must accept enrollment request on app.crowdsec.net +5. **Reusability**: The SAME key can be used multiple times with `--overwrite` flag +6. **Expiration**: Keys do not expire but may be revoked by user on console + +### UX Options Evaluation + +#### Option A: "Re-enroll" Button Requiring NEW Key ✅ RECOMMENDED + +**How it works:** + +- User provides a new enrollment key from crowdsec.net +- Backend sends `cscli console enroll --overwrite --name ` +- User accepts on crowdsec.net + +**Pros:** + +- ✅ Simple implementation (already supported via `force: true`) +- ✅ Secure - no key storage concerns beyond current encrypted storage +- ✅ Fresh key guarantees user has console access +- ✅ Matches CrowdSec's intended workflow + +**Cons:** + +- ⚠️ Requires user to visit crowdsec.net to get new key +- ⚠️ Extra step for user + +**Current UI Support:** + +- "Rotate key" button already calls `submitConsoleEnrollment(true)` with `force=true` +- "Retry enrollment" button appears when status is `degraded` + +#### Option B: "Re-enroll" with STORED Key + +**How it works:** + +- Use the encrypted key already stored in `EncryptedEnrollKey` +- Decrypt and re-send enrollment request + +**Pros:** + +- ✅ Simplest UX - one-click re-enrollment +- ✅ Key is already stored and encrypted + +**Cons:** + +- ⚠️ Security concern: Re-using stored keys increases exposure window +- ⚠️ Key may have been revoked on crowdsec.net without Charon knowing +- ⚠️ Old key may belong to different CrowdSec account +- ⚠️ Violates principle of least privilege + +**Current Implementation Gap:** + +- `decrypt()` method exists but is marked as "only used in tests" +- Would need new endpoint to retrieve stored key for re-enrollment + +#### Option C: "Unenroll" + Manual Re-enroll ❌ NOT SUPPORTED + +**How it would work:** + +- Clear local enrollment state +- User goes through fresh enrollment + +**Blockers:** + +- ❌ CrowdSec CLI has NO unenroll/disconnect command +- ❌ Would require manual deletion of config files +- ❌ May leave orphaned engine on crowdsec.net console + +**Files that would need cleanup:** + +```text +/app/data/crowdsec/config/console.yaml # Console options +/app/data/crowdsec/config/online_api_credentials.yaml # CAPI credentials +``` + +Note: Deleting these files would also affect CAPI registration, not just console enrollment. + +### 🎯 Recommended Approach: Option A (Enhanced) + +**Justification:** + +1. **Security First**: CrowdSec enrollment keys should be treated as sensitive credentials +2. **User Intent**: Re-enrollment implies user wants fresh connection to console +3. **Minimal Risk**: User must actively obtain new key, preventing accidental re-enrollments +4. **CrowdSec Best Practice**: The `--overwrite` flag is CrowdSec's designed mechanism for this + +**UI Flow Enhancement:** + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ Console Enrollment [?] Help │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Status: ● Enrolled │ +│ Agent: Charon-Home │ +│ Tenant: my-organization │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Need to re-enroll? │ │ +│ │ │ │ +│ │ To connect to a different CrowdSec console account or │ │ +│ │ reset your enrollment, you'll need a new enrollment key │ │ +│ │ from app.crowdsec.net. │ │ +│ │ │ │ +│ │ [Get new key ↗] [Re-enroll with new key] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ New Enrollment Key: [________________________] │ │ +│ │ Agent Name: [Charon-Home_____________] │ │ +│ │ Tenant: [my-organization_________] │ │ +│ │ │ │ +│ │ [Re-enroll] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Implementation Steps + +#### Step 1: Update Frontend UI (Priority: HIGH) + +**File:** `frontend/src/pages/CrowdSecConfig.tsx` + +Changes: + +1. Add "Re-enroll" section visible when `status === 'enrolled'` +2. Add expandable/collapsible panel for re-enrollment +3. Add link to app.crowdsec.net/enrollment-keys +4. Rename "Rotate key" button to "Re-enroll" for clarity +5. Add explanatory text about why re-enrollment requires new key + +#### Step 2: Improve Backend Logging (Priority: MEDIUM) + +**File:** `backend/internal/crowdsec/console_enroll.go` + +Changes: + +1. Add logging when enrollment is skipped due to existing status +2. Return `skipped: true` field in response when idempotency check triggers +3. Consider adding `reason` field to explain why enrollment was skipped + +#### Step 3: Add "Clear Enrollment" Admin Function (Priority: LOW) + +**File:** `backend/internal/api/handlers/crowdsec_handler.go` + +New endpoint: `DELETE /api/v1/admin/crowdsec/console/enrollment` + +Purpose: Reset local enrollment state to `not_enrolled` without touching CrowdSec config files. + +Note: This does NOT unenroll from crowdsec.net - that must be done manually on the console. + +#### Step 4: Documentation Update (Priority: MEDIUM) + +**File:** `docs/cerberus.md` + +Add section explaining: + +- Why re-enrollment requires new key +- How to get new enrollment key from crowdsec.net +- What happens to old engine on crowdsec.net (must be manually removed) +- Troubleshooting common enrollment issues + +--- + +## Executive Summary + +This document covers THREE issues: + +1. **CrowdSec Enrollment Backend** 🔴 **CRITICAL BUG FOUND**: Backend returns 200 OK but `cscli` is NEVER executed + - **Root Cause**: Silent idempotency check returns success without running enrollment command + - **Evidence**: POST returns 200 OK with 137ms latency, but NO `cscli` logs appear + - **Fix Required**: Add logging for skipped enrollments and clear guidance to use `force=true` 2. **Live Log Viewer**: Shows "Disconnected" status (Analysis pending implementation) +3. **Stale Database State**: Old `enrolled` status from pre-fix deployment blocks new enrollments + - **Symptoms**: User clicks Enroll, sees 200 OK, but nothing happens on crowdsec.net + - **Root Cause**: Database has `status=enrolled` from before the `pending_acceptance` fix was deployed + +--- + +## 🔴 CRITICAL BUG: Silent Idempotency Check (December 16, 2025) + +### Problem Statement + +User submits enrollment form, backend returns 200 OK (confirmed in Docker logs), but the enrollment NEVER appears on crowdsec.net. No `cscli` command execution visible in logs. + +### Docker Log Evidence + +``` +POST /api/v1/admin/crowdsec/console/enroll → 200 OK (137ms latency) +NO "starting crowdsec console enrollment" log ← cscli NEVER executed +NO cscli output logs +``` + +### Code Path Analysis + +**File:** [backend/internal/crowdsec/console_enroll.go](backend/internal/crowdsec/console_enroll.go) + +#### Step 1: Handler calls service (line 865-920) + +```go +// crowdsec_handler.go:888-895 +status, err := h.Console.Enroll(ctx, crowdsec.ConsoleEnrollRequest{ + EnrollmentKey: payload.EnrollmentKey, + Tenant: payload.Tenant, + AgentName: payload.AgentName, + Force: payload.Force, // <-- User did NOT check Force checkbox +}) +``` + +#### Step 2: Idempotency Check (lines 155-165) ⚠️ BUG HERE + +```go +// console_enroll.go:155-165 +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 { + return s.statusFromModel(rec), nil // <-- RETURNS SUCCESS WITHOUT LOGGING OR RUNNING CSCLI! +} +``` + +#### Step 3: Database State (confirmed via container inspection) + +``` +uuid: fb129bb5-d223-4c66-941c-a30e2e2b3040 +status: enrolled ← SET BY OLD CODE BEFORE pending_acceptance FIX +tenant: 5e045b3c-5196-406b-99cd-503bc64c7b0d +agent_name: Charon +``` + +### Root Cause + +1. **Historical State**: User enrolled BEFORE the `pending_acceptance` fix was deployed +2. **Old Code Bug**: Previous code set `status = enrolled` immediately after cscli returned exit 0 +3. **Silent Skip**: Current code silently skips enrollment when `status` is `enrolled` (or `pending_acceptance`) +4. **No User Feedback**: Returns 200 OK without logging or informing user enrollment was skipped + +### Manual Test Results from Container + +```bash +# cscli is available and working +docker exec charon cscli console enroll --help +# ✅ Shows help + +# LAPI is running +docker exec charon cscli lapi status +# ✅ "You can successfully interact with Local API (LAPI)" + +# Console status +docker exec charon cscli console status +# ✅ Shows options table (custom=true, tainted=true) + +# Manual enrollment with invalid key shows proper error +docker exec charon cscli console enroll --name test TESTINVALIDKEY123 +# ✅ Error: "the attachment key provided is not valid" + +# Config path exists and is correct +docker exec charon ls /app/data/crowdsec/config/config.yaml +# ✅ File exists +``` + +### Required Fixes + +#### Fix 1: Add Logging for Skipped Enrollments + +**File:** `backend/internal/crowdsec/console_enroll.go` lines 162-165 + +**Current:** +```go +if (rec.Status == consoleStatusEnrolled || rec.Status == consoleStatusPendingAcceptance) && !req.Force { + return s.statusFromModel(rec), nil +} +``` + +**Fixed:** +```go +if (rec.Status == consoleStatusEnrolled || rec.Status == consoleStatusPendingAcceptance) && !req.Force { + logger.Log().WithField("status", rec.Status).WithField("agent", rec.AgentName).WithField("tenant", rec.Tenant).Info("enrollment skipped: already enrolled or pending - use force=true to re-enroll") + return s.statusFromModel(rec), nil +} +``` + +#### Fix 2: Add "Skipped" Indicator to Response + +Add a field to indicate enrollment was skipped vs actually submitted: + +```go +type ConsoleEnrollmentStatus struct { + Status string `json:"status"` + Skipped bool `json:"skipped,omitempty"` // <-- NEW + // ... other fields +} +``` + +And in the idempotency return: +```go +status := s.statusFromModel(rec) +status.Skipped = true +return status, nil +``` + +#### Fix 3: Frontend Should Show "Already Enrolled" State + +**File:** `frontend/src/pages/CrowdSecConfig.tsx` + +When `consoleStatusQuery.data?.status === 'enrolled'` or `'pending_acceptance'`: +- Show "You are already enrolled" message +- Show "Force Re-Enrollment" button with checkbox +- Explain that acceptance on crowdsec.net may be required + +#### Fix 4: Migrate Stale "enrolled" Status to "pending_acceptance" + +Either: +1. Add a database migration to change all `enrolled` to `pending_acceptance` +2. Or have users click "Force Re-Enroll" once + +### Workaround for User + +Until fix is deployed, user can re-enroll using the Force option: + +1. In the UI: Check "Force re-enrollment" checkbox before clicking Enroll +2. Or via curl: +```bash +curl -X POST http://localhost:8080/api/v1/admin/crowdsec/console/enroll \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"enrollment_key":"", "agent_name":"Charon", "force":true}' +``` + +--- + +## Previous Frontend Analysis (Still Valid for Reference) + +### Enrollment Flow Path + +``` +User clicks "Enroll" button + ↓ +CrowdSecConfig.tsx: + + ) : ( +
+ {/* Re-enrollment form */} +
+
+ + setEnrollmentToken(e.target.value)} + placeholder="Paste your new enrollment key" + data-testid="reenroll-token-input" + /> +
+
+ + setConsoleAgentName(e.target.value)} + placeholder="e.g., Charon-Home" + /> +
+
+ + setConsoleTenant(e.target.value)} + placeholder="Your organization name" + /> +
+
+ +
+ + +
+
+ )} + + {/* Clear enrollment option */} +
+ +
+ + + )} +

Agent