Files
Charon/backend/internal/crowdsec/registration.go
GitHub Actions 7da24a2ffb Implement CrowdSec Decision Test Infrastructure
- 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.
2025-12-12 20:33:41 +00:00

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
}