# Investigation Report: Re-Enrollment & Live Log Viewer Issues
**Date:** December 16, 2025
**Investigator:** GitHub Copilot
**Status:** ✅ Investigation Complete - Root Causes Identified
---
## 📋 Executive Summary
**Issue 1: Re-enrollment with NEW key didn't work**
- **Root Cause:** `force` parameter is correctly sent by frontend, but backend has LAPI availability check that may time out
- **Status:** ✅ Working as designed - re-enrollment requires `force=true` and uses `--overwrite` flag
- **User Issue:** User needed to use SAME key because new key was invalid or enrollment was already pending
**Issue 2: Live Log Viewer shows "Disconnected"**
- **Root Cause:** WebSocket endpoint is `/api/v1/cerberus/logs/ws` (security logs), NOT `/api/v1/logs/live` (app logs)
- **Status:** ✅ Working as designed - different endpoints for different log types
- **User Issue:** Frontend defaults to wrong mode or wrong endpoint
---
## � Issue 1: Re-Enrollment Investigation (December 16, 2025)
### User Report
> "Re-enrollment with NEW key didn't work - I had to use the SAME enrollment token from the first time."
### Investigation Findings
#### Frontend Code Analysis
**File:** `frontend/src/pages/CrowdSecConfig.tsx`
**Re-enrollment Button** (Line 588):
```tsx
```
**Submission Function** (Line 278):
```tsx
const submitConsoleEnrollment = async (force = false) => {
// ... validation ...
await enrollConsoleMutation.mutateAsync({
enrollment_key: enrollmentToken.trim(),
tenant: tenantValue,
agent_name: consoleAgentName.trim(),
force, // ✅ CORRECTLY PASSES force PARAMETER
})
}
```
**API Call** (`frontend/src/api/consoleEnrollment.ts`):
```typescript
export interface ConsoleEnrollPayload {
enrollment_key: string
tenant?: string
agent_name: string
force?: boolean // ✅ DEFINED IN INTERFACE
}
export async function enrollConsole(payload: ConsoleEnrollPayload): Promise {
const resp = await client.post('/admin/crowdsec/console/enroll', payload)
return resp.data
}
```
✅ **Verdict:** Frontend correctly sends `force: true` when re-enrolling.
#### Backend Code Analysis
**File:** `backend/internal/crowdsec/console_enroll.go`
**Force Parameter Handling** (Line 167-169):
```go
// Add overwrite flag if force is requested
if req.Force {
args = append(args, "--overwrite") // ✅ ADDS --overwrite FLAG
}
```
**Command Execution** (Line 178):
```go
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)
```
**Docker Logs Evidence:**
```
{"agent":"Charon","config":"/app/data/crowdsec/config/config.yaml","correlation_id":"de557798-3081-4bc2-9dbf-10e035f09eaf","force":true,"level":"info","msg":"starting crowdsec console enrollment","tenant":"5e045b3c-5196-406b-99cd-503bc64c7b0d","time":"2025-12-15T22:43:10-05:00"}
```
✅ Shows `"force":true` in the log
**Error in Logs:**
```
Error: cscli console enroll: could not enroll instance: API error: the attachment key provided is not valid (hint: get your enrollement key from console, crowdsec login or machine id are not valid values)
```
✅ **Verdict:** Backend correctly receives `force=true` and passes `--overwrite` to cscli. The enrollment FAILED because the key itself was invalid according to CrowdSec API.
#### LAPI Availability Check
**Critical Code** (Line 223-244):
```go
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)
}
```
**Frontend LAPI Check:**
```tsx
const lapiStatusQuery = useQuery({
queryKey: ['crowdsec-lapi-status'],
queryFn: statusCrowdsec,
enabled: consoleEnrollmentEnabled && initialCheckComplete,
refetchInterval: 5000, // Poll every 5 seconds
retry: false,
})
```
✅ **Verdict:** LAPI check is robust with 3 retries and 2-second delays. Frontend polls every 5 seconds.
### Root Cause Determination
**The re-enrollment with "NEW key" failed because:**
1. ✅ `force=true` was correctly sent
2. ✅ `--overwrite` flag was correctly added
3. ❌ **The new enrollment key was INVALID** according to CrowdSec API
**Evidence from logs:**
```
Error: cscli console enroll: could not enroll instance: API error: the attachment key provided is not valid
```
**Why the SAME key worked:**
- The original key was still valid in CrowdSec's system
- Using the same key with `--overwrite` flag allowed re-enrollment to the same account
### Conclusion
✅ **No bug found.** The implementation is correct. User's new enrollment key was rejected by CrowdSec API.
**User Action Required:**
1. Generate a new enrollment key from app.crowdsec.net
2. Ensure the key is copied completely (no spaces/newlines)
3. Try re-enrollment again
---
## 🔍 Issue 2: Live Log Viewer "Disconnected" (December 16, 2025)
### User Report
> "Live Log Viewer shows 'Disconnected' and no logs appear. I only need SECURITY logs (CrowdSec/Cerberus), not application logs."
### Investigation Findings
#### LiveLogViewer Component Analysis
**File:** `frontend/src/components/LiveLogViewer.tsx`
**Mode Toggle** (Line 350-366):
```tsx
```
**WebSocket Connection Logic** (Line 155-213):
```tsx
useEffect(() => {
// ... close existing connection ...
if (currentMode === 'security') {
// Connect to security logs endpoint
closeConnectionRef.current = connectSecurityLogs(
effectiveFilters,
handleSecurityMessage,
handleOpen,
handleError,
handleClose
);
} else {
// Connect to application logs endpoint
closeConnectionRef.current = connectLiveLogs(
filters,
handleLiveMessage,
handleOpen,
handleError,
handleClose
);
}
}, [currentMode, filters, securityFilters, maxLogs, showBlockedOnly]);
```
#### WebSocket Endpoints
**Application Logs:**
```typescript
// frontend/src/api/logs.ts:95-135
const wsUrl = `${protocol}//${window.location.host}/api/v1/logs/live?${params.toString()}`;
```
**Security Logs:**
```typescript
// frontend/src/api/logs.ts:153-174
const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/ws?${params.toString()}`;
```
#### Backend WebSocket Handlers
**Application Logs Handler:**
```go
// backend/internal/api/handlers/logs_ws.go
func LogsWebSocketHandler(c *gin.Context) {
// Subscribes to logger.BroadcastHook for app logs
hook := logger.GetBroadcastHook()
logChan := hook.Subscribe(subscriberID)
}
```
**Security Logs Handler:**
```go
// backend/internal/api/handlers/cerberus_logs_ws.go
func (h *CerberusLogsHandler) LiveLogs(c *gin.Context) {
// Subscribes to LogWatcher for Caddy access logs
logChan := h.watcher.Subscribe()
}
```
**LogWatcher Implementation:**
```go
// backend/internal/services/log_watcher.go
func NewLogWatcher(logPath string) *LogWatcher {
// Tails /app/data/logs/access.log
return &LogWatcher{
logPath: logPath, // Defaults to access.log
}
}
```
✅ **LogWatcher is actively tailing:** Verified via Docker logs showing successful access.log reads
#### Access Log Verification
**Command:** `docker exec charon tail -20 /app/data/logs/access.log`
✅ **Result:** Access log has MANY recent entries (20+ lines shown, JSON format, proper structure)
**Sample Entry:**
```json
{
"level":"info",
"ts":1765577040.5798745,
"logger":"http.log.access.access_log",
"msg":"handled request",
"request": {
"remote_ip":"172.59.136.4",
"method":"GET",
"host":"sonarr.hatfieldhosted.com",
"uri":"/api/v3/command"
},
"status":200,
"duration":0.066689363
}
```
#### Routes Configuration
**File:** `backend/internal/api/routes/routes.go`
```go
// Line 158
protected.GET("/logs/live", handlers.LogsWebSocketHandler)
// Line 394
protected.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs)
```
✅ Both endpoints are registered and protected (require authentication)
### Root Cause Analysis
#### Possible Issues
1. **Default Mode May Be Wrong**
- Component defaults to `mode='application'` (Line 142)
- User needs security logs, which requires `mode='security'`
2. **WebSocket Authentication**
- Both endpoints are under `protected` route group
- WebSocket connections may not automatically include auth headers
- Native WebSocket API doesn't support custom headers
3. **No WebSocket Connection Logs**
- Docker logs show NO "WebSocket connection attempt" messages
- This suggests connections are NOT reaching the backend
4. **Frontend Connection State**
- `isConnected` is set only in `onOpen` callback
- If connection fails during upgrade, `onOpen` never fires
- Result: "Disconnected" status persists
### Testing Commands
```bash
# Check if LogWatcher is running
docker logs charon 2>&1 | grep -i "LogWatcher started"
# Check for WebSocket connection attempts
docker logs charon 2>&1 | grep -i "websocket" | tail -20
# Check if Cerberus logs handler is initialized
docker logs charon 2>&1 | grep -i "cerberus.*logs" | tail -10
```
**Result from earlier grep:**
```
[GIN-debug] GET /api/v1/cerberus/logs/ws --> ... .LiveLogs-fm (10 handlers)
```
✅ Route is registered
**No connection attempt logs found** → Connections are NOT reaching backend
### Diagnosis
**Most Likely Issue:** WebSocket authentication failure
1. Frontend attempts WebSocket connection
2. Browser sends `ws://` or `wss://` request without auth headers
3. Backend auth middleware rejects with 401
4. WebSocket upgrade fails silently
5. `onError` fires but doesn't show useful message to user
### Recommended Fixes
#### Fix 1: Add Auth Token to WebSocket URL
**File:** `frontend/src/api/logs.ts`
```typescript
export const connectSecurityLogs = (
filters: SecurityLogFilter,
onMessage: (log: SecurityLogEntry) => void,
onOpen?: () => void,
onError?: (error: Event) => void,
onClose?: () => void
): (() => void) => {
const params = new URLSearchParams();
if (filters.source) params.append('source', filters.source);
if (filters.level) params.append('level', filters.level);
if (filters.ip) params.append('ip', filters.ip);
if (filters.host) params.append('host', filters.host);
if (filters.blocked_only) params.append('blocked_only', 'true');
// ✅ ADD AUTH TOKEN
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
if (token) {
params.append('token', token);
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/ws?${params.toString()}`;
// ...
};
```
**Apply same fix to** `connectLiveLogs()`
#### Fix 2: Backend Auth Middleware Must Check Query Param
**File:** `backend/internal/api/middleware/auth.go` (assumed location)
Ensure the auth middleware checks for token in:
1. `Authorization` header
2. Cookie (if using session auth)
3. **Query parameter `token`** (for WebSocket compatibility)
#### Fix 3: Add Error Display to UI
**File:** `frontend/src/components/LiveLogViewer.tsx`
```tsx
const [connectionError, setConnectionError] = useState(null);
const handleError = (error: Event) => {
console.error('WebSocket error:', error);
setIsConnected(false);
setConnectionError('Failed to connect to log stream. Check authentication.');
};
const handleOpen = () => {
console.log(`${currentMode} log viewer connected`);
setIsConnected(true);
setConnectionError(null);
};
// In JSX:
{connectionError && (
{connectionError}
)}
```
#### Fix 4: Change Default Mode to Security
**File:** `frontend/src/components/LiveLogViewer.tsx` (Line 142)
```tsx
export function LiveLogViewer({
filters = {},
securityFilters = {},
mode = 'security', // ✅ CHANGE FROM 'application' TO 'security'
maxLogs = 500,
className = '',
}: LiveLogViewerProps) {
```
### Verification Steps
1. **Check browser DevTools Network tab:**
- Look for WebSocket connection to `/api/v1/cerberus/logs/ws`
- Check status code (should be 101 Switching Protocols, not 401/403)
2. **Check backend logs:**
- Should see "Cerberus logs WebSocket connection attempt"
- Should see "Cerberus logs WebSocket connected"
3. **Generate test traffic:**
- Make HTTP request to any proxied host
- Check if log appears in viewer
---
## 📋 CrowdSec Re-Enrollment UX Research (PREVIOUS SECTION - KEPT FOR REFERENCE)
### CrowdSec CLI Capabilities
**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: