- Added references to existing test files in the UI/UX testing plan. - Updated CI failure remediation plan with improved file paths and clarity. - Expanded CrowdSec full implementation documentation with detailed configuration steps and scripts. - Improved CrowdSec testing plan with clearer objectives and expected results. - Updated current specification documentation with additional context on CVE remediation. - Enhanced docs-to-issues workflow documentation for better issue tracking. - Corrected numbering in UI/UX bugfixes specification for clarity. - Improved WAF testing plan with detailed curl commands and expected results. - Updated QA reports for CrowdSec implementation and UI/UX testing with detailed results and coverage metrics. - Fixed rate limit integration test summary with clear identification of issues and resolutions. - Enhanced rate limit test status report with detailed root causes and next steps for follow-up.
2092 lines
74 KiB
Markdown
2092 lines
74 KiB
Markdown
# 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<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
}
|
|
|
|
export function LiveLogViewer({
|
|
filters = {},
|
|
securityFilters = {},
|
|
mode = 'application',
|
|
maxLogs = 500,
|
|
className = ''
|
|
}: LiveLogViewerProps) {
|
|
const [logs, setLogs] = useState<DisplayLogEntry[]>([]);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [currentMode, setCurrentMode] = useState<LogMode>(mode);
|
|
const [textFilter, setTextFilter] = useState('');
|
|
const [levelFilter, setLevelFilter] = useState('');
|
|
const [sourceFilter, setSourceFilter] = useState('');
|
|
const [showBlockedOnly, setShowBlockedOnly] = useState(false);
|
|
const logContainerRef = useRef<HTMLDivElement>(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<string, string> = {
|
|
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 (
|
|
<div className={`bg-gray-900 rounded-lg border border-gray-700 ${className}`}>
|
|
{/* Header with mode toggle */}
|
|
<div className="flex items-center justify-between p-3 border-b border-gray-700">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-white">
|
|
{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}
|
|
</h3>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
|
isConnected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
|
|
}`}>
|
|
{isConnected ? 'Connected' : 'Disconnected'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Mode toggle */}
|
|
<div className="flex bg-gray-800 rounded-md p-0.5">
|
|
<button
|
|
onClick={() => setCurrentMode('application')}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
currentMode === 'application' ? 'bg-blue-600 text-white' : 'text-gray-400'
|
|
}`}
|
|
title="Application logs"
|
|
>
|
|
<Globe className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentMode('security')}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
currentMode === 'security' ? 'bg-blue-600 text-white' : 'text-gray-400'
|
|
}`}
|
|
title="Security access logs"
|
|
>
|
|
<Shield className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{/* Existing controls */}
|
|
<Button variant="ghost" size="sm" onClick={() => setIsPaused(!isPaused)}>
|
|
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setLogs([])}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced filters for security mode */}
|
|
<div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
|
|
<Filter className="w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Filter by text..."
|
|
value={textFilter}
|
|
onChange={(e) => 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' && (
|
|
<>
|
|
<select
|
|
value={sourceFilter}
|
|
onChange={(e) => setSourceFilter(e.target.value)}
|
|
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white"
|
|
>
|
|
<option value="">All Sources</option>
|
|
<option value="waf">WAF</option>
|
|
<option value="crowdsec">CrowdSec</option>
|
|
<option value="ratelimit">Rate Limit</option>
|
|
<option value="acl">ACL</option>
|
|
</select>
|
|
<label className="flex items-center gap-1 text-xs text-gray-400">
|
|
<input
|
|
type="checkbox"
|
|
checked={showBlockedOnly}
|
|
onChange={(e) => setShowBlockedOnly(e.target.checked)}
|
|
className="rounded border-gray-600"
|
|
/>
|
|
Blocked only
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Log display with security styling */}
|
|
<div
|
|
ref={logContainerRef}
|
|
className="h-96 overflow-y-auto p-3 font-mono text-xs bg-black"
|
|
>
|
|
{logs.length === 0 && (
|
|
<div className="text-gray-500 text-center py-8">
|
|
No logs yet. Waiting for events...
|
|
</div>
|
|
)}
|
|
{logs.map((log, index) => (
|
|
<div
|
|
key={index}
|
|
className={`mb-1 px-1 -mx-1 rounded ${getEntryStyle(log)}`}
|
|
>
|
|
<span className="text-gray-500">{log.timestamp}</span>
|
|
<span className={`ml-2 px-1 rounded text-xs ${getSourceBadge(log.source)}`}>
|
|
{log.source.toUpperCase()}
|
|
</span>
|
|
{log.clientIP && (
|
|
<span className="ml-2 text-cyan-400">{log.clientIP}</span>
|
|
)}
|
|
<span className="ml-2 text-gray-200">{log.message}</span>
|
|
{log.blocked && log.blockReason && (
|
|
<span className="ml-2 text-red-400">[{log.blockReason}]</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-2 border-t border-gray-700 bg-gray-800 text-xs text-gray-400">
|
|
Showing {logs.length} logs {isPaused && <span className="text-yellow-400">⏸ Paused</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 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 && (
|
|
<div className="mt-6">
|
|
<LiveLogViewer
|
|
mode="security"
|
|
securityFilters={{ source: 'cerberus' }}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
#### 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 <IP>` 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
|