- Add logging when console enrollment is silently skipped - Add DELETE /admin/crowdsec/console/enrollment endpoint - Add enhanced re-enrollment UI with CrowdSec Console link - Fix WebSocket authentication by passing token in query params - Change Live Log Viewer default mode to security logs - Add error message display for failed WebSocket connections Fixes silent enrollment idempotency bug and WebSocket authentication issue causing disconnected log viewer.
1738 lines
60 KiB
Markdown
1738 lines
60 KiB
Markdown
# 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
|
||
|
||
---
|
||
|
||
## <20> 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
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => submitConsoleEnrollment(true)} // ✅ PASSES force=true
|
||
disabled={isConsolePending || !canRotateKey || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)}
|
||
isLoading={enrollConsoleMutation.isPending}
|
||
data-testid="console-rotate-btn"
|
||
>
|
||
Rotate key
|
||
</Button>
|
||
```
|
||
|
||
**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<ConsoleEnrollmentStatus> {
|
||
const resp = await client.post<ConsoleEnrollmentStatus>('/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<CrowdSecStatus>({
|
||
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
|
||
<div className="flex bg-gray-800 rounded-md p-0.5">
|
||
<button
|
||
onClick={() => handleModeChange('application')}
|
||
className={currentMode === 'application' ? 'bg-blue-600 text-white' : 'text-gray-400'}
|
||
>
|
||
<Globe className="w-4 h-4" />
|
||
<span>App</span>
|
||
</button>
|
||
<button
|
||
onClick={() => handleModeChange('security')}
|
||
className={currentMode === 'security' ? 'bg-blue-600 text-white' : 'text-gray-400'}
|
||
>
|
||
<Shield className="w-4 h-4" />
|
||
<span>Security</span>
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
**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<string | null>(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 && (
|
||
<div className="text-red-400 text-xs p-2 border-t border-gray-700">
|
||
{connectionError}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
#### 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 <key>` 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 <agent> <new_key>`
|
||
- 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 <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"enrollment_key":"<key>", "agent_name":"Charon", "force":true}'
|
||
```
|
||
|
||
---
|
||
|
||
## Previous Frontend Analysis (Still Valid for Reference)
|
||
|
||
### Enrollment Flow Path
|
||
|
||
```
|
||
User clicks "Enroll" button
|
||
↓
|
||
CrowdSecConfig.tsx: <Button onClick={() => submitConsoleEnrollment(false)} ...>
|
||
↓
|
||
submitConsoleEnrollment() function (line 269-299)
|
||
↓
|
||
validateConsoleEnrollment() check (line 254-267)
|
||
↓
|
||
enrollConsoleMutation.mutateAsync(payload)
|
||
↓
|
||
useConsoleEnrollment.ts: enrollConsole(payload)
|
||
↓
|
||
consoleEnrollment.ts: client.post('/admin/crowdsec/console/enroll', payload)
|
||
```
|
||
|
||
### Conditions That Block the Enrollment Request
|
||
|
||
#### 1. **Feature Flag Disabled** (POSSIBLE BLOCKER)
|
||
|
||
**File:** [CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L44-L45)
|
||
|
||
```typescript
|
||
const { data: featureFlags } = useQuery({ queryKey: ['feature-flags'], queryFn: getFeatureFlags })
|
||
const consoleEnrollmentEnabled = Boolean(featureFlags?.['feature.crowdsec.console_enrollment'])
|
||
```
|
||
|
||
**Impact:** If `feature.crowdsec.console_enrollment` is `false` or undefined, the **entire enrollment card is not rendered**:
|
||
|
||
```typescript
|
||
{consoleEnrollmentEnabled && (
|
||
<Card data-testid="console-enrollment-card">
|
||
... enrollment UI ...
|
||
</Card>
|
||
)}
|
||
```
|
||
|
||
#### 2. **Enroll Button Disabled Conditions** ⚠️ HIGH PROBABILITY
|
||
|
||
**File:** [CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L692)
|
||
|
||
```typescript
|
||
disabled={isConsolePending || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready) || !enrollmentToken.trim()}
|
||
```
|
||
|
||
The button is disabled when:
|
||
|
||
| Condition | Description |
|
||
|-----------|-------------|
|
||
| `isConsolePending` | Enrollment mutation is already in progress OR status is 'enrolling' |
|
||
| `lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready` | LAPI status query returned data but `lapi_ready` is `false` |
|
||
| `!enrollmentToken.trim()` | Enrollment token input is empty |
|
||
|
||
**⚠️ CRITICAL FINDING:** The LAPI ready check can block enrollment:
|
||
- If `lapiStatusQuery.data` exists AND `lapi_ready` is `false`, button is DISABLED
|
||
- This can happen if CrowdSec process is running but LAPI hasn't fully initialized
|
||
|
||
#### 3. **Validation Blocks in submitConsoleEnrollment()** ⚠️ HIGH PROBABILITY
|
||
|
||
**File:** [CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L269-L276)
|
||
|
||
```typescript
|
||
const submitConsoleEnrollment = async (force = false) => {
|
||
const allowMissingTenant = force && !consoleTenant.trim()
|
||
const requireAck = normalizedConsoleStatus === 'not_enrolled'
|
||
if (!validateConsoleEnrollment({ allowMissingTenant, requireAck })) return // <-- EARLY RETURN
|
||
...
|
||
}
|
||
```
|
||
|
||
**Validation function** (line 254-267):
|
||
|
||
```typescript
|
||
const validateConsoleEnrollment = (options?) => {
|
||
const nextErrors = {}
|
||
if (!enrollmentToken.trim()) {
|
||
nextErrors.token = 'Enrollment token is required'
|
||
}
|
||
if (!consoleAgentName.trim()) {
|
||
nextErrors.agent = 'Agent name is required'
|
||
}
|
||
if (!consoleTenant.trim() && !options?.allowMissingTenant) {
|
||
nextErrors.tenant = 'Tenant / organization is required' // <-- BLOCKS if tenant empty
|
||
}
|
||
if (options?.requireAck && !consoleAck) {
|
||
nextErrors.ack = 'You must acknowledge...' // <-- BLOCKS if checkbox unchecked
|
||
}
|
||
setConsoleErrors(nextErrors)
|
||
return Object.keys(nextErrors).length === 0
|
||
}
|
||
```
|
||
|
||
**Validation will SILENTLY block** the request if:
|
||
1. `enrollmentToken` is empty
|
||
2. `consoleAgentName` is empty
|
||
3. `consoleTenant` is empty (for non-force enrollment)
|
||
4. **`consoleAck` checkbox is unchecked** (for first-time enrollment where status is `not_enrolled`)
|
||
|
||
### Summary of Blocking Conditions
|
||
|
||
| Condition | Where | Effect |
|
||
|-----------|-------|--------|
|
||
| Feature flag disabled | Line 44-45 | Entire enrollment card not rendered |
|
||
| **LAPI not ready** | Line 692 | **Button disabled** |
|
||
| Token empty | Line 692, validation | Button disabled + validation blocks |
|
||
| Agent name empty | Validation line 260 | Validation silently blocks |
|
||
| **Tenant empty** | Validation line 262 | **Validation silently blocks** |
|
||
| **Acknowledgment unchecked** | Validation line 265 | **Validation silently blocks** |
|
||
| Already enrolling | Line 692 | Button disabled |
|
||
|
||
### Most Likely Root Causes (Ordered by Probability)
|
||
|
||
#### 1. **LAPI Not Ready Check** ⚠️ HIGH PROBABILITY
|
||
|
||
The condition `(lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)` will disable the button if:
|
||
- The status query has completed (data exists)
|
||
- But `lapi_ready` is `false`
|
||
|
||
**Check:** Call `GET /api/v1/admin/crowdsec/status` and verify `lapi_ready` field.
|
||
|
||
#### 2. **Acknowledgment Checkbox Not Checked** ⚠️ HIGH PROBABILITY
|
||
|
||
For first-time enrollment (`status === 'not_enrolled'`), the checkbox MUST be checked. The validation will silently `return` without making the API call.
|
||
|
||
**Check:** Ensure checkbox with `data-testid="console-ack-checkbox"` is checked.
|
||
|
||
#### 3. **Tenant Field Empty**
|
||
|
||
For non-force enrollment, the tenant field is required. An empty tenant will block the request silently.
|
||
|
||
**Check:** Ensure tenant input has a value.
|
||
|
||
### Code Sections That Need Fixes
|
||
|
||
#### Fix 1: Add Debug Logging (Temporary)
|
||
|
||
Add to `submitConsoleEnrollment()`:
|
||
|
||
```typescript
|
||
const submitConsoleEnrollment = async (force = false) => {
|
||
console.log('[DEBUG] submitConsoleEnrollment called', {
|
||
force,
|
||
enrollmentToken: enrollmentToken.trim() ? 'present' : 'empty',
|
||
consoleTenant,
|
||
consoleAgentName,
|
||
consoleAck,
|
||
normalizedConsoleStatus,
|
||
lapiReady: lapiStatusQuery.data?.lapi_ready,
|
||
})
|
||
// ... rest
|
||
}
|
||
```
|
||
|
||
#### Fix 2: Improve Validation Feedback
|
||
|
||
The validation currently sets `consoleErrors` but these may not be visible to the user. Ensure error messages are displayed.
|
||
|
||
#### Fix 3: Check LAPI Status Polling
|
||
|
||
The LAPI status query starts only after 3 seconds (`initialCheckComplete`). If the user clicks before then, the button may be enabled (good) but LAPI might not actually be ready (backend will fail).
|
||
|
||
### Recommended Debug Steps
|
||
|
||
1. **Open browser DevTools → Console**
|
||
2. **Check if enrollment card is rendered** (look for `data-testid="console-enrollment-card"`)
|
||
3. **Inspect button element** - check if `disabled` attribute is present
|
||
4. **Check Network tab** for:
|
||
- `GET /api/v1/feature-flags` response
|
||
- `GET /api/v1/admin/crowdsec/status` response (check `lapi_ready`)
|
||
5. **Verify form state**:
|
||
- Token field has value
|
||
- Agent name has value
|
||
- Tenant has value
|
||
- Checkbox is checked
|
||
|
||
### API Client Verification
|
||
|
||
**File:** [consoleEnrollment.ts](frontend/src/api/consoleEnrollment.ts#L27-L30)
|
||
|
||
```typescript
|
||
export async function enrollConsole(payload: ConsoleEnrollPayload): Promise<ConsoleEnrollmentStatus> {
|
||
const resp = await client.post<ConsoleEnrollmentStatus>('/admin/crowdsec/console/enroll', payload)
|
||
return resp.data
|
||
}
|
||
```
|
||
|
||
✅ The API client is correctly implemented. The issue is upstream - **the function is never being called** because conditions are blocking it.
|
||
|
||
---
|
||
|
||
## ✅ RESOLVED Issue A: CrowdSec Console Enrollment Not Working
|
||
|
||
### Symptoms
|
||
- User submits enrollment with valid key
|
||
- Charon shows "Enrollment submitted" success message
|
||
- No engine appears in CrowdSec.net dashboard
|
||
- User reports: "The CrowdSec enrollment request NEVER reached crowdsec.net"
|
||
|
||
### Root Cause (CONFIRMED)
|
||
|
||
**The Bug**: After a **successful** `cscli console enroll <key>` command (exit code 0), CrowdSec's help explicitly states:
|
||
> "After running this command you will need to validate the enrollment in the webapp."
|
||
|
||
Exit code 0 = enrollment REQUEST sent, NOT enrollment COMPLETE.
|
||
|
||
The code incorrectly set `status = enrolled` when it should have been `status = pending_acceptance`.
|
||
|
||
### Fixes Applied (December 16, 2025)
|
||
|
||
#### Fix A1: Backend Status Semantics
|
||
**File**: `backend/internal/crowdsec/console_enroll.go`
|
||
- Added `consoleStatusPendingAcceptance = "pending_acceptance"` constant
|
||
- Changed success status from `enrolled` to `pending_acceptance`
|
||
- Fixed idempotency check to also skip re-enrollment when status is `pending_acceptance`
|
||
- Fixed config path check to look in `config/config.yaml` subdirectory first
|
||
- Updated log message to say "pending acceptance on crowdsec.net"
|
||
|
||
#### Fix A2: Frontend User Guidance
|
||
**File**: `frontend/src/pages/CrowdSecConfig.tsx`
|
||
- Updated success toast to say "Accept the enrollment on app.crowdsec.net to complete registration"
|
||
- Added `isConsolePendingAcceptance` variable
|
||
- Updated `canRotateKey` to include `pending_acceptance` status
|
||
- Added info box with link to app.crowdsec.net when status is `pending_acceptance`
|
||
|
||
#### Fix A3: Test Updates
|
||
**Files**: `backend/internal/crowdsec/console_enroll_test.go`, `backend/internal/api/handlers/crowdsec_handler_test.go`
|
||
- Updated all tests expecting `enrolled` to expect `pending_acceptance`
|
||
- Updated test for idempotency to verify second call is blocked for `pending_acceptance`
|
||
- Changed `EnrolledAt` assertion to `LastAttemptAt` (enrollment is not complete yet)
|
||
|
||
### Verification
|
||
All backend tests pass:
|
||
- `TestConsoleEnrollSuccess` ✅
|
||
- `TestConsoleEnrollIdempotentWhenAlreadyEnrolled` ✅
|
||
- `TestConsoleEnrollNormalizesFullCommand` ✅
|
||
- `TestConsoleEnrollDoesNotPassTenant` ✅
|
||
- `TestConsoleEnrollmentStatus/returns_pending_acceptance_status_after_enrollment` ✅
|
||
- `TestConsoleStatusAfterEnroll` ✅
|
||
|
||
Frontend type-check passes ✅
|
||
|
||
---
|
||
|
||
## NEW Issue B: Live Log Viewer Shows "Disconnected"
|
||
|
||
### Symptoms
|
||
- Live Log Viewer component shows "Disconnected" status badge
|
||
- No logs appear (even when there should be logs)
|
||
- WebSocket connection may not be establishing
|
||
|
||
### Root Cause Analysis
|
||
|
||
**Primary Finding: WebSocket Connection Works But Logs Are Sparse**
|
||
|
||
The WebSocket implementation is correct. The issue is likely:
|
||
|
||
1. **No logs being generated** - If CrowdSec/Caddy aren't actively processing requests, there are no logs
|
||
2. **Initial connection timing** - The `isConnected` state depends on `onOpen` callback
|
||
|
||
**Verified Working Components:**
|
||
|
||
1. **Backend WebSocket Handler**: `backend/internal/api/handlers/logs_ws.go`
|
||
- Properly upgrades HTTP to WebSocket
|
||
- Subscribes to `BroadcastHook` for log entries
|
||
- Sends ping messages every 30 seconds
|
||
|
||
2. **Frontend Connection Logic**: `frontend/src/api/logs.ts`
|
||
- `connectLiveLogs()` correctly builds WebSocket URL
|
||
- Properly handles `onOpen`, `onClose`, `onError` callbacks
|
||
|
||
3. **Frontend Component**: `frontend/src/components/LiveLogViewer.tsx`
|
||
- `isConnected` state is set in `handleOpen` callback
|
||
- Connection effect runs on mount and mode changes
|
||
|
||
### Potential Issues Found
|
||
|
||
#### Issue B1: WebSocket Route May Be Protected
|
||
|
||
**Location**: `backend/internal/api/routes/routes.go` Line 158
|
||
|
||
The WebSocket endpoint is under the `protected` route group, meaning it requires authentication:
|
||
|
||
```go
|
||
protected.GET("/logs/live", handlers.LogsWebSocketHandler)
|
||
```
|
||
|
||
**Problem**: WebSocket connections may fail silently if auth token isn't being passed. The browser's native WebSocket API doesn't automatically include HTTP-only cookies or Authorization headers.
|
||
|
||
**Verification Steps:**
|
||
1. Check browser DevTools Network tab for WebSocket connection
|
||
2. Look for 401/403 responses
|
||
3. Check if `token` query parameter is being sent
|
||
|
||
#### Issue B2: No Error Display to User
|
||
|
||
**Location**: `frontend/src/components/LiveLogViewer.tsx` Lines 170-172
|
||
|
||
```tsx
|
||
const handleError = (error: Event) => {
|
||
console.error('WebSocket error:', error);
|
||
setIsConnected(false);
|
||
};
|
||
```
|
||
|
||
**Problem**: Errors are only logged to console, not displayed to user. User sees "Disconnected" without knowing why.
|
||
|
||
### Required Fixes for Issue B
|
||
|
||
#### Fix B1: Add Error State Display
|
||
|
||
**File**: `frontend/src/components/LiveLogViewer.tsx`
|
||
|
||
Add error state tracking:
|
||
|
||
```tsx
|
||
const [connectionError, setConnectionError] = useState<string | null>(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); // Clear any previous errors
|
||
};
|
||
```
|
||
|
||
Display error in UI:
|
||
|
||
```tsx
|
||
{connectionError && (
|
||
<div className="text-red-400 text-xs p-2">{connectionError}</div>
|
||
)}
|
||
```
|
||
|
||
#### Fix B2: Add Authentication to WebSocket URL
|
||
|
||
**File**: `frontend/src/api/logs.ts`
|
||
|
||
The WebSocket needs to pass auth token as query parameter since WebSocket API doesn't support custom headers:
|
||
|
||
```typescript
|
||
export const connectLiveLogs = (
|
||
filters: LiveLogFilter,
|
||
onMessage: (log: LiveLogEntry) => void,
|
||
onOpen?: () => void,
|
||
onError?: (error: Event) => void,
|
||
onClose?: () => void
|
||
): (() => void) => {
|
||
const params = new URLSearchParams();
|
||
if (filters.level) params.append('level', filters.level);
|
||
if (filters.source) params.append('source', filters.source);
|
||
|
||
// Add auth token from localStorage if available
|
||
const token = localStorage.getItem('token');
|
||
if (token) {
|
||
params.append('token', token);
|
||
}
|
||
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/api/v1/logs/live?${params.toString()}`;
|
||
// ...
|
||
};
|
||
```
|
||
|
||
**Backend Auth Check** (verify this exists):
|
||
The backend auth middleware must check for `token` query parameter in addition to headers/cookies for WebSocket connections.
|
||
|
||
#### Fix B3: Add Reconnection Logic
|
||
|
||
**File**: `frontend/src/components/LiveLogViewer.tsx`
|
||
|
||
Add automatic reconnection with exponential backoff:
|
||
|
||
```tsx
|
||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||
const maxReconnectAttempts = 5;
|
||
|
||
const handleClose = () => {
|
||
console.log(`${currentMode} log viewer disconnected`);
|
||
setIsConnected(false);
|
||
|
||
// Auto-reconnect logic
|
||
if (reconnectAttempts < maxReconnectAttempts) {
|
||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||
setTimeout(() => {
|
||
setReconnectAttempts(prev => prev + 1);
|
||
// Trigger reconnection by updating a dependency
|
||
}, delay);
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Summary of All Fixes
|
||
|
||
### Issue A: CrowdSec Enrollment
|
||
|
||
| File | Change |
|
||
|------|--------|
|
||
| `frontend/src/pages/CrowdSecConfig.tsx` | Update success toast to mention acceptance step |
|
||
| `frontend/src/pages/CrowdSecConfig.tsx` | Add info box with link to crowdsec.net |
|
||
| `backend/internal/crowdsec/console_enroll.go` | Add `pending_acceptance` status constant |
|
||
| `docs/cerberus.md` | Add documentation about acceptance requirement |
|
||
|
||
### Issue B: Live Log Viewer
|
||
|
||
| File | Change |
|
||
|------|--------|
|
||
| `frontend/src/components/LiveLogViewer.tsx` | Add error state display |
|
||
| `frontend/src/api/logs.ts` | Pass auth token in WebSocket URL |
|
||
| `frontend/src/components/LiveLogViewer.tsx` | Add reconnection logic with backoff |
|
||
|
||
---
|
||
|
||
## Testing Checklist
|
||
|
||
### Enrollment Testing
|
||
- [ ] Submit enrollment with valid key
|
||
- [ ] Verify success message mentions acceptance step
|
||
- [ ] Verify UI shows guidance to accept on crowdsec.net
|
||
- [ ] Accept enrollment on crowdsec.net
|
||
- [ ] Verify engine appears in dashboard
|
||
|
||
### Live Logs Testing
|
||
- [ ] Open Live Log Viewer page
|
||
- [ ] Verify WebSocket connects (check Network tab)
|
||
- [ ] Verify "Connected" badge shows
|
||
- [ ] Generate some logs (make HTTP request to proxy)
|
||
- [ ] Verify logs appear in viewer
|
||
- [ ] Test disconnect/reconnect behavior
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
- [CrowdSec Console Documentation](https://docs.crowdsec.net/docs/console/)
|
||
- [WEBSOCKET_FIX_SUMMARY.md](../../WEBSOCKET_FIX_SUMMARY.md)
|
||
- [cerberus.md - Console Enrollment](../../docs/cerberus.md)
|
||
|
||
---
|
||
---
|
||
|
||
# PREVIOUS ANALYSIS (Resolved Issues - Kept for Reference)
|
||
|
||
---
|
||
|
||
## Issue 1: CrowdSec Card Toggle Broken on Cerberus Dashboard
|
||
|
||
### Symptoms
|
||
- CrowdSec card shows "Active" but toggle doesn't work properly
|
||
- Shows "on and active" but CrowdSec is NOT actually on
|
||
|
||
### Root Cause Analysis
|
||
|
||
**Files Involved:**
|
||
- [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx#L69-L110) - `crowdsecPowerMutation`
|
||
- [frontend/src/api/crowdsec.ts](frontend/src/api/crowdsec.ts#L5-L18) - `startCrowdsec`, `stopCrowdsec`, `statusCrowdsec`
|
||
- [backend/internal/api/handlers/security_handler.go](backend/internal/api/handlers/security_handler.go#L61-L137) - `GetStatus()`
|
||
- [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go#L140-L206) - `Start()`, `Stop()`, `Status()`
|
||
|
||
**The Problem:**
|
||
|
||
1. **Dual-Source State Conflict**: The `GetStatus()` endpoint in [security_handler.go#L61-L137](backend/internal/api/handlers/security_handler.go#L61-L137) combines state from TWO sources:
|
||
- `settings` table: `security.crowdsec.enabled` and `security.crowdsec.mode`
|
||
- `security_configs` table: `CrowdSecMode` field
|
||
|
||
2. **Toggle Updates Wrong Store**: When the user toggles CrowdSec via `crowdsecPowerMutation`:
|
||
- It calls `updateSetting('security.crowdsec.enabled', ...)` which updates the `settings` table
|
||
- It calls `startCrowdsec()` / `stopCrowdsec()` which updates `security_configs.CrowdSecMode`
|
||
|
||
3. **State Priority Mismatch**: In [security_handler.go#L100-L108](backend/internal/api/handlers/security_handler.go#L100-L108):
|
||
```go
|
||
// CrowdSec enabled override (from settings table)
|
||
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
|
||
if strings.EqualFold(setting.Value, "true") {
|
||
crowdSecMode = "local"
|
||
} else {
|
||
crowdSecMode = "disabled"
|
||
}
|
||
}
|
||
```
|
||
The `settings` table overrides `security_configs`, but the `Start()` handler updates `security_configs`.
|
||
|
||
4. **Process State Not Verified**: The frontend shows "Active" based on `status.crowdsec.enabled` from the API, but this is computed from DB settings, NOT from actual process status. The `crowdsecStatus` state (line 43-44) fetches real process status but this is a **separate query** displayed below the card.
|
||
|
||
### The Fix
|
||
|
||
**Backend ([security_handler.go](backend/internal/api/handlers/security_handler.go)):**
|
||
- `GetStatus()` should check actual CrowdSec process status via the `CrowdsecExecutor.Status()` call, not just DB state
|
||
|
||
**Frontend ([Security.tsx](frontend/src/pages/Security.tsx)):**
|
||
- The toggle's `checked` state should use `crowdsecStatus?.running` (actual process state) instead of `status.crowdsec.enabled` (DB setting)
|
||
- Or sync both states properly after toggle
|
||
|
||
---
|
||
|
||
## Issue 2: Live Log Viewer Shows "Disconnected" But Logs Appear
|
||
|
||
### Symptoms
|
||
- Shows "Disconnected" status badge but logs ARE appearing
|
||
- Navigating away and back causes logs to disappear
|
||
|
||
### Root Cause Analysis
|
||
|
||
**Files Involved:**
|
||
- [frontend/src/components/LiveLogViewer.tsx](frontend/src/components/LiveLogViewer.tsx#L146-L240)
|
||
- [frontend/src/api/logs.ts](frontend/src/api/logs.ts#L95-L174) - `connectLiveLogs`, `connectSecurityLogs`
|
||
|
||
**The Problem:**
|
||
|
||
1. **Connection State Race Condition**: In [LiveLogViewer.tsx#L165-L240](frontend/src/components/LiveLogViewer.tsx#L165-L240):
|
||
```tsx
|
||
useEffect(() => {
|
||
// Close existing connection
|
||
if (closeConnectionRef.current) {
|
||
closeConnectionRef.current();
|
||
closeConnectionRef.current = null;
|
||
}
|
||
// ... setup handlers ...
|
||
return () => {
|
||
if (closeConnectionRef.current) {
|
||
closeConnectionRef.current();
|
||
closeConnectionRef.current = null;
|
||
}
|
||
setIsConnected(false); // <-- Issue: cleanup runs AFTER effect re-runs
|
||
};
|
||
}, [currentMode, filters, securityFilters, isPaused, maxLogs, showBlockedOnly]);
|
||
```
|
||
|
||
2. **Dependency Array Includes `isPaused`**: When `isPaused` changes, the entire effect re-runs, creating a new WebSocket. But the cleanup of the old connection sets `isConnected(false)` AFTER the new connection's `onOpen` sets `isConnected(true)`, causing a flash of "Disconnected".
|
||
|
||
3. **Logs Disappear on Navigation**: The `logs` state is stored locally in the component via `useState<DisplayLogEntry[]>([])`. When the component unmounts (navigation) and remounts, state resets to empty array. There's no persistence or caching.
|
||
|
||
### The Fix
|
||
|
||
**[LiveLogViewer.tsx](frontend/src/components/LiveLogViewer.tsx):**
|
||
|
||
1. **Fix State Race**: Use a ref to track connection state transitions:
|
||
```tsx
|
||
const connectionIdRef = useRef(0);
|
||
// In effect: increment connectionId, check it in callbacks
|
||
```
|
||
|
||
2. **Remove `isPaused` from Dependencies**: Pausing should NOT close/reopen the WebSocket. Instead, just skip adding messages when paused:
|
||
```tsx
|
||
// Current (wrong): connection is in dependency array
|
||
// Fixed: only filter/process messages based on isPaused flag
|
||
```
|
||
|
||
3. **Persist Logs Across Navigation**: Either:
|
||
- Store logs in React Query cache
|
||
- Use a global store (zustand/context)
|
||
- Accept the limitation with a "Logs cleared on navigation" note
|
||
|
||
---
|
||
|
||
## Issue 3: DEPRECATED CrowdSec Mode Toggle Still in UI
|
||
|
||
### Symptoms
|
||
- CrowdSec config page shows "Disabled/Local/External" mode toggle
|
||
- This is confusing because CrowdSec should run based SOLELY on the Feature Flag in System Settings
|
||
|
||
### Root Cause Analysis
|
||
|
||
**Files Involved:**
|
||
- [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L68-L100) - Mode toggle UI
|
||
- [frontend/src/pages/SystemSettings.tsx](frontend/src/pages/SystemSettings.tsx#L89-L107) - Feature flag toggle
|
||
- [backend/internal/models/security_config.go](backend/internal/models/security_config.go#L15) - `CrowdSecMode` field
|
||
|
||
**The Problem:**
|
||
|
||
1. **Redundant Control Surfaces**: There are THREE ways to control CrowdSec:
|
||
- Feature Flag: `feature.cerberus.enabled` in Settings (System Settings page)
|
||
- Per-Service Toggle: `security.crowdsec.enabled` in Settings (Security Dashboard)
|
||
- Mode Toggle: `CrowdSecMode` in SecurityConfig (CrowdSec Config page)
|
||
|
||
2. **Deprecated UI Still Present**: In [CrowdSecConfig.tsx#L68-L100](frontend/src/pages/CrowdSecConfig.tsx#L68-L100):
|
||
```tsx
|
||
<Card>
|
||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||
<div className="space-y-1">
|
||
<h2 className="text-lg font-semibold">CrowdSec Mode</h2>
|
||
<p className="text-sm text-gray-400">
|
||
{isLocalMode ? 'CrowdSec runs locally...' : 'CrowdSec decisions are paused...'}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-gray-400">Disabled</span>
|
||
<Switch
|
||
checked={isLocalMode}
|
||
onChange={(e) => handleModeToggle(e.target.checked)}
|
||
...
|
||
/>
|
||
<span className="text-sm text-gray-200">Local</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
```
|
||
|
||
3. **`isLocalMode` Derived from Wrong Source**: Line 28:
|
||
```tsx
|
||
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
|
||
```
|
||
This checks `mode` from `security_configs.CrowdSecMode`, not the feature flag.
|
||
|
||
4. **`handleModeToggle` Updates Wrong Setting**: Lines 72-77:
|
||
```tsx
|
||
const handleModeToggle = (nextEnabled: boolean) => {
|
||
const mode = nextEnabled ? 'local' : 'disabled'
|
||
updateModeMutation.mutate(mode) // Updates security.crowdsec.mode in settings
|
||
}
|
||
```
|
||
|
||
### The Fix
|
||
|
||
**[CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx):**
|
||
1. **Remove the Mode Toggle Card entirely** (lines 68-100)
|
||
2. **Add a notice**: "CrowdSec is controlled via the toggle on the Security Dashboard or System Settings"
|
||
|
||
**Backend Cleanup (optional future work):**
|
||
- Remove `CrowdSecMode` field from SecurityConfig model
|
||
- Migrate all state to use only `security.crowdsec.enabled` setting
|
||
|
||
---
|
||
|
||
## Issue 4: Enrollment Shows "CrowdSec is not running"
|
||
|
||
### Symptoms
|
||
- CrowdSec enrollment shows error even when enabled
|
||
- Red warning box: "CrowdSec is not running"
|
||
|
||
### Root Cause Analysis
|
||
|
||
**Files Involved:**
|
||
- [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L30-L45) - `lapiStatusQuery`
|
||
- [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx#L172-L196) - Warning display logic
|
||
- [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go#L252-L275) - `Status()`
|
||
|
||
**The Problem:**
|
||
|
||
1. **LAPI Status Query Uses Wrong Condition**: In [CrowdSecConfig.tsx#L30-L40](frontend/src/pages/CrowdSecConfig.tsx#L30-L40):
|
||
```tsx
|
||
const lapiStatusQuery = useQuery<CrowdSecStatus>({
|
||
queryKey: ['crowdsec-lapi-status'],
|
||
queryFn: statusCrowdsec,
|
||
enabled: consoleEnrollmentEnabled && initialCheckComplete,
|
||
refetchInterval: 5000,
|
||
retry: false,
|
||
})
|
||
```
|
||
The query is `enabled` only when `consoleEnrollmentEnabled` (feature flag for console enrollment).
|
||
|
||
2. **Warning Shows When Process Not Running**: In [CrowdSecConfig.tsx#L172-L196](frontend/src/pages/CrowdSecConfig.tsx#L172-L196):
|
||
```tsx
|
||
{lapiStatusQuery.data && !lapiStatusQuery.data.running && initialCheckComplete && (
|
||
<div className="..." data-testid="lapi-not-running-warning">
|
||
<p>CrowdSec is not running</p>
|
||
...
|
||
</div>
|
||
)}
|
||
```
|
||
This shows when `lapiStatusQuery.data.running === false`.
|
||
|
||
3. **Status Check May Return Stale Data**: The `Status()` backend handler checks:
|
||
- PID file existence
|
||
- Process status via `kill -0`
|
||
- LAPI health via `cscli lapi status`
|
||
|
||
But if CrowdSec was just enabled, there may be a race condition where the settings say "enabled" but the process hasn't started yet.
|
||
|
||
4. **Startup Reconciliation Timing**: `ReconcileCrowdSecOnStartup()` in [crowdsec_startup.go](backend/internal/services/crowdsec_startup.go) runs at container start, but if the user enables CrowdSec AFTER startup, the process won't auto-start.
|
||
|
||
### The Fix
|
||
|
||
**[CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx):**
|
||
|
||
1. **Improve Warning Message**: The "not running" warning should include:
|
||
- A "Start CrowdSec" button that calls `startCrowdsec()` API
|
||
- Or a link to the Security Dashboard where the toggle is
|
||
|
||
2. **Check Both States**: Show the warning only when:
|
||
- User has enabled CrowdSec (via either toggle)
|
||
- AND the process is not running
|
||
|
||
3. **Add Auto-Retry**: After enabling CrowdSec, poll status more aggressively for 30 seconds
|
||
|
||
---
|
||
|
||
## Implementation Plan
|
||
|
||
### Phase 1: Backend Fixes (Priority: High)
|
||
|
||
#### 1.1 Unify State Source
|
||
**File**: [backend/internal/api/handlers/security_handler.go](backend/internal/api/handlers/security_handler.go)
|
||
|
||
**Change**: Modify `GetStatus()` to include actual process status:
|
||
```go
|
||
// Add after line 137:
|
||
// Check actual CrowdSec process status
|
||
if h.crowdsecExecutor != nil {
|
||
ctx := c.Request.Context()
|
||
running, pid, _ := h.crowdsecExecutor.Status(ctx, h.dataDir)
|
||
// Override enabled state based on actual process
|
||
crowdsecProcessRunning = running
|
||
}
|
||
```
|
||
|
||
Add `crowdsecExecutor` field to `SecurityHandler` struct and inject it during initialization.
|
||
|
||
#### 1.2 Consistent Mode Updates
|
||
**File**: [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go)
|
||
|
||
**Change**: In `Start()` and `Stop()`, also update the `settings` table:
|
||
```go
|
||
// In Start(), after updating SecurityConfig (line ~165):
|
||
if h.DB != nil {
|
||
setting := models.Setting{Key: "security.crowdsec.enabled", Value: "true", Category: "security", Type: "bool"}
|
||
h.DB.Where(models.Setting{Key: "security.crowdsec.enabled"}).Assign(setting).FirstOrCreate(&setting)
|
||
}
|
||
|
||
// In Stop(), after updating SecurityConfig (line ~228):
|
||
if h.DB != nil {
|
||
setting := models.Setting{Key: "security.crowdsec.enabled", Value: "false", Category: "security", Type: "bool"}
|
||
h.DB.Where(models.Setting{Key: "security.crowdsec.enabled"}).Assign(setting).FirstOrCreate(&setting)
|
||
}
|
||
```
|
||
|
||
### Phase 2: Frontend Fixes (Priority: High)
|
||
|
||
#### 2.1 Fix CrowdSec Toggle State
|
||
**File**: [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx)
|
||
|
||
**Change 1**: Use actual process status for toggle (around line 203):
|
||
```tsx
|
||
// Replace: checked={status.crowdsec.enabled}
|
||
// With:
|
||
checked={crowdsecStatus?.running ?? status.crowdsec.enabled}
|
||
```
|
||
|
||
**Change 2**: After successful toggle, refetch both status and process status
|
||
|
||
#### 2.2 Fix LiveLogViewer Connection State
|
||
**File**: [frontend/src/components/LiveLogViewer.tsx](frontend/src/components/LiveLogViewer.tsx)
|
||
|
||
**Change 1**: Remove `isPaused` from useEffect dependencies (line 237):
|
||
```tsx
|
||
// Change from:
|
||
}, [currentMode, filters, securityFilters, isPaused, maxLogs, showBlockedOnly]);
|
||
// To:
|
||
}, [currentMode, filters, securityFilters, maxLogs, showBlockedOnly]);
|
||
```
|
||
|
||
**Change 2**: Handle pause inside message handler (line 192):
|
||
```tsx
|
||
const handleMessage = (entry: SecurityLogEntry) => {
|
||
// isPaused check stays here, not in effect
|
||
if (isPausedRef.current) return; // Use ref instead of state
|
||
// ... rest of handler
|
||
};
|
||
```
|
||
|
||
**Change 3**: Add ref for isPaused:
|
||
```tsx
|
||
const isPausedRef = useRef(isPaused);
|
||
useEffect(() => { isPausedRef.current = isPaused; }, [isPaused]);
|
||
```
|
||
|
||
#### 2.3 Remove Deprecated Mode Toggle
|
||
**File**: [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx)
|
||
|
||
**Change**: Remove the entire "CrowdSec Mode" Card (lines 291-311 in current render):
|
||
```tsx
|
||
// DELETE: The entire <Card> block containing "CrowdSec Mode"
|
||
```
|
||
|
||
Add informational banner instead:
|
||
```tsx
|
||
{/* Replace mode toggle with info banner */}
|
||
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
|
||
<p className="text-sm text-blue-200">
|
||
<strong>Note:</strong> CrowdSec is controlled via the toggle on the{' '}
|
||
<Link to="/security" className="underline">Security Dashboard</Link>.
|
||
Enable/disable CrowdSec there, then configure presets and files here.
|
||
</p>
|
||
</div>
|
||
```
|
||
|
||
#### 2.4 Fix Enrollment Warning
|
||
**File**: [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx)
|
||
|
||
**Change**: Add "Start CrowdSec" button to the warning (around line 185):
|
||
```tsx
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
await startCrowdsec();
|
||
toast.info('Starting CrowdSec...');
|
||
lapiStatusQuery.refetch();
|
||
} catch (err) {
|
||
toast.error('Failed to start CrowdSec');
|
||
}
|
||
}}
|
||
>
|
||
Start CrowdSec
|
||
</Button>
|
||
```
|
||
|
||
### Phase 3: Remove Deprecated Mode (Priority: Medium)
|
||
|
||
#### 3.1 Backend Model Cleanup (Future)
|
||
**File**: [backend/internal/models/security_config.go](backend/internal/models/security_config.go)
|
||
|
||
Mark `CrowdSecMode` as deprecated with migration path.
|
||
|
||
#### 3.2 Settings Migration
|
||
Create migration to ensure all users have `security.crowdsec.enabled` setting derived from `CrowdSecMode`.
|
||
|
||
---
|
||
|
||
## Files to Modify Summary
|
||
|
||
### Backend
|
||
| File | Changes |
|
||
|------|---------|
|
||
| `backend/internal/api/handlers/security_handler.go` | Add process status check to `GetStatus()` |
|
||
| `backend/internal/api/handlers/crowdsec_handler.go` | Sync `settings` table in `Start()`/`Stop()` |
|
||
|
||
### Frontend
|
||
| File | Changes |
|
||
|------|---------|
|
||
| `frontend/src/pages/Security.tsx` | Use `crowdsecStatus?.running` for toggle state |
|
||
| `frontend/src/components/LiveLogViewer.tsx` | Fix `isPaused` dependency, use ref |
|
||
| `frontend/src/pages/CrowdSecConfig.tsx` | Remove mode toggle, add info banner, add "Start CrowdSec" button |
|
||
|
||
---
|
||
|
||
## Testing Checklist
|
||
|
||
- [ ] Toggle CrowdSec on Security Dashboard → verify process starts
|
||
- [ ] Toggle CrowdSec off → verify process stops
|
||
- [ ] Refresh page → verify toggle state matches process state
|
||
- [ ] Open LiveLogViewer → verify "Connected" status
|
||
- [ ] Pause logs → verify connection remains open
|
||
- [ ] Navigate away and back → logs are cleared (expected) but connection re-establishes
|
||
- [ ] CrowdSec Config page → no mode toggle, info banner present
|
||
- [ ] Enrollment section → shows "Start CrowdSec" button when process not running
|