Files
Charon/backend/integration/crowdsec_lapi_integration_test.go
GitHub Actions 0eb0660d41 fix(crowdsec): resolve LAPI "access forbidden" authentication failures
Replace name-based bouncer validation with actual LAPI authentication
testing. The previous implementation checked if a bouncer NAME existed
but never validated if the API KEY was accepted by CrowdSec LAPI.

Key changes:
- Add testKeyAgainstLAPI() with real HTTP authentication against
  /v1/decisions/stream endpoint
- Implement exponential backoff retry (500ms → 5s cap) for transient
  connection errors while failing fast on 403 authentication failures
- Add mutex protection to prevent concurrent registration race conditions
- Use atomic file writes (temp → rename) for key persistence
- Mask API keys in all log output (CWE-312 compliance)

Breaking behavior: Invalid env var keys now auto-recover by registering
a new bouncer instead of failing silently with stale credentials.

Includes temporary acceptance of 7 Debian HIGH CVEs with documented
mitigation plan (Alpine migration in progress - issue #631).
2026-02-04 02:51:52 +00:00

932 lines
29 KiB
Go

//go:build integration
// +build integration
package integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// testConfig holds configuration for LAPI integration tests.
type testConfig struct {
BaseURL string
ContainerName string
Client *http.Client
Cookie []*http.Cookie
}
// newTestConfig creates a test configuration with defaults.
func newTestConfig() *testConfig {
baseURL := os.Getenv("CHARON_TEST_API_URL")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
jar, _ := cookiejar.New(nil)
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
}
return &testConfig{
BaseURL: baseURL,
ContainerName: "charon-e2e",
Client: client,
}
}
// authenticate registers and logs in to get session cookies.
func (tc *testConfig) authenticate(t *testing.T) error {
t.Helper()
// Register (may fail if user exists - that's OK)
registerPayload := map[string]string{
"email": "lapi-test@example.local",
"password": "testpassword123",
"name": "LAPI Tester",
}
payloadBytes, _ := json.Marshal(registerPayload)
_, _ = tc.Client.Post(tc.BaseURL+"/api/v1/auth/register", "application/json", bytes.NewReader(payloadBytes))
// Login
loginPayload := map[string]string{
"email": "lapi-test@example.local",
"password": "testpassword123",
}
payloadBytes, _ = json.Marshal(loginPayload)
resp, err := tc.Client.Post(tc.BaseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("login returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// doRequest performs an authenticated HTTP request.
func (tc *testConfig) doRequest(method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, tc.BaseURL+path, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return tc.Client.Do(req)
}
// waitForAPI waits for the API to be ready.
func (tc *testConfig) waitForAPI(t *testing.T, timeout time.Duration) error {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := tc.Client.Get(tc.BaseURL + "/api/v1/")
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
return nil
}
if resp != nil {
resp.Body.Close()
}
time.Sleep(1 * time.Second)
}
return fmt.Errorf("API not ready after %v", timeout)
}
// waitForLAPIReady polls the status endpoint until LAPI is ready or timeout.
func (tc *testConfig) waitForLAPIReady(t *testing.T, timeout time.Duration) (bool, error) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
if err != nil {
time.Sleep(1 * time.Second)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var status struct {
Running bool `json:"running"`
LapiReady bool `json:"lapi_ready"`
}
if err := json.Unmarshal(body, &status); err == nil {
if status.LapiReady {
return true, nil
}
}
time.Sleep(1 * time.Second)
}
return false, nil
}
// TestCrowdSecLAPIStartup verifies LAPI can be started via API and becomes ready.
//
// Test steps:
// 1. Start CrowdSec via POST /api/v1/admin/crowdsec/start
// 2. Wait for LAPI to initialize (up to 30s with polling)
// 3. Verify: GET /api/v1/admin/crowdsec/status returns lapi_ready: true
// 4. Use the diagnostic endpoint: GET /api/v1/admin/crowdsec/diagnostics/connectivity
func TestCrowdSecLAPIStartup(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Step 1: Start CrowdSec
t.Log("Step 1: Starting CrowdSec via API...")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to call start endpoint: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Start response: %s", string(body))
var startResp struct {
Status string `json:"status"`
PID int `json:"pid"`
LapiReady bool `json:"lapi_ready"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &startResp); err != nil {
t.Logf("Warning: Could not parse start response: %v", err)
}
// Check for expected responses
if resp.StatusCode != http.StatusOK {
// CrowdSec binary may not be available
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
t.Skip("CrowdSec binary not available in container - skipping")
}
t.Logf("Start returned non-200 status: %d - continuing to check status", resp.StatusCode)
}
// Step 2: Wait for LAPI to be ready
t.Log("Step 2: Waiting for LAPI to initialize (up to 30s)...")
lapiReady, _ := tc.waitForLAPIReady(t, 30*time.Second)
// Step 3: Verify status endpoint
t.Log("Step 3: Verifying status endpoint...")
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
if err != nil {
t.Fatalf("Failed to get status: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Status response: %s", string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Status endpoint returned %d", resp.StatusCode)
}
var statusResp struct {
Running bool `json:"running"`
PID int `json:"pid"`
LapiReady bool `json:"lapi_ready"`
}
if err := json.Unmarshal(body, &statusResp); err != nil {
t.Fatalf("Failed to parse status response: %v", err)
}
t.Logf("CrowdSec status: running=%v, pid=%d, lapi_ready=%v", statusResp.Running, statusResp.PID, statusResp.LapiReady)
// Validate: If we managed to start, LAPI should eventually be ready
// If CrowdSec binary is not available, we expect running=false
if statusResp.Running && !statusResp.LapiReady && lapiReady {
t.Error("Expected lapi_ready=true after waiting, but got false")
}
// Step 4: Check diagnostics connectivity endpoint
t.Log("Step 4: Checking diagnostics connectivity endpoint...")
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/connectivity", nil)
if err != nil {
t.Fatalf("Failed to get diagnostics: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Diagnostics connectivity response: %s", string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Diagnostics endpoint returned %d", resp.StatusCode)
}
var diagResp map[string]interface{}
if err := json.Unmarshal(body, &diagResp); err != nil {
t.Fatalf("Failed to parse diagnostics response: %v", err)
}
// Verify expected fields are present
expectedFields := []string{"lapi_running", "lapi_ready", "capi_registered", "console_enrolled"}
for _, field := range expectedFields {
if _, ok := diagResp[field]; !ok {
t.Errorf("Expected field '%s' not found in diagnostics response", field)
}
}
t.Log("TestCrowdSecLAPIStartup completed successfully")
}
// TestCrowdSecLAPIRestartPersistence verifies LAPI can restart and state persists.
//
// Test steps:
// 1. Start CrowdSec
// 2. Record initial state
// 3. Stop CrowdSec via API
// 4. Start CrowdSec again
// 5. Verify LAPI comes back online
// 6. Verify state persists
func TestCrowdSecLAPIRestartPersistence(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Step 1: Start CrowdSec
t.Log("Step 1: Starting CrowdSec...")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to start CrowdSec: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
t.Skip("CrowdSec binary not available in container - skipping")
}
// Wait for LAPI to be ready
lapiReady, _ := tc.waitForLAPIReady(t, 30*time.Second)
t.Logf("Step 2: Initial LAPI ready state: %v", lapiReady)
// Step 3: Stop CrowdSec
t.Log("Step 3: Stopping CrowdSec...")
resp, err = tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil)
if err != nil {
t.Fatalf("Failed to stop CrowdSec: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Stop response: %s", string(body))
// Verify stopped
time.Sleep(2 * time.Second)
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
if err != nil {
t.Fatalf("Failed to get status after stop: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
var statusResp struct {
Running bool `json:"running"`
}
if err := json.Unmarshal(body, &statusResp); err == nil {
t.Logf("Status after stop: running=%v", statusResp.Running)
}
// Step 4: Restart CrowdSec
t.Log("Step 4: Restarting CrowdSec...")
resp, err = tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to restart CrowdSec: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Restart response: %s", string(body))
// Step 5: Verify LAPI comes back online
t.Log("Step 5: Waiting for LAPI to come back online...")
lapiReadyAfterRestart, _ := tc.waitForLAPIReady(t, 30*time.Second)
// Step 6: Verify state
t.Log("Step 6: Verifying state after restart...")
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
if err != nil {
t.Fatalf("Failed to get status after restart: %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Final status: %s", string(body))
var finalStatus struct {
Running bool `json:"running"`
LapiReady bool `json:"lapi_ready"`
}
if err := json.Unmarshal(body, &finalStatus); err != nil {
t.Fatalf("Failed to parse final status: %v", err)
}
// If CrowdSec is available, it should be running after restart
if lapiReady && !lapiReadyAfterRestart {
t.Error("LAPI was ready before stop but not after restart")
}
t.Log("TestCrowdSecLAPIRestartPersistence completed successfully")
}
// TestCrowdSecDiagnosticsConnectivity verifies the connectivity diagnostics endpoint.
//
// Test steps:
// 1. Start CrowdSec
// 2. Call GET /api/v1/admin/crowdsec/diagnostics/connectivity
// 3. Verify response contains all expected fields:
// - lapi_running
// - lapi_ready
// - capi_registered
// - console_enrolled
func TestCrowdSecDiagnosticsConnectivity(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Try to start CrowdSec (may fail if binary not available)
t.Log("Attempting to start CrowdSec...")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err == nil {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Start response: %s", string(body))
// Wait briefly for LAPI
tc.waitForLAPIReady(t, 10*time.Second)
}
// Call diagnostics connectivity endpoint
t.Log("Calling diagnostics connectivity endpoint...")
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/connectivity", nil)
if err != nil {
t.Fatalf("Failed to get diagnostics connectivity: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Diagnostics connectivity response: %s", string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Diagnostics connectivity returned %d", resp.StatusCode)
}
var diagResp map[string]interface{}
if err := json.Unmarshal(body, &diagResp); err != nil {
t.Fatalf("Failed to parse diagnostics response: %v", err)
}
// Verify all required fields are present
requiredFields := []string{
"lapi_running",
"lapi_ready",
"capi_registered",
"console_enrolled",
}
for _, field := range requiredFields {
if _, ok := diagResp[field]; !ok {
t.Errorf("Required field '%s' not found in diagnostics response", field)
} else {
t.Logf("Field '%s': %v", field, diagResp[field])
}
}
// Optional fields that should be present when applicable
optionalFields := []string{
"lapi_pid",
"capi_reachable",
"console_reachable",
"console_status",
"console_agent_name",
}
for _, field := range optionalFields {
if val, ok := diagResp[field]; ok {
t.Logf("Optional field '%s': %v", field, val)
}
}
t.Log("TestCrowdSecDiagnosticsConnectivity completed successfully")
}
// TestCrowdSecDiagnosticsConfig verifies the config diagnostics endpoint.
//
// Test steps:
// 1. Call GET /api/v1/admin/crowdsec/diagnostics/config
// 2. Verify response contains:
// - config_exists
// - acquis_exists
// - lapi_port
// - errors array
func TestCrowdSecDiagnosticsConfig(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Call diagnostics config endpoint
t.Log("Calling diagnostics config endpoint...")
resp, err := tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/config", nil)
if err != nil {
t.Fatalf("Failed to get diagnostics config: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("Diagnostics config response: %s", string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Diagnostics config returned %d", resp.StatusCode)
}
var diagResp map[string]interface{}
if err := json.Unmarshal(body, &diagResp); err != nil {
t.Fatalf("Failed to parse diagnostics response: %v", err)
}
// Verify all required fields are present
requiredFields := []string{
"config_exists",
"acquis_exists",
"lapi_port",
"errors",
}
for _, field := range requiredFields {
if _, ok := diagResp[field]; !ok {
t.Errorf("Required field '%s' not found in diagnostics config response", field)
} else {
t.Logf("Field '%s': %v", field, diagResp[field])
}
}
// Verify errors is an array
if errors, ok := diagResp["errors"]; ok {
if _, isArray := errors.([]interface{}); !isArray {
t.Errorf("Expected 'errors' to be an array, got %T", errors)
}
}
// Optional fields that may be present when configs exist
optionalFields := []string{
"config_valid",
"acquis_valid",
"config_path",
"acquis_path",
}
for _, field := range optionalFields {
if val, ok := diagResp[field]; ok {
t.Logf("Optional field '%s': %v", field, val)
}
}
// Log summary
t.Logf("Config exists: %v, Acquis exists: %v, LAPI port: %v",
diagResp["config_exists"],
diagResp["acquis_exists"],
diagResp["lapi_port"],
)
t.Log("TestCrowdSecDiagnosticsConfig completed successfully")
}
// Helper: execDockerCommand runs a command inside the container and returns output.
func execDockerCommand(containerName string, args ...string) (string, error) {
fullArgs := append([]string{"exec", containerName}, args...)
cmd := exec.Command("docker", fullArgs...)
output, err := cmd.CombinedOutput()
return strings.TrimSpace(string(output)), err
}
// TestBouncerAuth_InvalidEnvKeyAutoRecovers verifies that when an invalid API key is set
// via environment variable, Charon detects the failure and auto-generates a new valid key.
//
// Test Steps:
// 1. Set CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey in environment
// 2. Enable CrowdSec via API
// 3. Verify logs show:
// - "Environment variable CHARON_SECURITY_CROWDSEC_API_KEY is set but invalid"
// - "A new valid key will be generated and saved"
//
// 4. Verify new key auto-generated and saved to file
// 5. Verify Caddy bouncer connects successfully with new key
func TestBouncerAuth_InvalidEnvKeyAutoRecovers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Note: Environment variable must be set in docker-compose.yml before starting container.
// This test assumes CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey is already set.
t.Log("Step 1: Assuming invalid environment variable is set (CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey)")
// Step 2: Enable CrowdSec
t.Log("Step 2: Enabling CrowdSec via API")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to start CrowdSec: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
t.Skip("CrowdSec binary not available - skipping")
}
t.Logf("Start response: %s (continuing despite non-200 status)", string(body))
}
// Wait for LAPI to initialize
tc.waitForLAPIReady(t, 30*time.Second)
// Step 3: Check logs for auto-recovery messages
t.Log("Step 3: Checking container logs for auto-recovery messages")
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
if err != nil {
// Try docker logs command if log file doesn't exist
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
output, _ := cmd.CombinedOutput()
logs = string(output)
}
if !strings.Contains(logs, "Environment variable") && !strings.Contains(logs, "invalid") {
t.Logf("Warning: Expected warning messages not found in logs. This may indicate env var was not set before container start.")
t.Logf("Logs (last 500 chars): %s", logs[max(0, len(logs)-500):])
}
// Step 4: Verify key file exists and contains a valid key
t.Log("Step 4: Verifying bouncer key file exists")
keyFilePath := "/app/data/crowdsec/bouncer_key"
generatedKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
if err != nil {
t.Fatalf("Failed to read bouncer key file: %v", err)
}
if generatedKey == "" {
t.Fatal("Bouncer key file is empty")
}
if generatedKey == "fakeinvalidkey" {
t.Fatal("Key should be regenerated, not the invalid env var")
}
t.Logf("Generated key (masked): %s...%s", generatedKey[:min(4, len(generatedKey))], generatedKey[max(0, len(generatedKey)-4):])
// Step 5: Verify Caddy bouncer can authenticate with generated key
t.Log("Step 5: Verifying Caddy bouncer authentication with generated key")
lapiURL := tc.BaseURL // LAPI is on same host in test environment
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
if err != nil {
t.Fatalf("Failed to create LAPI request: %v", err)
}
req.Header.Set("X-Api-Key", generatedKey)
client := &http.Client{Timeout: 10 * time.Second}
decisionsResp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to query LAPI: %v", err)
}
defer decisionsResp.Body.Close()
if decisionsResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(decisionsResp.Body)
t.Fatalf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
}
t.Log("✅ Auto-recovery from invalid env var successful")
}
// TestBouncerAuth_ValidEnvKeyPreserved verifies that when a valid API key is set
// via environment variable, it is used without triggering new registration.
//
// Test Steps:
// 1. Pre-register bouncer with cscli
// 2. Note: Registered key must be set as CHARON_SECURITY_CROWDSEC_API_KEY before starting container
// 3. Enable CrowdSec
// 4. Verify logs show "source=environment_variable"
// 5. Verify no duplicate bouncer registration
// 6. Verify authentication works with env key
func TestBouncerAuth_ValidEnvKeyPreserved(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Step 1: Pre-register bouncer (if not already registered)
t.Log("Step 1: Checking if bouncer is pre-registered")
listOutput, err := execDockerCommand(tc.ContainerName, "cscli", "bouncers", "list", "-o", "json")
if err != nil {
t.Logf("Failed to list bouncers: %v (this is expected if CrowdSec not fully initialized)", err)
}
bouncerExists := strings.Contains(listOutput, `"name":"caddy-bouncer"`)
t.Logf("Bouncer exists: %v", bouncerExists)
// Step 2: Note - Environment variable must be set in docker-compose.yml with the registered key
t.Log("Step 2: Assuming valid environment variable is set (must match pre-registered key)")
// Step 3: Enable CrowdSec
t.Log("Step 3: Enabling CrowdSec via API")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to start CrowdSec: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
t.Skip("CrowdSec binary not available - skipping")
}
t.Logf("Start response: %s (continuing)", string(body))
}
// Wait for LAPI
tc.waitForLAPIReady(t, 30*time.Second)
// Step 4: Check logs for environment variable source
t.Log("Step 4: Checking logs for env var source indicator")
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
if err != nil {
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
output, _ := cmd.CombinedOutput()
logs = string(output)
}
if !strings.Contains(logs, "source=environment_variable") {
t.Logf("Warning: Expected 'source=environment_variable' not found in logs")
t.Logf("This may indicate the env var was not set before container start")
}
// Step 5: Verify no duplicate bouncer registration
t.Log("Step 5: Verifying no duplicate bouncer registration")
listOutputAfter, err := execDockerCommand(tc.ContainerName, "cscli", "bouncers", "list", "-o", "json")
if err == nil {
bouncerCount := strings.Count(listOutputAfter, `"name":"caddy-bouncer"`)
if bouncerCount > 1 {
t.Errorf("Expected exactly 1 bouncer, found %d duplicates", bouncerCount)
}
t.Logf("Bouncer count: %d (expected 1)", bouncerCount)
}
// Step 6: Verify authentication works
t.Log("Step 6: Verifying authentication (key must be set correctly in env)")
keyFromFile, err := execDockerCommand(tc.ContainerName, "cat", "/app/data/crowdsec/bouncer_key")
if err != nil {
t.Logf("Could not read key file: %v", err)
return // Cannot verify without key
}
lapiURL := tc.BaseURL
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
if err != nil {
t.Fatalf("Failed to create LAPI request: %v", err)
}
req.Header.Set("X-Api-Key", strings.TrimSpace(keyFromFile))
client := &http.Client{Timeout: 10 * time.Second}
decisionsResp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to query LAPI: %v", err)
}
defer decisionsResp.Body.Close()
if decisionsResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(decisionsResp.Body)
t.Errorf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
} else {
t.Log("✅ Valid environment variable preserved successfully")
}
}
// TestBouncerAuth_FileKeyPersistsAcrossRestarts verifies that an auto-generated key
// is saved to file and reused across container restarts.
//
// Test Steps:
// 1. Clear any existing key file
// 2. Enable CrowdSec (triggers auto-generation)
// 3. Read generated key from file
// 4. Restart Charon container
// 5. Verify same key is still in file
// 6. Verify logs show "source=file"
// 7. Verify authentication works with persisted key
func TestBouncerAuth_FileKeyPersistsAcrossRestarts(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tc := newTestConfig()
// Wait for API to be ready
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Skipf("API not available, skipping test: %v", err)
}
// Authenticate
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed: %v", err)
}
// Step 1: Clear key file (note: requires container to be started without env var set)
t.Log("Step 1: Clearing key file")
keyFilePath := "/app/data/crowdsec/bouncer_key"
_, _ = execDockerCommand(tc.ContainerName, "rm", "-f", keyFilePath) // Ignore error if file doesn't exist
// Step 2: Enable CrowdSec to trigger key auto-generation
t.Log("Step 2: Enabling CrowdSec to trigger key auto-generation")
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
if err != nil {
t.Fatalf("Failed to start CrowdSec: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
t.Skip("CrowdSec binary not available - skipping")
}
}
// Wait for LAPI and key generation
tc.waitForLAPIReady(t, 30*time.Second)
time.Sleep(5 * time.Second) // Allow time for key file creation
// Step 3: Read generated key
t.Log("Step 3: Reading generated key from file")
originalKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
if err != nil {
t.Fatalf("Failed to read bouncer key file after generation: %v", err)
}
if originalKey == "" {
t.Fatal("Bouncer key file is empty after generation")
}
t.Logf("Original key (masked): %s...%s", originalKey[:min(4, len(originalKey))], originalKey[max(0, len(originalKey)-4):])
// Step 4: Restart container
t.Log("Step 4: Restarting Charon container")
cmd := exec.Command("docker", "restart", tc.ContainerName)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to restart container: %v, output: %s", err, string(output))
}
// Wait for container to come back up
time.Sleep(10 * time.Second)
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
t.Fatalf("API not available after restart: %v", err)
}
// Re-authenticate after restart
if err := tc.authenticate(t); err != nil {
t.Fatalf("Authentication failed after restart: %v", err)
}
// Step 5: Verify same key persisted
t.Log("Step 5: Verifying key persisted after restart")
persistedKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
if err != nil {
t.Fatalf("Failed to read bouncer key file after restart: %v", err)
}
if persistedKey != originalKey {
t.Errorf("Key changed after restart. Original: %s...%s, After: %s...%s",
originalKey[:4], originalKey[len(originalKey)-4:],
persistedKey[:min(4, len(persistedKey))], persistedKey[max(0, len(persistedKey)-4):])
}
// Step 6: Verify logs show file source
t.Log("Step 6: Checking logs for file source indicator")
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
if err != nil {
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
output, _ := cmd.CombinedOutput()
logs = string(output)
}
if !strings.Contains(logs, "source=file") {
t.Logf("Warning: Expected 'source=file' not found in logs after restart")
}
// Step 7: Verify authentication with persisted key
t.Log("Step 7: Verifying authentication with persisted key")
lapiURL := tc.BaseURL
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
if err != nil {
t.Fatalf("Failed to create LAPI request: %v", err)
}
req.Header.Set("X-Api-Key", persistedKey)
client := &http.Client{Timeout: 10 * time.Second}
decisionsResp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to query LAPI: %v", err)
}
defer decisionsResp.Body.Close()
if decisionsResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(decisionsResp.Body)
t.Fatalf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
}
t.Log("✅ File key persistence across restarts successful")
}
// Helper: min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Helper: max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}