- Added integration test script `crowdsec_decision_integration.sh` for verifying CrowdSec decision management functionality. - Created QA report for the CrowdSec decision management integration test infrastructure, detailing file verification, validation results, and overall status. - Included comprehensive test cases for starting CrowdSec, managing IP bans, and checking API responses. - Ensured proper logging, error handling, and cleanup procedures within the test script. - Verified syntax, security, and functionality of all related files.
253 lines
7.3 KiB
Go
253 lines
7.3 KiB
Go
// Package crowdsec provides integration with CrowdSec for security decisions and remediation.
|
|
package crowdsec
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// defaultLAPIURL is the default CrowdSec LAPI URL.
|
|
// Port 8085 is used to avoid conflict with Charon management API on port 8080.
|
|
defaultLAPIURL = "http://127.0.0.1:8085"
|
|
defaultHealthTimeout = 5 * time.Second
|
|
defaultRegistrationName = "caddy-bouncer"
|
|
)
|
|
|
|
// BouncerRegistration holds information about a registered bouncer.
|
|
type BouncerRegistration struct {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
Valid bool `json:"valid"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// LAPIHealthResponse represents the health check response from CrowdSec LAPI.
|
|
type LAPIHealthResponse struct {
|
|
Message string `json:"message,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
}
|
|
|
|
// EnsureBouncerRegistered checks if a caddy bouncer is registered with CrowdSec LAPI.
|
|
// If not registered and cscli is available, it will attempt to register one.
|
|
// Returns the API key for the bouncer (from env var or newly registered).
|
|
func EnsureBouncerRegistered(ctx context.Context, lapiURL string) (string, error) {
|
|
// First check if API key is provided via environment
|
|
apiKey := getBouncerAPIKey()
|
|
if apiKey != "" {
|
|
return apiKey, nil
|
|
}
|
|
|
|
// Check if cscli is available
|
|
if !hasCSCLI() {
|
|
return "", fmt.Errorf("no API key provided and cscli not available for bouncer registration")
|
|
}
|
|
|
|
// Check if bouncer already exists
|
|
existing, err := getExistingBouncer(ctx, defaultRegistrationName)
|
|
if err == nil && existing.APIKey != "" {
|
|
return existing.APIKey, nil
|
|
}
|
|
|
|
// Register new bouncer using cscli
|
|
return registerBouncer(ctx, defaultRegistrationName)
|
|
}
|
|
|
|
// CheckLAPIHealth verifies CrowdSec LAPI is responding.
|
|
func CheckLAPIHealth(lapiURL string) bool {
|
|
if lapiURL == "" {
|
|
lapiURL = defaultLAPIURL
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultHealthTimeout)
|
|
defer cancel()
|
|
|
|
// Try the /health endpoint first (standard LAPI health check)
|
|
healthURL := strings.TrimRight(lapiURL, "/") + "/health"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, http.NoBody)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
client := &http.Client{Timeout: defaultHealthTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// Fallback: try the /v1/decisions endpoint with a HEAD request
|
|
return checkDecisionsEndpoint(ctx, lapiURL)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check content-type to ensure we're getting JSON from actual LAPI (not HTML from frontend)
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType != "" && !strings.Contains(contentType, "application/json") {
|
|
// Not JSON response, likely hitting a frontend/proxy
|
|
return false
|
|
}
|
|
|
|
// LAPI returns 200 OK for healthy status
|
|
if resp.StatusCode == http.StatusOK {
|
|
return true
|
|
}
|
|
|
|
// If health endpoint returned non-OK, try decisions endpoint fallback
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return checkDecisionsEndpoint(ctx, lapiURL)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetLAPIVersion retrieves the CrowdSec LAPI version.
|
|
func GetLAPIVersion(ctx context.Context, lapiURL string) (string, error) {
|
|
if lapiURL == "" {
|
|
lapiURL = defaultLAPIURL
|
|
}
|
|
|
|
versionURL := strings.TrimRight(lapiURL, "/") + "/v1/version"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, http.NoBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create version request: %w", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: defaultHealthTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("version request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("version request returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read version response: %w", err)
|
|
}
|
|
|
|
var versionResp struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(body, &versionResp); err != nil {
|
|
// Some versions return plain text
|
|
return strings.TrimSpace(string(body)), nil
|
|
}
|
|
|
|
return versionResp.Version, nil
|
|
}
|
|
|
|
// checkDecisionsEndpoint is a fallback health check using the decisions endpoint.
|
|
func checkDecisionsEndpoint(ctx context.Context, lapiURL string) bool {
|
|
decisionsURL := strings.TrimRight(lapiURL, "/") + "/v1/decisions"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, decisionsURL, http.NoBody)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
client := &http.Client{Timeout: defaultHealthTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check content-type to avoid false positives from HTML responses
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType != "" && !strings.Contains(contentType, "application/json") {
|
|
// Not JSON response, likely hitting a frontend/proxy
|
|
return false
|
|
}
|
|
|
|
// 401 is expected without auth, but indicates LAPI is running
|
|
// 200 with empty array is also valid (no decisions)
|
|
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized
|
|
}
|
|
|
|
// getBouncerAPIKey returns the bouncer API key from environment variables.
|
|
func getBouncerAPIKey() string {
|
|
// Check multiple possible env var names for the API key
|
|
envVars := []string{
|
|
"CROWDSEC_API_KEY",
|
|
"CROWDSEC_BOUNCER_API_KEY",
|
|
"CERBERUS_SECURITY_CROWDSEC_API_KEY",
|
|
"CHARON_SECURITY_CROWDSEC_API_KEY",
|
|
"CPM_SECURITY_CROWDSEC_API_KEY",
|
|
}
|
|
|
|
for _, key := range envVars {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hasCSCLI checks if cscli command is available.
|
|
func hasCSCLI() bool {
|
|
_, err := exec.LookPath("cscli")
|
|
return err == nil
|
|
}
|
|
|
|
// getExistingBouncer retrieves an existing bouncer registration by name.
|
|
func getExistingBouncer(ctx context.Context, name string) (BouncerRegistration, error) {
|
|
cmd := exec.CommandContext(ctx, "cscli", "bouncers", "list", "-o", "json")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return BouncerRegistration{}, fmt.Errorf("list bouncers: %w", err)
|
|
}
|
|
|
|
var bouncers []struct {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
IPAddress string `json:"ip_address"`
|
|
Valid bool `json:"valid"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &bouncers); err != nil {
|
|
return BouncerRegistration{}, fmt.Errorf("parse bouncers: %w", err)
|
|
}
|
|
|
|
for _, b := range bouncers {
|
|
if b.Name == name {
|
|
var createdAt time.Time
|
|
if b.CreatedAt != "" {
|
|
createdAt, _ = time.Parse(time.RFC3339, b.CreatedAt)
|
|
}
|
|
return BouncerRegistration{
|
|
Name: b.Name,
|
|
APIKey: b.APIKey,
|
|
IPAddress: b.IPAddress,
|
|
Valid: b.Valid,
|
|
CreatedAt: createdAt,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return BouncerRegistration{}, fmt.Errorf("bouncer %q not found", name)
|
|
}
|
|
|
|
// registerBouncer registers a new bouncer with CrowdSec using cscli.
|
|
func registerBouncer(ctx context.Context, name string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "cscli", "bouncers", "add", name, "-o", "raw")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("register bouncer: %w", err)
|
|
}
|
|
|
|
apiKey := strings.TrimSpace(string(output))
|
|
if apiKey == "" {
|
|
return "", fmt.Errorf("empty API key returned from bouncer registration")
|
|
}
|
|
|
|
return apiKey, nil
|
|
}
|