#!/bin/bash ################################################################################ # Script Name: crowdsec-exporter.sh # Version: 1.0 # Description: Prometheus exporter for CrowdSec providing supplementary # operational metrics from cscli commands — active decisions, # alerts, bouncers, machines, hub items, and threat analysis # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Note: CrowdSec has a built-in Prometheus endpoint at port 6060 for internal # metrics (bucket counts, parser hits, etc.). This exporter provides # SUPPLEMENTARY operational metrics from cscli commands. # # Prerequisites: # - CrowdSec installed and running # - cscli command available # - jq for JSON parsing # - Root/sudo access # - netcat (nc) for HTTP mode # - curl for --grab-local mode # # Usage: # # Output to stdout # sudo ./crowdsec-exporter.sh # # # HTTP server mode # sudo ./crowdsec-exporter.sh --http -p 9192 # # # Textfile collector mode # sudo ./crowdsec-exporter.sh --textfile # # Metrics Exported: # - crowdsec_up - Exporter status (1=up, 0=down) # - crowdsec_info{version,exporter_version} - CrowdSec version info # - crowdsec_decisions_active - Total active decisions # - crowdsec_decisions_active_by_type{type} - Active decisions by type # - crowdsec_decisions_active_by_origin{origin} - Active decisions by origin # - crowdsec_decisions_active_by_scenario{scenario} - Active decisions by scenario # - crowdsec_alerts_total - Total alerts # - crowdsec_alerts_per_period{period} - Alerts in 1h/24h # - crowdsec_top_attacker_decisions{ip} - Top 5 IPs by decision count # - crowdsec_top_scenario_alerts{scenario} - Top 5 scenarios by alert count # - crowdsec_bouncer_up{name} - Per-bouncer registered status # - crowdsec_bouncer_last_pull_timestamp{name} - Per-bouncer last pull time # - crowdsec_machine_up{name} - Machine registration status # - crowdsec_lapi_up - LAPI health status # - crowdsec_hub_items{type} - Installed hub items per type # - crowdsec_exporter_duration_seconds - Script execution time # - crowdsec_exporter_last_run_timestamp - Last run timestamp # # Configuration: # Default HTTP port: 9192 # Textfile directory: /var/lib/node_exporter # ################################################################################ # ============================================================================ # CONFIGURATION VARIABLES # ============================================================================ TEXTFILE_DIR="/var/lib/node_exporter" OUTPUT_FILE="" HTTP_MODE=false HTTP_PORT=9192 GRAB_LOCAL=false LOCAL_PORT=6060 # ============================================================================ # HELPER FUNCTIONS # ============================================================================ show_usage() { cat <&2; exit 1 ;; esac done } # Check if CrowdSec is installed and responding # Returns: 0 if OK, 1 if error check_crowdsec() { if ! command -v cscli >/dev/null 2>&1; then echo "ERROR: cscli command not found" >&2 return 1 fi if ! command -v jq >/dev/null 2>&1; then echo "ERROR: jq not found (required for JSON parsing)" >&2 return 1 fi # Verify LAPI is responding if ! cscli lapi status >/dev/null 2>&1; then echo "ERROR: CrowdSec LAPI not responding" >&2 return 1 fi return 0 } # Escape special characters in Prometheus label values # Args: $1 - string to escape # Returns: escaped string safe for Prometheus labels prom_escape() { local val="$1" val="${val//\\/\\\\}" val="${val//\"/\\\"}" val="${val//$'\n'/}" echo "$val" } # Get CrowdSec version string # Returns: version string (e.g., "1.5.4") get_crowdsec_version() { cscli version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 } # Get active decisions as JSON # Returns: JSON array of active decisions, or "null" on error get_decisions_json() { cscli decisions list -o json 2>/dev/null } # Get alerts as JSON # Args: $1 - optional --since parameter (e.g., "1h") # Returns: JSON array of alerts, or "null" on error get_alerts_json() { local since="$1" if [ -n "$since" ]; then cscli alerts list --since "$since" -o json 2>/dev/null else cscli alerts list -o json 2>/dev/null fi } # Get bouncers as JSON # Returns: JSON array of bouncers get_bouncers_json() { cscli bouncers list -o json 2>/dev/null } # Get machines as JSON # Returns: JSON array of machines get_machines_json() { cscli machines list -o json 2>/dev/null } # Check LAPI health # Returns: 1 if healthy, 0 if not get_lapi_status() { if cscli lapi status >/dev/null 2>&1; then echo "1" else echo "0" fi } # Get hub items as JSON # Returns: JSON output from cscli hub list get_hub_json() { cscli hub list -o json 2>/dev/null } # ============================================================================ # METRIC GENERATION # ============================================================================ # Generate all Prometheus metrics # Returns: Prometheus text format metrics on stdout generate_metrics() { local script_start script_start=$(date +%s) # Check CrowdSec status first if ! check_crowdsec; then cat </dev/null) total_decisions=${total_decisions:-0} fi cat </dev/null; then echo "$decisions_json" | jq -r ' group_by(.type) | .[] | "\(.[0].type) \(length)" ' 2>/dev/null | while read -r dtype count; do [ -z "$dtype" ] && continue echo "crowdsec_decisions_active_by_type{type=\"$(prom_escape "$dtype")\"} $count" done fi echo "" # Decisions by origin (crowdsec, cscli, CAPI) cat </dev/null; then echo "$decisions_json" | jq -r ' group_by(.origin) | .[] | "\(.[0].origin) \(length)" ' 2>/dev/null | while read -r origin count; do [ -z "$origin" ] && continue echo "crowdsec_decisions_active_by_origin{origin=\"$(prom_escape "$origin")\"} $count" done fi echo "" # Decisions by scenario cat </dev/null; then echo "$decisions_json" | jq -r ' group_by(.scenario) | .[] | "\(.[0].scenario) \(length)" ' 2>/dev/null | while read -r scenario count; do [ -z "$scenario" ] && continue echo "crowdsec_decisions_active_by_scenario{scenario=\"$(prom_escape "$scenario")\"} $count" done fi echo "" # Top 5 attackers by decision count cat </dev/null; then echo "$decisions_json" | jq -r ' group_by(.value) | map({ip: .[0].value, count: length}) | sort_by(-.count) | .[0:5] | .[] | "\(.ip) \(.count)" ' 2>/dev/null | while read -r ip count; do [ -z "$ip" ] && continue echo "crowdsec_top_attacker_decisions{ip=\"$(prom_escape "$ip")\"} $count" done fi echo "" # ======================================================================== # ALERTS METRICS # ======================================================================== local alerts_json alerts_json=$(get_alerts_json) local total_alerts=0 if [ -n "$alerts_json" ] && [ "$alerts_json" != "null" ]; then total_alerts=$(echo "$alerts_json" | jq 'length' 2>/dev/null) total_alerts=${total_alerts:-0} fi cat </dev/null) alerts_1h=${alerts_1h:-0} fi local alerts_24h=0 if [ -n "$alerts_24h_json" ] && [ "$alerts_24h_json" != "null" ]; then alerts_24h=$(echo "$alerts_24h_json" | jq 'length' 2>/dev/null) alerts_24h=${alerts_24h:-0} fi cat </dev/null; then echo "$alerts_json" | jq -r ' group_by(.scenario) | map({scenario: .[0].scenario, count: length}) | sort_by(-.count) | .[0:5] | .[] | "\(.scenario) \(.count)" ' 2>/dev/null | while read -r scenario count; do [ -z "$scenario" ] && continue echo "crowdsec_top_scenario_alerts{scenario=\"$(prom_escape "$scenario")\"} $count" done fi echo "" # ======================================================================== # BOUNCER METRICS # ======================================================================== local bouncers_json bouncers_json=$(get_bouncers_json) cat </dev/null | while read -r name status; do [ -z "$name" ] && continue echo "crowdsec_bouncer_up{name=\"$(prom_escape "$name")\"} $status" done fi echo "" cat </dev/null | while read -r name last_pull; do [ -z "$name" ] && continue # Convert ISO timestamp to Unix epoch local ts ts=$(date -d "$last_pull" +%s 2>/dev/null || echo "0") echo "crowdsec_bouncer_last_pull_timestamp{name=\"$(prom_escape "$name")\"} $ts" done fi echo "" # ======================================================================== # MACHINE METRICS # ======================================================================== local machines_json machines_json=$(get_machines_json) cat </dev/null | while read -r name status; do [ -z "$name" ] && continue echo "crowdsec_machine_up{name=\"$(prom_escape "$name")\"} $status" done fi echo "" # ======================================================================== # LAPI HEALTH # ======================================================================== local lapi_status lapi_status=$(get_lapi_status) cat </dev/null) parsers=$(echo "$hub_json" | jq '[.parsers // [] | .[] | select(.installed == true)] | length' 2>/dev/null) scenarios=$(echo "$hub_json" | jq '[.scenarios // [] | .[] | select(.installed == true)] | length' 2>/dev/null) postoverflows=$(echo "$hub_json" | jq '[.postoverflows // [] | .[] | select(.installed == true)] | length' 2>/dev/null) echo "crowdsec_hub_items{type=\"collections\"} ${collections:-0}" echo "crowdsec_hub_items{type=\"parsers\"} ${parsers:-0}" echo "crowdsec_hub_items{type=\"scenarios\"} ${scenarios:-0}" echo "crowdsec_hub_items{type=\"postoverflows\"} ${postoverflows:-0}" else echo "crowdsec_hub_items{type=\"collections\"} 0" echo "crowdsec_hub_items{type=\"parsers\"} 0" echo "crowdsec_hub_items{type=\"scenarios\"} 0" echo "crowdsec_hub_items{type=\"postoverflows\"} 0" fi echo "" # ======================================================================== # BUILT-IN METRICS (optional, via --grab-local) # ======================================================================== if [ "$GRAB_LOCAL" = true ]; then local builtin_metrics builtin_metrics=$(curl -sf --max-time 5 "http://localhost:${LOCAL_PORT}/metrics" 2>/dev/null) if [ -n "$builtin_metrics" ]; then echo "# ================================================================" echo "# CrowdSec built-in metrics from localhost:${LOCAL_PORT}" echo "# ================================================================" echo "$builtin_metrics" echo "" else echo "# WARNING: Failed to fetch built-in metrics from localhost:${LOCAL_PORT}" echo "" fi fi # ======================================================================== # EXPORTER RUNTIME # ======================================================================== local script_end script_duration script_end=$(date +%s) script_duration=$((script_end - script_start)) cat <&2 if ! command -v nc >/dev/null 2>&1; then echo "ERROR: netcat (nc) required for HTTP mode" >&2 exit 1 fi # Infinite loop accepting HTTP requests while true; do { read -r request # Check if request is for /metrics endpoint if [[ "$request" =~ ^GET\ /metrics ]]; then echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\n\r" generate_metrics else # Serve HTML landing page for other requests echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r" cat < CrowdSec Exporter v1.0

CrowdSec Prometheus Exporter v1.0

Metrics

Supplementary operational metrics from cscli commands.

For internal CrowdSec metrics (buckets, parsers), see port 6060.

EOF fi } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null done } # ============================================================================ # MAIN EXECUTION # ============================================================================ # Main entry point - routes to appropriate output mode main() { parse_args "$@" if [ "$HTTP_MODE" = true ]; then # Run HTTP server (blocks until killed) run_http_server elif [ -n "$OUTPUT_FILE" ]; then # Textfile collector mode: write atomically using temp file local output_dir output_dir="$(dirname "$OUTPUT_FILE")" mkdir -p "$output_dir" # Create temp file in SAME directory for atomic rename (same filesystem) local temp_file temp_file=$(mktemp "${output_dir}/.crowdsec_metrics.XXXXXX") # Generate metrics to temp file if ! generate_metrics > "$temp_file" 2>/dev/null; then rm -f "$temp_file" echo "ERROR: Failed to generate metrics" >&2 exit 1 fi # Validate: file must exist, have content local file_lines file_lines=$(wc -l < "$temp_file" 2>/dev/null || echo 0) if [ "$file_lines" -lt 10 ]; then rm -f "$temp_file" echo "ERROR: Metrics file too small ($file_lines lines), keeping previous" >&2 exit 1 fi # Set permissions before move chmod 644 "$temp_file" # Atomic rename - no gap where file is missing mv -f "$temp_file" "$OUTPUT_FILE" echo "Metrics written to $OUTPUT_FILE ($file_lines lines)" >&2 else # Default: output to stdout generate_metrics fi } # Execute main function with all script arguments main "$@"