// 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 }