# CrowdSec Full Implementation Plan **Status:** Planning **Created:** December 12, 2025 **Priority:** Critical - CrowdSec is completely non-functional **Issue:** `FATAL crowdsec init: while loading acquisition config: no datasource enabled` --- ## Table of Contents 1. [Executive Summary](#executive-summary) 2. [Root Cause Analysis](#root-cause-analysis) 3. [Architecture Overview](#architecture-overview) 4. [Implementation Phases](#implementation-phases) - [Phase 1: Core CrowdSec Configuration](#phase-1-core-crowdsec-configuration) - [Phase 2: Caddy Integration](#phase-2-caddy-integration) - [Phase 2.5: Unified Logging & Live Viewer Integration](#phase-25-unified-logging--live-viewer-integration) - [Phase 3: API Integration](#phase-3-api-integration) - [Phase 4: Testing](#phase-4-testing) 5. [File Changes Summary](#file-changes-summary) 6. [Configuration Options](#configuration-options) 7. [Ignore Files Review](#ignore-files-review) 8. [Rollout & Verification](#rollout--verification) --- ## Executive Summary CrowdSec is currently **completely broken** in Charon. When starting the container with `CERBERUS_SECURITY_CROWDSEC_MODE=local`, CrowdSec crashes immediately with: ``` FATAL crowdsec init: while loading acquisition config: no datasource enabled ``` This indicates that while CrowdSec binaries are installed and configuration files are copied, the **acquisition configuration** (which tells CrowdSec what logs to parse) is missing or empty. ### What Works Today - ✅ CrowdSec binaries installed (`crowdsec`, `cscli`) - ✅ Base config files copied from release tarball to `/etc/crowdsec.dist/` - ✅ Config copied to `/etc/crowdsec/` at container startup - ✅ LAPI port changed to 8085 to avoid conflict with Charon - ✅ Machine registration via `cscli machines add -a --force` - ✅ Hub index update via `cscli hub update` - ✅ Backend API handlers for decisions, ban/unban, import/export - ✅ caddy-crowdsec-bouncer compiled into Caddy binary ### What's Broken/Missing - ❌ **No `acquis.yaml`** - CrowdSec doesn't know what logs to parse - ❌ **No parsers installed** - No way to understand Caddy log format - ❌ **No scenarios installed** - No detection rules - ❌ **No collections installed** - No pre-packaged security configurations - ❌ **Caddy not logging in parseable format** - Default JSON logging may not match parsers - ❌ **Bouncer not registered** - caddy-crowdsec-bouncer needs API key - ❌ **No automated bouncer API key generation** --- ## Root Cause Analysis ### The Fatal Error Explained CrowdSec requires **datasources** to function. A datasource tells CrowdSec: 1. Where to find logs (file path, journald, etc.) 2. What parser to use for those logs 3. Optional labels for categorization Without datasources configured in `acquis.yaml`, CrowdSec has nothing to monitor and refuses to start. ### Missing Acquisition Configuration The CrowdSec release tarball includes default config files, but the `acquis.yaml` in the tarball is either: 1. Empty 2. Contains example datasources that don't exist in the container (like syslog) 3. Not present at all **Current entrypoint flow:** ```bash # Step 1: Copy base config (MISSING acquis.yaml or empty) cp -r /etc/crowdsec.dist/* /etc/crowdsec/ # Step 2: Variable substitution (doesn't create acquis.yaml) envsubst < config.yaml > config.yaml.tmp && mv config.yaml.tmp config.yaml # Step 3: Port changes (doesn't affect acquisition) sed -i 's|listen_uri: 127.0.0.1:8080|listen_uri: 127.0.0.1:8085|g' config.yaml # Step 4: Hub update (doesn't install parsers/scenarios by default) cscli hub update # Step 5: Machine registration (succeeds) cscli machines add -a --force # Step 6: START CROWDSEC → CRASH (no datasources!) crowdsec & ``` ### Missing Components 1. **Parsers**: CrowdSec needs parsers to understand log formats - Caddy uses JSON logging by default - Need `crowdsecurity/caddy-logs` parser or custom parser 2. **Scenarios**: Detection rules that identify attacks - HTTP flood detection - HTTP probing - HTTP path traversal - HTTP SQLi/XSS patterns 3. **Collections**: Bundled parsers + scenarios for specific applications - `crowdsecurity/caddy` collection (may not exist) - `crowdsecurity/http-cve` for known vulnerabilities - `crowdsecurity/base-http-scenarios` for generic HTTP attacks 4. **Acquisition Config**: Tells CrowdSec where to read logs ```yaml # /etc/crowdsec/acquis.yaml source: file filenames: - /var/log/caddy/access.log labels: type: caddy ``` --- ## Architecture Overview ### How CrowdSec Should Integrate with Charon ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Docker Container │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────────────────────────────┐ │ │ │ Caddy │──logs──▶│ /var/log/caddy/access.log (JSON) │ │ │ │ (Reverse │ └──────────────┬──────────────────────┘ │ │ │ Proxy) │ │ │ │ │ │ ▼ │ │ │ ┌─────────┐ │ ┌─────────────────────────────────────┐ │ │ │ │Bouncer │◀├─LAPI───│ CrowdSec Agent (LAPI :8085) │ │ │ │ │Plugin │ │ │ ┌─────────────────────────────────┐│ │ │ │ └─────────┘ │ │ │ acquis.yaml → file acquisition ││ │ │ └─────────────┘ │ │ parsers → crowdsecurity/caddy ││ │ │ │ │ │ scenarios → http-flood, etc. ││ │ │ │ │ └─────────────────────────────────┘│ │ │ │ └──────────────┬──────────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────────────────────────────┐ │ │ │ Charon │◀─API───│ cscli (CLI management) │ │ │ │ (Management │ │ - decisions list/add/delete │ │ │ │ API) │ │ - hub install parsers/scenarios │ │ │ └─────────────┘ └─────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Data Flow 1. **Request arrives** at Caddy reverse proxy 2. **Caddy bouncer plugin** checks CrowdSec LAPI for decision on client IP - If banned → Return 403 - If allowed → Continue processing 3. **Caddy logs request** to `/var/log/caddy/access.log` in JSON format 4. **CrowdSec agent** reads log file via acquisition config 5. **Parser** converts JSON log to normalized event 6. **Scenarios** analyze events for attack patterns 7. **Decisions** are created (ban IP for X duration) 8. **Bouncer plugin** receives decision via streaming/polling 9. **Future requests** from banned IP are blocked ### Required CrowdSec Components | Component | Source | Purpose | |-----------|--------|---------| | `crowdsecurity/caddy-logs` | Hub | Parse Caddy JSON access logs | | `crowdsecurity/base-http-scenarios` | Hub | Generic HTTP attack detection | | `crowdsecurity/http-cve` | Hub | Known HTTP CVE detection | | Custom `acquis.yaml` | Charon | Point CrowdSec at Caddy logs | | Bouncer API key | Auto-generated | Authenticate bouncer with LAPI | --- ## Implementation Phases ### Phase 1: Core CrowdSec Configuration **Goal:** Make CrowdSec agent start successfully and process logs. #### 1.1 Create Acquisition Template Create a default acquisition configuration that reads Caddy logs: **New file: `configs/crowdsec/acquis.yaml`** ```yaml # Charon/Caddy Log Acquisition Configuration # This file tells CrowdSec what logs to monitor # Caddy access logs (JSON format) source: file filenames: - /var/log/caddy/access.log - /var/log/caddy/*.log labels: type: caddy --- # Alternative: If using syslog output from Caddy # source: journalctl # journalctl_filter: # - "_SYSTEMD_UNIT=caddy.service" # labels: # type: caddy ``` #### 1.2 Create Default Config Template **New file: `configs/crowdsec/config.yaml.template`** ```yaml # CrowdSec Configuration for Charon # Generated at container startup common: daemonize: false log_media: stdout log_level: info log_dir: /var/log/crowdsec/ working_dir: . config_paths: config_dir: /etc/crowdsec/ data_dir: /var/lib/crowdsec/data/ simulation_path: /etc/crowdsec/simulation.yaml hub_dir: /etc/crowdsec/hub/ index_path: /etc/crowdsec/hub/.index.json notification_dir: /etc/crowdsec/notifications/ plugin_dir: /usr/lib/crowdsec/plugins/ crowdsec_service: enable: true acquisition_path: /etc/crowdsec/acquis.yaml acquisition_dir: /etc/crowdsec/acquis.d/ parser_routines: 1 buckets_routines: 1 output_routines: 1 cscli: output: human color: auto # hub_branch: master db_config: log_level: info type: sqlite db_path: /var/lib/crowdsec/data/crowdsec.db flush: max_items: 5000 max_age: 7d plugin_config: user: root group: root api: client: insecure_skip_verify: false credentials_path: /etc/crowdsec/local_api_credentials.yaml server: log_level: info listen_uri: 127.0.0.1:8085 profiles_path: /etc/crowdsec/profiles.yaml console_path: /etc/crowdsec/console.yaml online_client: credentials_path: /etc/crowdsec/online_api_credentials.yaml tls: # TLS disabled for local-only LAPI # Enable when exposing LAPI externally prometheus: enabled: true level: full listen_addr: 127.0.0.1 listen_port: 6060 ``` #### 1.3 Create Local API Credentials Template **New file: `configs/crowdsec/local_api_credentials.yaml.template`** ```yaml # CrowdSec Local API Credentials # This file is auto-generated - do not edit manually url: http://127.0.0.1:8085 login: ${CROWDSEC_MACHINE_ID} password: ${CROWDSEC_MACHINE_PASSWORD} ``` #### 1.4 Create Bouncer Registration Script **New file: `configs/crowdsec/register_bouncer.sh`** ```bash #!/bin/sh # Register the Caddy bouncer with CrowdSec LAPI # This script is idempotent - safe to run multiple times BOUNCER_NAME="${CROWDSEC_BOUNCER_NAME:-caddy-bouncer}" API_KEY_FILE="/etc/crowdsec/bouncers/${BOUNCER_NAME}.key" # Ensure bouncer directory exists mkdir -p /etc/crowdsec/bouncers # Check if bouncer already registered if cscli bouncers list -o json 2>/dev/null | grep -q "\"name\":\"${BOUNCER_NAME}\""; then echo "Bouncer '${BOUNCER_NAME}' already registered" # If key file doesn't exist, we need to re-register if [ ! -f "$API_KEY_FILE" ]; then echo "API key file missing, re-registering bouncer..." cscli bouncers delete "${BOUNCER_NAME}" 2>/dev/null || true else echo "Using existing API key from ${API_KEY_FILE}" cat "$API_KEY_FILE" exit 0 fi fi # Register new bouncer and save API key echo "Registering bouncer '${BOUNCER_NAME}'..." API_KEY=$(cscli bouncers add "${BOUNCER_NAME}" -o raw) if [ -z "$API_KEY" ]; then echo "ERROR: Failed to register bouncer" >&2 exit 1 fi # Save API key to file echo "$API_KEY" > "$API_KEY_FILE" chmod 600 "$API_KEY_FILE" echo "Bouncer registered successfully" echo "API Key: $API_KEY" ``` #### 1.5 Create Hub Setup Script **New file: `configs/crowdsec/install_hub_items.sh`** ```bash #!/bin/sh # Install required CrowdSec hub items (parsers, scenarios, collections) # This script runs during container startup set -e echo "Installing CrowdSec hub items for Charon..." # Update hub index first echo "Updating hub index..." cscli hub update # Install Caddy log parser (if available) # Note: crowdsecurity/caddy-logs may not exist yet - check hub if cscli parsers inspect crowdsecurity/caddy-logs >/dev/null 2>&1; then echo "Installing Caddy log parser..." cscli parsers install crowdsecurity/caddy-logs --force else echo "Caddy-specific parser not available, using HTTP parser..." cscli parsers install crowdsecurity/http-logs --force 2>/dev/null || true fi # Install base HTTP parsers (always needed) echo "Installing base parsers..." cscli parsers install crowdsecurity/syslog-logs --force 2>/dev/null || true cscli parsers install crowdsecurity/geoip-enrich --force 2>/dev/null || true cscli parsers install crowdsecurity/http-logs --force 2>/dev/null || true # Install HTTP scenarios for attack detection echo "Installing HTTP scenarios..." cscli scenarios install crowdsecurity/http-probing --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-sensitive-files --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-backdoors-attempts --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-path-traversal-probing --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-xss-probing --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-sqli-probing --force 2>/dev/null || true cscli scenarios install crowdsecurity/http-generic-bf --force 2>/dev/null || true # Install CVE collection for known vulnerabilities echo "Installing CVE collection..." cscli collections install crowdsecurity/http-cve --force 2>/dev/null || true # Install base HTTP collection (bundles common scenarios) echo "Installing base HTTP collection..." cscli collections install crowdsecurity/base-http-scenarios --force 2>/dev/null || true # Verify installation echo "" echo "=== Installed Components ===" echo "Parsers:" cscli parsers list -o json 2>/dev/null | grep -o '"name":"[^"]*"' | head -10 || echo " (none or error)" echo "" echo "Scenarios:" cscli scenarios list -o json 2>/dev/null | grep -o '"name":"[^"]*"' | head -10 || echo " (none or error)" echo "" echo "Collections:" cscli collections list -o json 2>/dev/null | grep -o '"name":"[^"]*"' | head -10 || echo " (none or error)" echo "" echo "Hub installation complete!" ``` #### 1.6 Update Dockerfile **File: `Dockerfile`** Add CrowdSec configuration files to the image: ```dockerfile # After the CrowdSec installer stage, add: # Copy CrowdSec configuration templates COPY configs/crowdsec/acquis.yaml /etc/crowdsec.dist/acquis.yaml COPY configs/crowdsec/config.yaml.template /etc/crowdsec.dist/config.yaml.template COPY configs/crowdsec/local_api_credentials.yaml.template /etc/crowdsec.dist/local_api_credentials.yaml.template COPY configs/crowdsec/register_bouncer.sh /usr/local/bin/register_bouncer.sh COPY configs/crowdsec/install_hub_items.sh /usr/local/bin/install_hub_items.sh # Make scripts executable RUN chmod +x /usr/local/bin/register_bouncer.sh /usr/local/bin/install_hub_items.sh ``` #### 1.7 Update docker-entrypoint.sh **File: `docker-entrypoint.sh`** Replace the CrowdSec initialization section with a more robust implementation: ```bash #!/bin/sh set -e echo "Starting Charon with integrated Caddy..." # ============================================================================ # CrowdSec Initialization # ============================================================================ CROWDSEC_PID="" SECURITY_CROWDSEC_MODE=${CERBERUS_SECURITY_CROWDSEC_MODE:-${CHARON_SECURITY_CROWDSEC_MODE:-$CPM_SECURITY_CROWDSEC_MODE}} # Initialize CrowdSec configuration if cscli is present if command -v cscli >/dev/null; then echo "Initializing CrowdSec configuration..." # Create required directories mkdir -p /etc/crowdsec mkdir -p /etc/crowdsec/hub mkdir -p /etc/crowdsec/acquis.d mkdir -p /etc/crowdsec/bouncers mkdir -p /etc/crowdsec/notifications mkdir -p /var/lib/crowdsec/data mkdir -p /var/log/crowdsec mkdir -p /var/log/caddy # Copy base configuration if not exists if [ ! -f "/etc/crowdsec/config.yaml" ]; then echo "Copying base CrowdSec configuration..." if [ -d "/etc/crowdsec.dist" ]; then cp -r /etc/crowdsec.dist/* /etc/crowdsec/ 2>/dev/null || true fi fi # Create/update acquisition config for Caddy logs # This is CRITICAL - CrowdSec won't start without datasources if [ ! -f "/etc/crowdsec/acquis.yaml" ] || [ ! -s "/etc/crowdsec/acquis.yaml" ]; then echo "Creating acquisition configuration for Caddy logs..." cat > /etc/crowdsec/acquis.yaml << 'EOF' # Caddy access logs acquisition # CrowdSec will monitor these files for security events source: file filenames: - /var/log/caddy/access.log - /var/log/caddy/*.log labels: type: caddy EOF fi # Ensure config.yaml has correct LAPI port (8085 to avoid conflict with Charon) if [ -f "/etc/crowdsec/config.yaml" ]; then sed -i 's|listen_uri: 127.0.0.1:8080|listen_uri: 127.0.0.1:8085|g' /etc/crowdsec/config.yaml sed -i 's|listen_uri: 0.0.0.0:8080|listen_uri: 127.0.0.1:8085|g' /etc/crowdsec/config.yaml fi # Update local_api_credentials.yaml to use correct port if [ -f "/etc/crowdsec/local_api_credentials.yaml" ]; then sed -i 's|url: http://127.0.0.1:8080|url: http://127.0.0.1:8085|g' /etc/crowdsec/local_api_credentials.yaml sed -i 's|url: http://localhost:8080|url: http://127.0.0.1:8085|g' /etc/crowdsec/local_api_credentials.yaml fi # Update hub index if [ ! -f "/etc/crowdsec/hub/.index.json" ]; then echo "Updating CrowdSec hub index..." cscli hub update || echo "Warning: Failed to update hub index (network issue?)" fi # Register machine with LAPI (required for cscli commands) echo "Registering machine with CrowdSec LAPI..." cscli machines add -a --force 2>/dev/null || echo "Warning: Machine registration may have failed" # Install hub items (parsers, scenarios, collections) if [ "$SECURITY_CROWDSEC_MODE" = "local" ]; then echo "Installing CrowdSec hub items..." /usr/local/bin/install_hub_items.sh 2>/dev/null || echo "Warning: Some hub items may not have installed" fi fi # Start CrowdSec agent if local mode is enabled if [ "$SECURITY_CROWDSEC_MODE" = "local" ]; then echo "CrowdSec Local Mode enabled." if command -v crowdsec >/dev/null; then # Create an empty access log so CrowdSec doesn't fail on missing file touch /var/log/caddy/access.log echo "Starting CrowdSec agent..." crowdsec -c /etc/crowdsec/config.yaml & CROWDSEC_PID=$! echo "CrowdSec started (PID: $CROWDSEC_PID)" # Wait for LAPI to be ready echo "Waiting for CrowdSec LAPI..." for i in $(seq 1 30); do if wget -q -O- http://127.0.0.1:8085/health >/dev/null 2>&1; then echo "CrowdSec LAPI is ready!" break fi sleep 1 done # Register bouncer for Caddy if [ -x /usr/local/bin/register_bouncer.sh ]; then echo "Registering Caddy bouncer..." BOUNCER_API_KEY=$(/usr/local/bin/register_bouncer.sh 2>/dev/null | tail -1) if [ -n "$BOUNCER_API_KEY" ]; then export CROWDSEC_BOUNCER_API_KEY="$BOUNCER_API_KEY" echo "Bouncer registered with API key" fi fi else echo "CrowdSec binary not found - skipping agent startup" fi fi # ... rest of entrypoint (Caddy startup, Charon startup, etc.) ``` ### Phase 2: Caddy Integration **Goal:** Configure Caddy to log in a format CrowdSec can parse, and enable the bouncer plugin. #### 2.1 Configure Caddy Access Logging Charon generates Caddy configuration dynamically. We need to ensure access logs are written in a format CrowdSec can parse. **Update: `backend/internal/caddy/config.go`** Add logging configuration to the Caddy JSON config: ```go // In the buildCaddyConfig function or where global config is built // Add logging configuration for CrowdSec func buildLoggingConfig() map[string]interface{} { return map[string]interface{}{ "logs": map[string]interface{}{ "default": map[string]interface{}{ "writer": map[string]interface{}{ "output": "file", "filename": "/var/log/caddy/access.log", }, "encoder": map[string]interface{}{ "format": "json", }, "level": "INFO", }, }, } } ``` #### 2.2 Update CrowdSec Bouncer Handler The existing `buildCrowdSecHandler` function already generates the correct format, but we need to ensure the API key is available. **File: `backend/internal/caddy/config.go`** The function at line 752 is mostly correct. Verify it includes: - `api_url`: Points to `http://127.0.0.1:8085` (already done) - `api_key`: From environment variable (already done) - `enable_streaming`: For real-time updates (already done) #### 2.3 Create Custom Caddy Parser for CrowdSec Since there may not be an official `crowdsecurity/caddy-logs` parser, we need to create a custom parser or use the generic HTTP parser with appropriate normalization. **New file: `configs/crowdsec/parsers/caddy-json-logs.yaml`** ```yaml # Custom parser for Caddy JSON access logs # Install with: cscli parsers install ./caddy-json-logs.yaml --force name: charon/caddy-json-logs description: Parse Caddy JSON access logs for Charon filter: evt.Meta.log_type == 'caddy' # Caddy JSON log format example: # {"level":"info","ts":1702406400.123,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"192.168.1.100","method":"GET","uri":"/api/v1/test","host":"example.com"},"status":200,"duration":0.001} pattern: '%{DATA:message}' grok: apply_on: message pattern: '%{GREEDYDATA:json_data}' statics: - parsed: json_data target: evt.Unmarshaled # Extract fields from JSON jsondecoder: - apply_on: json_data target: evt.Parsed # Map Caddy fields to CrowdSec standard fields statics: - target: evt.Meta.source_ip expression: evt.Parsed.request.remote_ip - target: evt.Meta.http_method expression: evt.Parsed.request.method - target: evt.Meta.http_path expression: evt.Parsed.request.uri - target: evt.Meta.http_host expression: evt.Parsed.request.host - target: evt.Meta.http_status expression: evt.Parsed.status - target: evt.Meta.http_user_agent expression: evt.Parsed.request.headers["User-Agent"][0] - target: evt.StrTime expression: string(evt.Parsed.ts) ``` ### Phase 2.5: Unified Logging & Live Viewer Integration **Goal:** Make Caddy access logs accessible to ALL Cerberus security modules (CrowdSec, WAF/Coraza, Rate Limiting, ACL) and integrate with the existing Live Log Viewer on the Cerberus dashboard. #### 2.5.1 Architecture: Unified Security Log Pipeline The current architecture has separate logging paths that need to be unified: ``` ┌─────────────────────────────────────────────────────────────────────────────────┐ │ Unified Logging Architecture │ ├─────────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ CADDY REVERSE PROXY │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────────────┐ │ │ │ │ │ CrowdSec │ │ Coraza │ │ Rate │ │ Request Logging │ │ │ │ │ │ Bouncer │ │ WAF │ │ Limiter │ │ (access.log → JSON) │ │ │ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └───────────┬───────────┘ │ │ │ │ │ │ │ │ │ │ │ └────────┼──────────────┼──────────────┼────────────────────┼───────────────┘ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ │ UNIFIED LOG FILE: /var/log/caddy/access.log │ │ │ │ (JSON format with security event annotations) │ │ │ └─────────────────────────────────────────────────────┬───────────────────────┘ │ │ │ │ │ ┌───────────────────────────────────┼───────────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────┐ ┌──────────────────────────────────────────────┐ │ │ │ CrowdSec Agent │ │ Charon Backend │ │ │ │ (acquis.yaml reads │ │ ┌──────────────────────────────────────┐ │ │ │ │ /var/log/caddy/*.log)│ │ │ LogWatcher Service (NEW) │ │ │ │ │ │ │ │ - Tail log file in real-time │ │ │ │ │ Parses → Scenarios → │ │ │ - Parse JSON entries │ │ │ │ │ Decisions → LAPI │ │ │ - Broadcast to WebSocket listeners │ │ │ │ └─────────────────────────┘ │ │ - Tag entries by security module │ │ │ │ │ └──────────────────┬───────────────────┘ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ Live Logs WebSocket Handler │ │ │ │ │ │ /api/v1/logs/live │ │ │ │ │ │ /api/v1/cerberus/logs/live (NEW) │ │ │ │ │ └──────────────────┬───────────────────┘ │ │ │ └──────────────────────┼───────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ │ FRONTEND │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ │ │ LiveLogViewer Component (Enhanced) │ │ │ │ │ │ - Subscribe to cerberus log stream │ │ │ │ │ │ - Filter by: source (waf, crowdsec, ratelimit, acl), level, IP │ │ │ │ │ │ - Color-coded security events │ │ │ │ │ │ - Click to expand request details │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────────┘ ``` #### 2.5.2 Create Log Watcher Service A new service that tails the Caddy access log and broadcasts entries to WebSocket subscribers. This replaces the current approach that only broadcasts internal Go application logs. **New file: `backend/internal/services/log_watcher.go`** ```go package services import ( "bufio" "context" "encoding/json" "io" "os" "sync" "time" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" ) // LogWatcher provides real-time tailing of Caddy access logs type LogWatcher struct { mu sync.RWMutex subscribers map[string]chan models.SecurityLogEntry logPath string ctx context.Context cancel context.CancelFunc } // SecurityLogEntry represents a security-relevant log entry broadcast to clients type SecurityLogEntry struct { Timestamp string `json:"timestamp"` Level string `json:"level"` Source string `json:"source"` // "caddy", "waf", "crowdsec", "ratelimit", "acl" ClientIP string `json:"client_ip"` Method string `json:"method"` Host string `json:"host"` Path string `json:"path"` Status int `json:"status"` Duration float64 `json:"duration"` Message string `json:"message"` Blocked bool `json:"blocked"` // True if request was blocked BlockReason string `json:"block_reason"` // "waf", "crowdsec", "ratelimit", "acl" Details map[string]interface{} `json:"details,omitempty"` } // NewLogWatcher creates a new log watcher instance func NewLogWatcher(logPath string) *LogWatcher { ctx, cancel := context.WithCancel(context.Background()) return &LogWatcher{ subscribers: make(map[string]chan models.SecurityLogEntry), logPath: logPath, ctx: ctx, cancel: cancel, } } // Start begins tailing the log file func (w *LogWatcher) Start() error { go w.tailFile() return nil } // Stop halts the log watcher func (w *LogWatcher) Stop() { w.cancel() } // Subscribe adds a new subscriber for log entries func (w *LogWatcher) Subscribe(id string) <-chan models.SecurityLogEntry { w.mu.Lock() defer w.mu.Unlock() ch := make(chan models.SecurityLogEntry, 100) w.subscribers[id] = ch return ch } // Unsubscribe removes a subscriber func (w *LogWatcher) Unsubscribe(id string) { w.mu.Lock() defer w.mu.Unlock() if ch, ok := w.subscribers[id]; ok { close(ch) delete(w.subscribers, id) } } // broadcast sends a log entry to all subscribers func (w *LogWatcher) broadcast(entry models.SecurityLogEntry) { w.mu.RLock() defer w.mu.RUnlock() for _, ch := range w.subscribers { select { case ch <- entry: default: // Skip if channel is full (prevents blocking) } } } // tailFile continuously reads new entries from the log file func (w *LogWatcher) tailFile() { for { select { case <-w.ctx.Done(): return default: } // Wait for file to exist if _, err := os.Stat(w.logPath); os.IsNotExist(err) { time.Sleep(time.Second) continue } file, err := os.Open(w.logPath) if err != nil { logger.Log().WithError(err).Error("Failed to open log file for tailing") time.Sleep(time.Second) continue } // Seek to end of file file.Seek(0, io.SeekEnd) reader := bufio.NewReader(file) for { select { case <-w.ctx.Done(): file.Close() return default: } line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { // No new data, wait and retry time.Sleep(100 * time.Millisecond) continue } logger.Log().WithError(err).Warn("Error reading log file") break // Reopen file } if line == "" || line == "\n" { continue } entry := w.parseLogEntry(line) if entry != nil { w.broadcast(*entry) } } file.Close() time.Sleep(time.Second) // Brief pause before reopening } } // parseLogEntry converts a Caddy JSON log line into a SecurityLogEntry func (w *LogWatcher) parseLogEntry(line string) *models.SecurityLogEntry { var caddyLog models.CaddyAccessLog if err := json.Unmarshal([]byte(line), &caddyLog); err != nil { return nil } entry := &models.SecurityLogEntry{ Timestamp: time.Unix(int64(caddyLog.Ts), 0).Format(time.RFC3339), Level: caddyLog.Level, Source: "caddy", ClientIP: caddyLog.Request.RemoteIP, Method: caddyLog.Request.Method, Host: caddyLog.Request.Host, Path: caddyLog.Request.URI, Status: caddyLog.Status, Duration: caddyLog.Duration, Message: caddyLog.Msg, Details: make(map[string]interface{}), } // Detect security events from status codes and log metadata if caddyLog.Status == 403 { entry.Blocked = true entry.Level = "warn" // Determine block reason from response headers or log fields // WAF blocks typically include "waf" or "coraza" in the response // CrowdSec blocks come from the bouncer // Rate limit blocks have specific status patterns if caddyLog.Logger == "http.handlers.waf" || containsKey(caddyLog.Request.Headers, "X-Coraza-Id") { entry.Source = "waf" entry.BlockReason = "WAF rule triggered" } else { entry.Source = "cerberus" entry.BlockReason = "Access denied" } } if caddyLog.Status == 429 { entry.Blocked = true entry.Source = "ratelimit" entry.Level = "warn" entry.BlockReason = "Rate limit exceeded" } return entry } // containsKey checks if a header map contains a specific key func containsKey(headers map[string][]string, key string) bool { _, ok := headers[key] return ok } ``` #### 2.5.3 Add Security Log Entry Model **Update file: `backend/internal/models/logs.go`** Add the SecurityLogEntry type alongside existing log models: ```go // SecurityLogEntry represents a security-relevant log entry for live streaming type SecurityLogEntry struct { Timestamp string `json:"timestamp"` Level string `json:"level"` Source string `json:"source"` // "caddy", "waf", "crowdsec", "ratelimit", "acl" ClientIP string `json:"client_ip"` Method string `json:"method"` Host string `json:"host"` Path string `json:"path"` Status int `json:"status"` Duration float64 `json:"duration"` Message string `json:"message"` Blocked bool `json:"blocked"` BlockReason string `json:"block_reason,omitempty"` Details map[string]interface{} `json:"details,omitempty"` } ``` #### 2.5.4 Create Cerberus Live Logs WebSocket Handler A new WebSocket endpoint specifically for Cerberus security logs that streams parsed Caddy access logs. **New file: `backend/internal/api/handlers/cerberus_logs_ws.go`** ```go package handlers import ( "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/services" ) // CerberusLogsHandler handles Cerberus security log streaming type CerberusLogsHandler struct { watcher *services.LogWatcher } // NewCerberusLogsHandler creates a new Cerberus logs handler func NewCerberusLogsHandler(watcher *services.LogWatcher) *CerberusLogsHandler { return &CerberusLogsHandler{watcher: watcher} } // LiveLogs handles WebSocket connections for Cerberus security log streaming func (h *CerberusLogsHandler) LiveLogs(c *gin.Context) { logger.Log().Info("Cerberus logs WebSocket connection attempt") // Upgrade HTTP connection to WebSocket conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Log().WithError(err).Error("Failed to upgrade Cerberus logs WebSocket") return } defer conn.Close() subscriberID := uuid.New().String() logger.Log().WithField("subscriber_id", subscriberID).Info("Cerberus logs WebSocket connected") // Parse query filters sourceFilter := strings.ToLower(c.Query("source")) // waf, crowdsec, ratelimit, acl levelFilter := strings.ToLower(c.Query("level")) ipFilter := c.Query("ip") hostFilter := strings.ToLower(c.Query("host")) blockedOnly := c.Query("blocked") == "true" // Subscribe to log watcher logChan := h.watcher.Subscribe(subscriberID) defer h.watcher.Unsubscribe(subscriberID) // Channel to detect client disconnect done := make(chan struct{}) go func() { defer close(done) for { if _, _, err := conn.ReadMessage(); err != nil { return } } }() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case entry, ok := <-logChan: if !ok { return } // Apply filters if sourceFilter != "" && !strings.EqualFold(entry.Source, sourceFilter) { continue } if levelFilter != "" && !strings.EqualFold(entry.Level, levelFilter) { continue } if ipFilter != "" && !strings.Contains(entry.ClientIP, ipFilter) { continue } if hostFilter != "" && !strings.Contains(strings.ToLower(entry.Host), hostFilter) { continue } if blockedOnly && !entry.Blocked { continue } // Send to WebSocket client if err := conn.WriteJSON(entry); err != nil { logger.Log().WithError(err).Debug("Failed to write Cerberus log to WebSocket") return } case <-ticker.C: if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { return } case <-done: return } } } ``` #### 2.5.5 Register New Routes **Update file: `backend/internal/api/routes/routes.go`** Add the new Cerberus logs endpoint: ```go // In SetupRoutes function, add: // Initialize log watcher for Caddy access logs logPath := filepath.Join(cfg.DataDir, "logs", "access.log") logWatcher := services.NewLogWatcher(logPath) if err := logWatcher.Start(); err != nil { logger.Log().WithError(err).Error("Failed to start log watcher") } // Cerberus security logs WebSocket cerberusLogsHandler := handlers.NewCerberusLogsHandler(logWatcher) api.GET("/cerberus/logs/live", cerberusLogsHandler.LiveLogs) // Alternative: Also expose under /logs/security/live for clarity api.GET("/logs/security/live", cerberusLogsHandler.LiveLogs) ``` #### 2.5.6 Update Frontend API Client **Update file: `frontend/src/api/logs.ts`** Add the new security logs connection function: ```typescript export interface SecurityLogEntry { timestamp: string; level: string; source: string; // "caddy", "waf", "crowdsec", "ratelimit", "acl" client_ip: string; method: string; host: string; path: string; status: number; duration: number; message: string; blocked: boolean; block_reason?: string; details?: Record; } export interface SecurityLogFilter { source?: string; // Filter by security module level?: string; ip?: string; host?: string; blocked?: boolean; // Only show blocked requests } /** * Connects to the Cerberus security logs WebSocket endpoint. * This streams Caddy access logs with security event annotations. */ 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) params.append('blocked', 'true'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/live?${params.toString()}`; console.log('Connecting to Cerberus logs WebSocket:', wsUrl); const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('Cerberus logs WebSocket connected'); onOpen?.(); }; ws.onmessage = (event: MessageEvent) => { try { const log = JSON.parse(event.data) as SecurityLogEntry; onMessage(log); } catch (err) { console.error('Failed to parse security log:', err); } }; ws.onerror = (error: Event) => { console.error('Cerberus logs WebSocket error:', error); onError?.(error); }; ws.onclose = (event: CloseEvent) => { console.log('Cerberus logs WebSocket closed', event); onClose?.(); }; return () => { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(); } }; }; ``` #### 2.5.7 Update LiveLogViewer Component **Update file: `frontend/src/components/LiveLogViewer.tsx`** Enhance the component to support both application logs and security access logs: ```tsx import { useEffect, useRef, useState } from 'react'; import { connectLiveLogs, connectSecurityLogs, LiveLogEntry, LiveLogFilter, SecurityLogEntry, SecurityLogFilter } from '../api/logs'; import { Button } from './ui/Button'; import { Pause, Play, Trash2, Filter, Shield, Globe } from 'lucide-react'; type LogMode = 'application' | 'security'; interface LiveLogViewerProps { filters?: LiveLogFilter; securityFilters?: SecurityLogFilter; mode?: LogMode; maxLogs?: number; className?: string; } // Unified log entry for display interface DisplayLogEntry { timestamp: string; level: string; source: string; message: string; blocked?: boolean; blockReason?: string; clientIP?: string; method?: string; host?: string; path?: string; status?: number; details?: Record; } export function LiveLogViewer({ filters = {}, securityFilters = {}, mode = 'application', maxLogs = 500, className = '' }: LiveLogViewerProps) { const [logs, setLogs] = useState([]); const [isPaused, setIsPaused] = useState(false); const [isConnected, setIsConnected] = useState(false); const [currentMode, setCurrentMode] = useState(mode); const [textFilter, setTextFilter] = useState(''); const [levelFilter, setLevelFilter] = useState(''); const [sourceFilter, setSourceFilter] = useState(''); const [showBlockedOnly, setShowBlockedOnly] = useState(false); const logContainerRef = useRef(null); const closeConnectionRef = useRef<(() => void) | null>(null); const shouldAutoScroll = useRef(true); // Convert entries to unified format const toDisplayEntry = (entry: LiveLogEntry | SecurityLogEntry): DisplayLogEntry => { if ('client_ip' in entry) { // SecurityLogEntry const secEntry = entry as SecurityLogEntry; return { timestamp: secEntry.timestamp, level: secEntry.level, source: secEntry.source, message: secEntry.blocked ? `🚫 BLOCKED: ${secEntry.block_reason} - ${secEntry.method} ${secEntry.path}` : `${secEntry.method} ${secEntry.path} → ${secEntry.status}`, blocked: secEntry.blocked, blockReason: secEntry.block_reason, clientIP: secEntry.client_ip, method: secEntry.method, host: secEntry.host, path: secEntry.path, status: secEntry.status, details: secEntry.details, }; } // LiveLogEntry (application logs) return { timestamp: entry.timestamp, level: entry.level, source: entry.source || 'app', message: entry.message, details: entry.data, }; }; useEffect(() => { // Cleanup previous connection if (closeConnectionRef.current) { closeConnectionRef.current(); } const handleMessage = (entry: LiveLogEntry | SecurityLogEntry) => { if (!isPaused) { const displayEntry = toDisplayEntry(entry); setLogs((prev) => { const updated = [...prev, displayEntry]; return updated.length > maxLogs ? updated.slice(-maxLogs) : updated; }); } }; const handleOpen = () => { console.log(`${currentMode} log viewer connected`); setIsConnected(true); }; const handleError = (error: Event) => { console.error('WebSocket error:', error); setIsConnected(false); }; const handleClose = () => { console.log(`${currentMode} log viewer disconnected`); setIsConnected(false); }; // Connect based on mode if (currentMode === 'security') { closeConnectionRef.current = connectSecurityLogs( { ...securityFilters, blocked: showBlockedOnly }, handleMessage as (log: SecurityLogEntry) => void, handleOpen, handleError, handleClose ); } else { closeConnectionRef.current = connectLiveLogs( filters, handleMessage as (log: LiveLogEntry) => void, handleOpen, handleError, handleClose ); } return () => { if (closeConnectionRef.current) { closeConnectionRef.current(); } setIsConnected(false); }; }, [currentMode, filters, securityFilters, isPaused, maxLogs, showBlockedOnly]); // ... rest of component (auto-scroll, handleScroll, filtering logic) // Color coding for security events const getEntryStyle = (log: DisplayLogEntry) => { if (log.blocked) { return 'bg-red-900/30 border-l-2 border-red-500'; } const level = log.level.toLowerCase(); if (level.includes('error') || level.includes('fatal')) return 'text-red-400'; if (level.includes('warn')) return 'text-yellow-400'; return ''; }; const getSourceBadge = (source: string) => { const colors: Record = { waf: 'bg-orange-600', crowdsec: 'bg-purple-600', ratelimit: 'bg-blue-600', acl: 'bg-green-600', caddy: 'bg-gray-600', cerberus: 'bg-indigo-600', }; return colors[source.toLowerCase()] || 'bg-gray-500'; }; return (
{/* Header with mode toggle */}

{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}

{isConnected ? 'Connected' : 'Disconnected'}
{/* Mode toggle */}
{/* Existing controls */}
{/* Enhanced filters for security mode */}
setTextFilter(e.target.value)} className="flex-1 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white" /> {currentMode === 'security' && ( <> )}
{/* Log display with security styling */}
{logs.length === 0 && (
No logs yet. Waiting for events...
)} {logs.map((log, index) => (
{log.timestamp} {log.source.toUpperCase()} {log.clientIP && ( {log.clientIP} )} {log.message} {log.blocked && log.blockReason && ( [{log.blockReason}] )}
))}
{/* Footer */}
Showing {logs.length} logs {isPaused && ⏸ Paused}
); } ``` #### 2.5.8 Update Security Page to Use Enhanced Viewer **Update file: `frontend/src/pages/Security.tsx`** Change the LiveLogViewer invocation to use security mode: ```tsx {/* Live Activity Section */} {status.cerberus?.enabled && (
)} ``` #### 2.5.9 Update Caddy Logging to Include Security Metadata **Update file: `backend/internal/caddy/config.go`** Enhance the logging configuration to include security-relevant fields: ```go // In GenerateConfig, update the logging configuration: Logging: &LoggingConfig{ Logs: map[string]*LogConfig{ "access": { Level: "INFO", Writer: &WriterConfig{ Output: "file", Filename: logFile, Roll: true, RollSize: 10, RollKeep: 5, RollKeepDays: 7, }, Encoder: &EncoderConfig{ Format: "json", // Include all relevant fields for security analysis Fields: map[string]interface{}{ "request>remote_ip": "", "request>method": "", "request>host": "", "request>uri": "", "request>proto": "", "request>headers>User-Agent": "", "request>headers>X-Forwarded-For": "", "status": "", "size": "", "duration": "", "resp_headers>X-Coraza-Id": "", // WAF tracking "resp_headers>X-RateLimit-Remaining": "", // Rate limit tracking }, }, Include: []string{"http.log.access.access_log"}, }, }, }, ``` #### 2.5.10 Summary of File Changes for Phase 2.5 | Path | Type | Purpose | |------|------|---------| | `backend/internal/services/log_watcher.go` | New | Tail Caddy logs and broadcast to WebSocket | | `backend/internal/models/logs.go` | Update | Add SecurityLogEntry type | | `backend/internal/api/handlers/cerberus_logs_ws.go` | New | Cerberus security logs WebSocket handler | | `backend/internal/api/routes/routes.go` | Update | Register new /cerberus/logs/live endpoint | | `frontend/src/api/logs.ts` | Update | Add SecurityLogEntry types and connectSecurityLogs | | `frontend/src/components/LiveLogViewer.tsx` | Update | Support security mode with enhanced filtering | | `frontend/src/pages/Security.tsx` | Update | Use enhanced LiveLogViewer with security mode | | `backend/internal/caddy/config.go` | Update | Include security metadata in access logs | ### Phase 3: API Integration **Goal:** Ensure existing handlers work correctly and add any missing functionality. #### 3.1 Update CrowdSec Handler Initialization **File: `backend/internal/api/handlers/crowdsec_handler.go`** The existing handler is comprehensive. Key areas to verify/update: 1. **LAPI Health Check**: Already implemented at `CheckLAPIHealth` 2. **Decision Management**: Already implemented via `ListDecisions`, `BanIP`, `UnbanIP` 3. **Process Management**: Already implemented via `Start`, `Stop`, `Status` #### 3.2 Add Bouncer Registration Endpoint **New endpoint in `crowdsec_handler.go`:** ```go // RegisterBouncer registers a new bouncer or returns existing bouncer API key func (h *CrowdsecHandler) RegisterBouncer(c *gin.Context) { ctx := c.Request.Context() // Use the registration helper from internal/crowdsec package apiKey, err := crowdsec.EnsureBouncerRegistered(ctx, "http://127.0.0.1:8085") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Don't expose the full API key - just confirm registration c.JSON(http.StatusOK, gin.H{ "status": "registered", "bouncer_name": "caddy-bouncer", "api_key_preview": apiKey[:8] + "...", }) } // Add to RegisterRoutes: // rg.POST("/admin/crowdsec/bouncer/register", h.RegisterBouncer) ``` #### 3.3 Add Acquisition Config Endpoint ```go // GetAcquisitionConfig returns the current acquisition configuration func (h *CrowdsecHandler) GetAcquisitionConfig(c *gin.Context) { acquis, err := os.ReadFile("/etc/crowdsec/acquis.yaml") if err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "acquisition config not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "content": string(acquis), "path": "/etc/crowdsec/acquis.yaml", }) } // UpdateAcquisitionConfig updates the acquisition configuration func (h *CrowdsecHandler) UpdateAcquisitionConfig(c *gin.Context) { var payload struct { Content string `json:"content" binding:"required"` } if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "content required"}) return } // Backup existing config backupPath := fmt.Sprintf("/etc/crowdsec/acquis.yaml.backup.%s", time.Now().Format("20060102-150405")) if _, err := os.Stat("/etc/crowdsec/acquis.yaml"); err == nil { _ = os.Rename("/etc/crowdsec/acquis.yaml", backupPath) } // Write new config if err := os.WriteFile("/etc/crowdsec/acquis.yaml", []byte(payload.Content), 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"}) return } c.JSON(http.StatusOK, gin.H{ "status": "updated", "backup": backupPath, "reload_hint": true, }) } ``` ### Phase 4: Testing **Goal:** Update existing test scripts and create comprehensive integration tests. #### 4.1 Update Integration Test Script **File: `scripts/crowdsec_decision_integration.sh`** Add pre-flight checks for CrowdSec readiness: ```bash # Add after container start, before other tests: # TC-0: Verify CrowdSec agent started successfully log_test "TC-0: Verify CrowdSec agent started" # Check container logs for CrowdSec startup CROWDSEC_STARTED=$(docker logs ${CONTAINER_NAME} 2>&1 | grep -c "CrowdSec LAPI is ready" || echo "0") if [ "$CROWDSEC_STARTED" -ge 1 ]; then log_info " CrowdSec agent started successfully" pass_test else # Check for the fatal error FATAL_ERROR=$(docker logs ${CONTAINER_NAME} 2>&1 | grep -c "no datasource enabled" || echo "0") if [ "$FATAL_ERROR" -ge 1 ]; then fail_test "CrowdSec failed to start: no datasource enabled (acquis.yaml missing)" else log_warn " CrowdSec may not have started properly" pass_test fi fi # TC-0b: Verify acquisition config exists log_test "TC-0b: Verify acquisition config exists" ACQUIS_EXISTS=$(docker exec ${CONTAINER_NAME} cat /etc/crowdsec/acquis.yaml 2>/dev/null | grep -c "source:" || echo "0") if [ "$ACQUIS_EXISTS" -ge 1 ]; then log_info " Acquisition config found" pass_test else fail_test "Acquisition config missing or empty" fi ``` #### 4.2 Create CrowdSec Startup Test **New file: `scripts/crowdsec_startup_test.sh`** ```bash #!/usr/bin/env bash set -euo pipefail # Brief: Test that CrowdSec starts correctly in Charon container # This is a focused test for the startup issue PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" CONTAINER_NAME="charon-crowdsec-startup-test" echo "=== CrowdSec Startup Test ===" # Build if needed if ! docker image inspect charon:local >/dev/null 2>&1; then echo "Building charon:local image..." docker build -t charon:local . fi # Clean up any existing container docker rm -f ${CONTAINER_NAME} 2>/dev/null || true # Start container with CrowdSec enabled echo "Starting container with CERBERUS_SECURITY_CROWDSEC_MODE=local..." docker run -d --name ${CONTAINER_NAME} \ -p 8580:8080 \ -e CHARON_ENV=development \ -e CERBERUS_SECURITY_CROWDSEC_MODE=local \ -e FEATURE_CERBERUS_ENABLED=true \ charon:local echo "Waiting 30 seconds for CrowdSec to initialize..." sleep 30 # Check logs for errors echo "" echo "=== Container Logs (last 50 lines) ===" docker logs ${CONTAINER_NAME} 2>&1 | tail -50 echo "" echo "=== Checking for CrowdSec Status ===" # Check for fatal error if docker logs ${CONTAINER_NAME} 2>&1 | grep -q "no datasource enabled"; then echo "❌ FAIL: CrowdSec failed with 'no datasource enabled'" echo " The acquis.yaml file is missing or empty" docker rm -f ${CONTAINER_NAME} exit 1 fi # Check if LAPI is healthy LAPI_HEALTH=$(docker exec ${CONTAINER_NAME} wget -q -O- http://127.0.0.1:8085/health 2>/dev/null || echo "failed") if [ "$LAPI_HEALTH" != "failed" ]; then echo "✅ PASS: CrowdSec LAPI is healthy" else echo "⚠️ WARN: CrowdSec LAPI not responding (may still be starting)" fi # Check acquisition config echo "" echo "=== Acquisition Config ===" docker exec ${CONTAINER_NAME} cat /etc/crowdsec/acquis.yaml 2>/dev/null || echo "(not found)" # Check installed items echo "" echo "=== Installed Parsers ===" docker exec ${CONTAINER_NAME} cscli parsers list 2>/dev/null || echo "(cscli not available)" echo "" echo "=== Installed Scenarios ===" docker exec ${CONTAINER_NAME} cscli scenarios list 2>/dev/null || echo "(cscli not available)" # Cleanup docker rm -f ${CONTAINER_NAME} echo "" echo "=== Test Complete ===" ``` #### 4.3 Update Go Integration Test **File: `backend/integration/crowdsec_decisions_integration_test.go`** Add a specific test for CrowdSec startup: ```go func TestCrowdsecStartup(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "bash", "./scripts/crowdsec_startup_test.sh") cmd.Dir = "../../" out, err := cmd.CombinedOutput() t.Logf("crowdsec startup test output:\n%s", string(out)) if err != nil { t.Fatalf("crowdsec startup test failed: %v", err) } // Check for fatal errors in output if strings.Contains(string(out), "no datasource enabled") { t.Fatal("CrowdSec failed to start: no datasource enabled") } } ``` --- ## File Changes Summary ### New Files to Create | Path | Purpose | |------|---------| | `configs/crowdsec/acquis.yaml` | Default acquisition config for Caddy logs | | `configs/crowdsec/config.yaml.template` | CrowdSec main config template | | `configs/crowdsec/local_api_credentials.yaml.template` | LAPI credentials template | | `configs/crowdsec/register_bouncer.sh` | Script to register Caddy bouncer | | `configs/crowdsec/install_hub_items.sh` | Script to install parsers/scenarios | | `configs/crowdsec/parsers/caddy-json-logs.yaml` | Custom parser for Caddy JSON logs | | `scripts/crowdsec_startup_test.sh` | Focused startup test script | | `backend/internal/services/log_watcher.go` | Tail Caddy access logs and broadcast to WebSocket subscribers | | `backend/internal/api/handlers/cerberus_logs_ws.go` | Cerberus security logs WebSocket handler | ### Files to Modify | Path | Changes | |------|---------| | `Dockerfile` | Copy CrowdSec config files, make scripts executable | | `docker-entrypoint.sh` | Complete rewrite of CrowdSec initialization section | | `backend/internal/caddy/config.go` | Add logging configuration for Caddy with security metadata | | `backend/internal/api/handlers/crowdsec_handler.go` | Add bouncer registration, acquisition endpoints | | `backend/internal/models/logs.go` | Add SecurityLogEntry type for live streaming | | `backend/internal/api/routes/routes.go` | Register `/cerberus/logs/live` WebSocket endpoint | | `frontend/src/api/logs.ts` | Add SecurityLogEntry types and connectSecurityLogs function | | `frontend/src/components/LiveLogViewer.tsx` | Support security mode with enhanced filtering | | `frontend/src/pages/Security.tsx` | Use enhanced LiveLogViewer with security mode | | `scripts/crowdsec_decision_integration.sh` | Add CrowdSec startup verification | ### File Structure ``` Charon/ ├── configs/ │ └── crowdsec/ │ ├── acquis.yaml │ ├── config.yaml.template │ ├── local_api_credentials.yaml.template │ ├── register_bouncer.sh │ ├── install_hub_items.sh │ └── parsers/ │ └── caddy-json-logs.yaml ├── backend/ │ └── internal/ │ ├── api/ │ │ └── handlers/ │ │ └── cerberus_logs_ws.go (new) │ ├── models/ │ │ └── logs.go (updated) │ └── services/ │ └── log_watcher.go (new) ├── frontend/ │ └── src/ │ ├── api/ │ │ └── logs.ts (updated) │ ├── components/ │ │ └── LiveLogViewer.tsx (updated) │ └── pages/ │ └── Security.tsx (updated) ├── docker-entrypoint.sh (modified) ├── Dockerfile (modified) └── scripts/ ├── crowdsec_decision_integration.sh (modified) └── crowdsec_startup_test.sh (new) ``` --- ## Configuration Options ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `CERBERUS_SECURITY_CROWDSEC_MODE` | `disabled` | `disabled`, `local`, or `external` | | `CHARON_SECURITY_CROWDSEC_MODE` | (fallback) | Alternative name for mode | | `CROWDSEC_API_KEY` | (auto) | Bouncer API key (auto-generated if local) | | `CROWDSEC_BOUNCER_API_KEY` | (auto) | Alternative name for API key | | `CERBERUS_SECURITY_CROWDSEC_API_URL` | `http://127.0.0.1:8085` | LAPI URL for external mode | | `CROWDSEC_BOUNCER_NAME` | `caddy-bouncer` | Name for registered bouncer | ### CrowdSec Paths in Container | Path | Purpose | |------|---------| | `/etc/crowdsec/` | Main config directory | | `/etc/crowdsec/acquis.yaml` | Acquisition configuration | | `/etc/crowdsec/hub/` | Hub index and downloaded items | | `/etc/crowdsec/bouncers/` | Bouncer API keys | | `/var/lib/crowdsec/data/` | SQLite database, GeoIP data | | `/var/log/crowdsec/` | CrowdSec logs | | `/var/log/caddy/` | Caddy access logs (monitored by CrowdSec) | --- ## Ignore Files Review ### `.gitignore` Updates Add the following entries: ```gitignore # ----------------------------------------------------------------------------- # CrowdSec Runtime Data # ----------------------------------------------------------------------------- /etc/crowdsec/ /var/lib/crowdsec/ /var/log/crowdsec/ *.key ``` ### `.dockerignore` Updates Add the following entries: ```dockerignore # CrowdSec configs are copied explicitly in Dockerfile # No changes needed - configs/crowdsec/ is included by default ``` ### `.codecov.yml` Updates Add CrowdSec config files to ignore: ```yaml ignore: # ... existing entries ... # CrowdSec configuration (not source code) - "configs/crowdsec/**" ``` ### `Dockerfile` Updates Add directory creation and COPY statements: ```dockerfile # Create CrowdSec directories RUN mkdir -p /etc/crowdsec /etc/crowdsec/acquis.d /etc/crowdsec/bouncers \ /etc/crowdsec/hub /etc/crowdsec/notifications \ /var/lib/crowdsec/data /var/log/crowdsec /var/log/caddy # Copy CrowdSec configuration templates COPY configs/crowdsec/ /etc/crowdsec.dist/ COPY configs/crowdsec/register_bouncer.sh /usr/local/bin/ COPY configs/crowdsec/install_hub_items.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/register_bouncer.sh /usr/local/bin/install_hub_items.sh ``` --- ## Rollout & Verification ### Pre-Implementation Checklist - [ ] Create `configs/crowdsec/` directory structure - [ ] Write acquisition config template - [ ] Write bouncer registration script - [ ] Write hub items installation script - [ ] Test scripts locally with Docker ### Implementation Checklist - [ ] Update Dockerfile with new COPY statements - [ ] Update docker-entrypoint.sh with new initialization - [ ] Build and test image locally - [ ] Verify CrowdSec starts without "no datasource enabled" error - [ ] Verify LAPI responds on port 8085 - [ ] Verify bouncer registration works - [ ] Verify Caddy logs are being written - [ ] Verify CrowdSec parses Caddy logs ### Post-Implementation Testing 1. **Build Test:** ```bash docker build -t charon:local . ``` 2. **Startup Test:** ```bash docker run --rm -d --name charon-test \ -p 8080:8080 \ -e CERBERUS_SECURITY_CROWDSEC_MODE=local \ charon:local sleep 30 docker logs charon-test 2>&1 | grep -i crowdsec ``` 3. **LAPI Health Test:** ```bash docker exec charon-test wget -q -O- http://127.0.0.1:8085/health ``` 4. **Integration Test:** ```bash bash scripts/crowdsec_decision_integration.sh ``` 5. **Full Workflow Test:** - Enable CrowdSec in UI - Ban a test IP - Verify IP appears in banned list - Unban the IP - Verify removal 6. **Unified Logging Test:** ```bash # Verify log watcher connects to Caddy logs curl -s http://localhost:8080/api/v1/status | jq '.log_watcher' # Test WebSocket connection (using websocat if available) websocat ws://localhost:8080/api/v1/cerberus/logs/live # Generate some traffic and verify logs appear curl -s http://localhost:80/nonexistent 2>/dev/null ``` 7. **Live Log Viewer UI Test:** - Open Cerberus dashboard in browser - Verify "Security Access Logs" panel appears - Toggle between Application and Security modes - Verify blocked requests show with red highlighting - Test source filters (WAF, CrowdSec, Rate Limit, ACL) ### Success Criteria - [ ] CrowdSec agent starts without errors - [ ] LAPI responds on port 8085 - [ ] `cscli decisions list` works - [ ] `cscli decisions add -i ` works - [ ] Caddy access logs are written to `/var/log/caddy/access.log` - [ ] Bouncer plugin can query LAPI for decisions - [ ] Integration tests pass - [ ] **NEW:** LogWatcher service starts and tails Caddy logs - [ ] **NEW:** WebSocket endpoint `/api/v1/cerberus/logs/live` streams logs - [ ] **NEW:** LiveLogViewer displays security events in real-time - [ ] **NEW:** Security events (403, 429) are highlighted with source tags - [ ] **NEW:** Filters by source (waf, crowdsec, ratelimit, acl) work correctly --- ## References - [CrowdSec Documentation](https://doc.crowdsec.net/) - [CrowdSec Acquisition Configuration](https://doc.crowdsec.net/docs/data_sources/intro) - [caddy-crowdsec-bouncer Plugin](https://github.com/hslatman/caddy-crowdsec-bouncer) - [CrowdSec Hub](https://hub.crowdsec.net/) - [Caddy Logging Documentation](https://caddyserver.com/docs/json/apps/http/servers/logs/) - [Charon Security Documentation](../security.md) - [Cerberus Technical Documentation](../cerberus.md) - [Gorilla WebSocket](https://github.com/gorilla/websocket) - WebSocket implementation used