Files
Charon/docs/plans/crowdsec_bouncer_auto_registration.md
GitHub Actions c9965bb45b feat: Add CrowdSec Bouncer Key Display component and integrate into Security page
- Implemented CrowdSecBouncerKeyDisplay component to fetch and display the bouncer API key information.
- Added loading skeletons and error handling for API requests.
- Integrated the new component into the Security page, conditionally rendering it based on CrowdSec status.
- Created unit tests for the CrowdSecBouncerKeyDisplay component, covering various states including loading, registered/unregistered bouncer, and no key configured.
- Added functional tests for the Security page to ensure proper rendering of the CrowdSec Bouncer Key Display based on the CrowdSec status.
- Updated translation files to include new keys related to the bouncer API key functionality.
2026-02-03 21:07:16 +00:00

42 KiB

CrowdSec Bouncer Auto-Registration and Key Persistence

Status: Ready for Implementation Priority: P1 (User Experience Enhancement) Created: 2026-02-03 Estimated Effort: 8-12 hours


Executive Summary

CrowdSec bouncer integration currently requires manual key generation and configuration, which is error-prone and leads to "access forbidden" errors when users set invented (non-registered) keys. This plan implements automatic bouncer registration with persistent key storage, eliminating manual key management.

Goals

  1. Zero-config CrowdSec: Users enable CrowdSec via GUI toggle without touching API keys
  2. Self-healing: System auto-registers and stores valid keys, recovers from invalid state
  3. Transparency: Users can view their bouncer key in the UI if needed for external tools
  4. Backward Compatibility: Existing env-var overrides continue to work

Current State Analysis

How It Works Today

┌─────────────────────────────────────────────────────────────────────────────┐
│                      Current Flow (Manual, Error-Prone)                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. User sets docker-compose.yml:                                            │
│     CHARON_SECURITY_CROWDSEC_API_KEY=invented-key-that-doesnt-work           │
│     ❌ PROBLEM: Key is NOT registered with CrowdSec                          │
│                                                                              │
│  2. Container starts:                                                        │
│     docker-entrypoint.sh runs:                                               │
│     └─► cscli machines add -a --force (machine only, NOT bouncer)           │
│                                                                              │
│  3. User enables CrowdSec via GUI:                                           │
│     POST /api/v1/admin/crowdsec/start                                        │
│     └─► Start crowdsec process                                              │
│     └─► Wait for LAPI ready                                                 │
│     └─► ❌ NO bouncer registration                                           │
│                                                                              │
│  4. Caddy config generation:                                                 │
│     getCrowdSecAPIKey() returns "invented-key-that-doesnt-work"              │
│     └─► Reads from CHARON_SECURITY_CROWDSEC_API_KEY env var                 │
│     └─► ❌ No file fallback, no validation                                   │
│                                                                              │
│  5. CrowdSec bouncer connects:                                               │
│     └─► Sends invalid key to LAPI                                           │
│     └─► ❌ "access forbidden" error                                          │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Key Files Involved

File Purpose Current Behavior
.docker/docker-entrypoint.sh Container startup Registers machine only, not bouncer
configs/crowdsec/register_bouncer.sh Bouncer registration script Exists but never called automatically
backend/internal/caddy/config.go getCrowdSecAPIKey() Reads env vars only, no file fallback
backend/internal/api/handlers/crowdsec_handler.go Start() handler Starts CrowdSec but doesn't register bouncer
backend/internal/crowdsec/registration.go Registration utilities Has helpers but not integrated into startup

Current Key Storage Locations

Location Used For Current State
CHARON_SECURITY_CROWDSEC_API_KEY env var User-provided override Only source checked
/etc/crowdsec/bouncers/caddy-bouncer.key Generated key file Written by script but never read
/app/data/crowdsec/ Persistent volume Not used for key storage

Proposed Design

New Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                       Proposed Flow (Automatic, Self-Healing)               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. Container starts:                                                        │
│     docker-entrypoint.sh runs:                                               │
│     └─► cscli machines add -a --force                                        │
│     └─► 🆕 mkdir -p /app/data/crowdsec                                       │
│     └─► 🆕 Check for env var key and validate if present                     │
│                                                                              │
│  2. User enables CrowdSec via GUI:                                           │
│     POST /api/v1/admin/crowdsec/start                                        │
│     └─► Start crowdsec process                                              │
│     └─► Wait for LAPI ready                                                 │
│     └─► 🆕 ensureBouncerRegistered()                                         │
│         ├─► Check env var key → if set AND valid → use it                  │
│         ├─► Check file key → if exists AND valid → use it                  │
│         ├─► Otherwise → register new bouncer                                │
│         └─► Save valid key to /app/data/crowdsec/bouncer_key                │
│     └─► 🆕 Log key to container logs (for user reference)                   │
│     └─► 🆕 Regenerate Caddy config with valid key                           │
│                                                                              │
│  3. Caddy config generation:                                                 │
│     getCrowdSecAPIKey() with file fallback:                                  │
│     └─► Check env vars (priority 1)                                         │
│     └─► Check /app/data/crowdsec/bouncer_key (priority 2)                   │
│     └─► Return empty string if neither exists                               │
│                                                                              │
│  4. CrowdSec bouncer connects:                                               │
│     └─► Uses validated/registered key                                       │
│     └─► ✅ Connection successful                                             │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Key Persistence Location

Primary location: /app/data/crowdsec/bouncer_key

  • Inside the persistent volume mount (/app/data)
  • Survives container rebuilds
  • Protected permissions (600)
  • Contains plain text API key

Symlink for compatibility: /etc/crowdsec/bouncers/caddy-bouncer.key/app/data/crowdsec/bouncer_key

  • Maintains compatibility with register_bouncer.sh script

Key Priority Order

When determining which API key to use:

┌────────────────────────────────────────────────────────────────┐
│                    API Key Resolution Order                     │
├────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. Environment Variables (HIGHEST PRIORITY)                    │
│     ├─► CROWDSEC_API_KEY                                        │
│     ├─► CROWDSEC_BOUNCER_API_KEY                                │
│     ├─► CERBERUS_SECURITY_CROWDSEC_API_KEY                      │
│     ├─► CHARON_SECURITY_CROWDSEC_API_KEY                        │
│     └─► CPM_SECURITY_CROWDSEC_API_KEY                           │
│     IF env var set → validate key → use if valid                │
│     IF env var set but INVALID → log warning → continue search  │
│                                                                 │
│  2. Persistent Key File                                         │
│     /app/data/crowdsec/bouncer_key                              │
│     IF file exists → validate key → use if valid                │
│                                                                 │
│  3. Auto-Registration                                           │
│     IF no valid key found → register new bouncer                │
│     → save to /app/data/crowdsec/bouncer_key                    │
│     → use new key                                               │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

Requirements (EARS Notation)

Must Have (P0)

REQ-1: Auto-Registration on CrowdSec Start

WHEN CrowdSec is started via GUI or API and no valid bouncer key exists, THE SYSTEM SHALL automatically register a bouncer with CrowdSec LAPI.

Acceptance Criteria:

  • Bouncer registration occurs after LAPI is confirmed ready
  • Registration uses cscli bouncers add caddy-bouncer -o raw
  • Generated key is captured and stored
  • No user intervention required

REQ-2: Key Persistence to Volume

WHEN a bouncer key is generated or validated, THE SYSTEM SHALL persist it to /app/data/crowdsec/bouncer_key.

Acceptance Criteria:

  • Key file created with permissions 600
  • Key survives container restart/rebuild
  • Key file contains plain text API key (no JSON wrapper)
  • Key file owned by charon:charon user

REQ-3: Key Logging for User Reference

WHEN a new bouncer key is generated, THE SYSTEM SHALL log the key to container logs with clear instructions.

Acceptance Criteria:

  • Log message includes full API key
  • Log message explains where key is stored
  • Log message advises user to copy key if using external CrowdSec
  • Log level is INFO (not DEBUG)

Log Format:

════════════════════════════════════════════════════════════════════
🔐 CrowdSec Bouncer Registered Successfully
────────────────────────────────────────────────────────────────────
Bouncer Name: caddy-bouncer
API Key:      PNoOaOwrUZgSN9nuYuk9BdnCqpp6xLrdXcZwwCh2GSs
Saved To:     /app/data/crowdsec/bouncer_key
────────────────────────────────────────────────────────────────────
💡 TIP: If connecting to an EXTERNAL CrowdSec instance, copy this
   key to your docker-compose.yml as CHARON_SECURITY_CROWDSEC_API_KEY
════════════════════════════════════════════════════════════════════

REQ-4: File Fallback for API Key

WHEN no API key is provided via environment variable, THE SYSTEM SHALL read the key from /app/data/crowdsec/bouncer_key.

Acceptance Criteria:

  • getCrowdSecAPIKey() checks file after env vars
  • Empty/whitespace-only files are treated as no key
  • File read errors are logged but don't crash

REQ-5: Environment Variable Priority

WHEN both file key and env var key exist, THE SYSTEM SHALL prefer the environment variable.

Acceptance Criteria:

  • Env var always takes precedence
  • Log message indicates env var override is active
  • File key is still validated for future use

Should Have (P1)

REQ-6: Key Validation on Startup

WHEN CrowdSec starts with an existing API key, THE SYSTEM SHALL validate the key is registered with LAPI before using it.

Acceptance Criteria:

  • Validation uses cscli bouncers list to check registration
  • Invalid keys trigger warning log
  • Invalid keys trigger auto-registration

Validation Flow:

1. Check if 'caddy-bouncer' exists in cscli bouncers list
2. If exists → key is valid → proceed
3. If not exists → key is invalid → re-register

REQ-7: Auto-Heal Invalid Keys

WHEN a configured API key is not registered with CrowdSec, THE SYSTEM SHALL delete the bouncer and re-register to get a valid key.

Acceptance Criteria:

  • Stale bouncer entries are deleted before re-registration
  • New key is saved to file
  • Log warning about invalid key being replaced
  • Caddy config regenerated with new key

REQ-8: Display Key in UI

WHEN viewing CrowdSec settings in the Security dashboard, THE SYSTEM SHALL display the current bouncer API key (masked with copy button).

Acceptance Criteria:

  • Key displayed as PNoO...GSs (first 4, last 3 chars)
  • Copy button copies full key to clipboard
  • Toast notification confirms copy
  • Key only shown when CrowdSec is enabled

UI Mockup:

┌─────────────────────────────────────────────────────────────────┐
│ CrowdSec Integration                              [Enabled ✓]  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Status: Running (PID: 12345)                                  │
│  LAPI: http://127.0.0.1:8085 (Healthy)                         │
│                                                                 │
│  Bouncer API Key                                               │
│  ┌─────────────────────────────────────────────────────────┐  │
│  │ PNoO...GSs                                    [📋 Copy] │  │
│  └─────────────────────────────────────────────────────────┘  │
│  Key stored at: /app/data/crowdsec/bouncer_key                 │
│                                                                 │
│  [Configure CrowdSec →]  [View Decisions]  [Manage Bouncers]  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Nice to Have (P2)

REQ-9: Key Rotation

WHEN user clicks "Rotate Key" button in UI, THE SYSTEM SHALL delete the old bouncer, register a new one, and update configuration.

Acceptance Criteria:

  • Confirmation dialog before rotation
  • Old bouncer deleted from CrowdSec
  • New bouncer registered
  • New key saved to file
  • Caddy config regenerated
  • Log new key to container logs

REQ-10: External CrowdSec Support

WHEN CHARON_SECURITY_CROWDSEC_MODE=remote is set, THE SYSTEM SHALL skip auto-registration and require manual key configuration.

Acceptance Criteria:

  • Auto-registration disabled when mode=remote
  • Clear error message if no key provided for remote mode
  • Documentation updated with remote setup instructions

Technical Design

Implementation Overview

Phase Component Changes
1 Backend Add ensureBouncerRegistered() to CrowdSec Start handler
2 Backend Update getCrowdSecAPIKey() with file fallback
3 Backend Add bouncer validation and auto-heal logic
4 Backend Add API endpoint to get bouncer key info
5 Frontend Add bouncer key display to CrowdSec settings
6 Entrypoint Create key persistence directory on startup

Phase 1: Bouncer Auto-Registration

File: backend/internal/api/handlers/crowdsec_handler.go

Changes to Start() method (after LAPI readiness check, ~line 290):

// After confirming LAPI is ready, ensure bouncer is registered
if lapiReady {
    // Register bouncer if needed (idempotent)
    apiKey, regErr := h.ensureBouncerRegistration(ctx)
    if regErr != nil {
        logger.Log().WithError(regErr).Warn("Failed to register bouncer, CrowdSec may not enforce decisions")
    } else if apiKey != "" {
        // Log the key for user reference
        h.logBouncerKeyBanner(apiKey)

        // Regenerate Caddy config with new API key
        if h.CaddyManager != nil {
            if err := h.CaddyManager.ReloadConfig(ctx); err != nil {
                logger.Log().WithError(err).Warn("Failed to reload Caddy config with new bouncer key")
            }
        }
    }
}

New method ensureBouncerRegistration():

const (
    bouncerKeyFile = "/app/data/crowdsec/bouncer_key"
    bouncerName    = "caddy-bouncer"
)

// ensureBouncerRegistration checks if bouncer is registered and registers if needed.
// Returns the API key if newly generated (empty if already set via env var).
func (h *CrowdsecHandler) ensureBouncerRegistration(ctx context.Context) (string, error) {
    // Priority 1: Check environment variables
    envKey := getBouncerAPIKeyFromEnv()
    if envKey != "" {
        if h.validateBouncerKey(ctx) {
            logger.Log().Info("Using CrowdSec API key from environment variable")
            return "", nil // Key valid, nothing new to report
        }
        logger.Log().Warn("Env-provided CrowdSec API key is invalid or bouncer not registered, will re-register")
    }

    // Priority 2: Check persistent key file
    fileKey := readKeyFromFile(bouncerKeyFile)
    if fileKey != "" {
        if h.validateBouncerKey(ctx) {
            logger.Log().WithField("file", bouncerKeyFile).Info("Using CrowdSec API key from file")
            return "", nil // Key valid
        }
        logger.Log().WithField("file", bouncerKeyFile).Warn("File API key is invalid, will re-register")
    }

    // No valid key found - register new bouncer
    return h.registerAndSaveBouncer(ctx)
}

// validateBouncerKey checks if 'caddy-bouncer' is registered with CrowdSec.
func (h *CrowdsecHandler) validateBouncerKey(ctx context.Context) bool {
    checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    output, err := h.CmdExec.Execute(checkCtx, "cscli", "bouncers", "list", "-o", "json")
    if err != nil {
        logger.Log().WithError(err).Debug("Failed to list bouncers")
        return false
    }

    var bouncers []struct {
        Name string `json:"name"`
    }
    if err := json.Unmarshal(output, &bouncers); err != nil {
        return false
    }

    for _, b := range bouncers {
        if b.Name == bouncerName {
            return true
        }
    }
    return false
}

// registerAndSaveBouncer registers a new bouncer and saves the key to file.
func (h *CrowdsecHandler) registerAndSaveBouncer(ctx context.Context) (string, error) {
    // Delete existing bouncer if present (stale registration)
    deleteCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    h.CmdExec.Execute(deleteCtx, "cscli", "bouncers", "delete", bouncerName)
    cancel()

    // Register new bouncer
    regCtx, regCancel := context.WithTimeout(ctx, 10*time.Second)
    defer regCancel()

    output, err := h.CmdExec.Execute(regCtx, "cscli", "bouncers", "add", bouncerName, "-o", "raw")
    if err != nil {
        return "", fmt.Errorf("bouncer registration failed: %w: %s", err, string(output))
    }

    apiKey := strings.TrimSpace(string(output))
    if apiKey == "" {
        return "", fmt.Errorf("bouncer registration returned empty API key")
    }

    // Save key to persistent file
    if err := saveKeyToFile(bouncerKeyFile, apiKey); err != nil {
        logger.Log().WithError(err).Warn("Failed to save bouncer key to file")
        // Continue - key is still valid for this session
    }

    return apiKey, nil
}

// logBouncerKeyBanner logs the bouncer key with a formatted banner.
func (h *CrowdsecHandler) logBouncerKeyBanner(apiKey string) {
    banner := `
════════════════════════════════════════════════════════════════════
🔐 CrowdSec Bouncer Registered Successfully
────────────────────────────────────────────────────────────────────
Bouncer Name: %s
API Key:      %s
Saved To:     %s
────────────────────────────────────────────────────────────────────
💡 TIP: If connecting to an EXTERNAL CrowdSec instance, copy this
   key to your docker-compose.yml as CHARON_SECURITY_CROWDSEC_API_KEY
════════════════════════════════════════════════════════════════════`
    logger.Log().Infof(banner, bouncerName, apiKey, bouncerKeyFile)
}

// Helper functions
func getBouncerAPIKeyFromEnv() string {
    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 ""
}

func readKeyFromFile(path string) string {
    data, err := os.ReadFile(path)
    if err != nil {
        return ""
    }
    return strings.TrimSpace(string(data))
}

func saveKeyToFile(path string, key string) error {
    dir := filepath.Dir(path)
    if err := os.MkdirAll(dir, 0750); err != nil {
        return fmt.Errorf("create directory: %w", err)
    }

    if err := os.WriteFile(path, []byte(key+"\n"), 0600); err != nil {
        return fmt.Errorf("write key file: %w", err)
    }

    return nil
}

Phase 2: Update getCrowdSecAPIKey() with File Fallback

File: backend/internal/caddy/config.go

Replace current getCrowdSecAPIKey() implementation (~line 1129):

// getCrowdSecAPIKey retrieves the CrowdSec bouncer API key.
// Priority: environment variables > persistent key file
func getCrowdSecAPIKey() string {
    // Priority 1: Check environment variables
    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
        }
    }

    // Priority 2: Check persistent key file
    const bouncerKeyFile = "/app/data/crowdsec/bouncer_key"
    if data, err := os.ReadFile(bouncerKeyFile); err == nil {
        key := strings.TrimSpace(string(data))
        if key != "" {
            return key
        }
    }

    return ""
}

Phase 3: Add Bouncer Key Info API Endpoint

File: backend/internal/api/handlers/crowdsec_handler.go

New endpoint: GET /api/v1/admin/crowdsec/bouncer

// BouncerInfo represents the bouncer key information for UI display.
type BouncerInfo struct {
    Name       string `json:"name"`
    KeyPreview string `json:"key_preview"` // First 4 + last 3 chars
    KeySource  string `json:"key_source"`  // "env_var" | "file" | "none"
    FilePath   string `json:"file_path"`
    Registered bool   `json:"registered"`
}

// GetBouncerInfo returns information about the current bouncer key.
// GET /api/v1/admin/crowdsec/bouncer
func (h *CrowdsecHandler) GetBouncerInfo(c *gin.Context) {
    ctx := c.Request.Context()

    info := BouncerInfo{
        Name:     bouncerName,
        FilePath: bouncerKeyFile,
    }

    // Determine key source
    envKey := getBouncerAPIKeyFromEnv()
    fileKey := readKeyFromFile(bouncerKeyFile)

    var fullKey string
    if envKey != "" {
        info.KeySource = "env_var"
        fullKey = envKey
    } else if fileKey != "" {
        info.KeySource = "file"
        fullKey = fileKey
    } else {
        info.KeySource = "none"
    }

    // Generate preview
    if fullKey != "" && len(fullKey) > 7 {
        info.KeyPreview = fullKey[:4] + "..." + fullKey[len(fullKey)-3:]
    } else if fullKey != "" {
        info.KeyPreview = "***"
    }

    // Check if bouncer is registered
    info.Registered = h.validateBouncerKey(ctx)

    c.JSON(http.StatusOK, info)
}

// GetBouncerKey returns the full bouncer key (for copy to clipboard).
// GET /api/v1/admin/crowdsec/bouncer/key
func (h *CrowdsecHandler) GetBouncerKey(c *gin.Context) {
    envKey := getBouncerAPIKeyFromEnv()
    if envKey != "" {
        c.JSON(http.StatusOK, gin.H{"key": envKey, "source": "env_var"})
        return
    }

    fileKey := readKeyFromFile(bouncerKeyFile)
    if fileKey != "" {
        c.JSON(http.StatusOK, gin.H{"key": fileKey, "source": "file"})
        return
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "No bouncer key configured"})
}

Add routes: backend/internal/api/routes/routes.go

// Add to CrowdSec admin routes section
crowdsec.GET("/bouncer", crowdsecHandler.GetBouncerInfo)
crowdsec.GET("/bouncer/key", crowdsecHandler.GetBouncerKey)

Phase 4: Frontend - Display Bouncer Key in UI

File: frontend/src/pages/Security.tsx (or create new component)

New component: frontend/src/components/CrowdSecBouncerKeyDisplay.tsx

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Copy, Check, Key, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';

interface BouncerInfo {
  name: string;
  key_preview: string;
  key_source: 'env_var' | 'file' | 'none';
  file_path: string;
  registered: boolean;
}

async function fetchBouncerInfo(): Promise<BouncerInfo> {
  const res = await fetch('/api/v1/admin/crowdsec/bouncer');
  if (!res.ok) throw new Error('Failed to fetch bouncer info');
  return res.json();
}

async function fetchBouncerKey(): Promise<string> {
  const res = await fetch('/api/v1/admin/crowdsec/bouncer/key');
  if (!res.ok) throw new Error('No key available');
  const data = await res.json();
  return data.key;
}

export function CrowdSecBouncerKeyDisplay() {
  const { t } = useTranslation();
  const [copied, setCopied] = useState(false);

  const { data: info, isLoading } = useQuery({
    queryKey: ['crowdsec-bouncer-info'],
    queryFn: fetchBouncerInfo,
    refetchInterval: 30000, // Refresh every 30s
  });

  const handleCopyKey = async () => {
    try {
      const key = await fetchBouncerKey();
      await navigator.clipboard.writeText(key);
      setCopied(true);
      toast.success(t('security.crowdsec.keyCopied'));
      setTimeout(() => setCopied(false), 2000);
    } catch {
      toast.error(t('security.crowdsec.copyFailed'));
    }
  };

  if (isLoading || !info) {
    return null;
  }

  if (info.key_source === 'none') {
    return (
      <Card className="border-yellow-200 bg-yellow-50">
        <CardContent className="flex items-center gap-2 py-3">
          <AlertCircle className="h-4 w-4 text-yellow-600" />
          <span className="text-sm text-yellow-800">
            {t('security.crowdsec.noKeyConfigured')}
          </span>
        </CardContent>
      </Card>
    );
  }

  return (
    <Card>
      <CardHeader className="pb-2">
        <CardTitle className="flex items-center gap-2 text-base">
          <Key className="h-4 w-4" />
          {t('security.crowdsec.bouncerApiKey')}
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-3">
        <div className="flex items-center justify-between">
          <code className="rounded bg-muted px-2 py-1 font-mono text-sm">
            {info.key_preview}
          </code>
          <Button
            variant="outline"
            size="sm"
            onClick={handleCopyKey}
            disabled={copied}
          >
            {copied ? (
              <>
                <Check className="mr-1 h-3 w-3" />
                {t('common.copied')}
              </>
            ) : (
              <>
                <Copy className="mr-1 h-3 w-3" />
                {t('common.copy')}
              </>
            )}
          </Button>
        </div>

        <div className="flex flex-wrap gap-2">
          <Badge variant={info.registered ? 'default' : 'destructive'}>
            {info.registered
              ? t('security.crowdsec.registered')
              : t('security.crowdsec.notRegistered')}
          </Badge>
          <Badge variant="outline">
            {info.key_source === 'env_var'
              ? t('security.crowdsec.sourceEnvVar')
              : t('security.crowdsec.sourceFile')}
          </Badge>
        </div>

        <p className="text-xs text-muted-foreground">
          {t('security.crowdsec.keyStoredAt')}: <code>{info.file_path}</code>
        </p>
      </CardContent>
    </Card>
  );
}

Add translations: frontend/src/i18n/en.json

{
  "security": {
    "crowdsec": {
      "bouncerApiKey": "Bouncer API Key",
      "keyCopied": "API key copied to clipboard",
      "copyFailed": "Failed to copy API key",
      "noKeyConfigured": "No bouncer key configured. Enable CrowdSec to auto-register.",
      "registered": "Registered",
      "notRegistered": "Not Registered",
      "sourceEnvVar": "From environment variable",
      "sourceFile": "From file",
      "keyStoredAt": "Key stored at"
    }
  }
}

Phase 5: Update Docker Entrypoint

File: .docker/docker-entrypoint.sh

Add key directory creation (after line ~45, in the CrowdSec initialization section):

# ============================================================================
# CrowdSec Key Persistence Directory
# ============================================================================
# Create the persistent directory for bouncer key storage.
# This directory is inside /app/data which is volume-mounted.

CS_KEY_DIR="/app/data/crowdsec"
mkdir -p "$CS_KEY_DIR" 2>/dev/null || echo "Warning: Cannot create $CS_KEY_DIR"

# Fix ownership for key directory
if is_root; then
    chown charon:charon "$CS_KEY_DIR" 2>/dev/null || true
fi

# Create symlink for backwards compatibility with register_bouncer.sh
BOUNCER_DIR="/etc/crowdsec/bouncers"
if [ ! -d "$BOUNCER_DIR" ]; then
    mkdir -p "$BOUNCER_DIR" 2>/dev/null || true
fi

# Log key location for user reference
echo "CrowdSec bouncer key will be stored at: $CS_KEY_DIR/bouncer_key"

Test Scenarios

Playwright E2E Tests

File: tests/crowdsec/bouncer-auto-registration.spec.ts

import { test, expect } from '@playwright/test';
import { loginUser } from '../fixtures/auth-fixtures';
import { waitForLoadingComplete } from '../utils/wait-helpers';

test.describe('CrowdSec Bouncer Auto-Registration', () => {
  test.beforeEach(async ({ page, adminUser }) => {
    await loginUser(page, adminUser);
    await page.goto('/security');
    await waitForLoadingComplete(page);
  });

  test('Scenario 1: Fresh install - auto-registers bouncer on CrowdSec enable', async ({ page }) => {
    await test.step('Enable CrowdSec', async () => {
      const crowdsecToggle = page.getByRole('switch', { name: /crowdsec/i });
      await crowdsecToggle.click();

      // Wait for CrowdSec to start (can take up to 30s)
      await expect(page.getByText(/running/i)).toBeVisible({ timeout: 45000 });
    });

    await test.step('Verify bouncer key is displayed', async () => {
      // Should show bouncer key card after registration
      await expect(page.getByText(/bouncer api key/i)).toBeVisible();
      await expect(page.getByText(/registered/i)).toBeVisible();

      // Key preview should be visible
      const keyPreview = page.locator('code').filter({ hasText: /.../ });
      await expect(keyPreview).toBeVisible();
    });

    await test.step('Copy key to clipboard', async () => {
      const copyButton = page.getByRole('button', { name: /copy/i });
      await copyButton.click();

      // Verify toast notification
      await expect(page.getByText(/copied/i)).toBeVisible();
    });
  });

  test('Scenario 2: Invalid env var key - auto-heals by re-registering', async ({ page, request }) => {
    // This test requires setting an invalid env var - may need Docker restart
    // or API-based configuration change
    test.skip(true, 'Requires container restart with invalid env var');
  });

  test('Scenario 3: Key persists across CrowdSec restart', async ({ page }) => {
    await test.step('Enable CrowdSec and note key', async () => {
      const crowdsecToggle = page.getByRole('switch', { name: /crowdsec/i });
      if (!(await crowdsecToggle.isChecked())) {
        await crowdsecToggle.click();
        await expect(page.getByText(/running/i)).toBeVisible({ timeout: 45000 });
      }
    });

    const keyPreview = await page.locator('code').filter({ hasText: /.../ }).textContent();

    await test.step('Stop CrowdSec', async () => {
      const crowdsecToggle = page.getByRole('switch', { name: /crowdsec/i });
      await crowdsecToggle.click();
      await expect(page.getByText(/stopped/i)).toBeVisible({ timeout: 10000 });
    });

    await test.step('Re-enable CrowdSec', async () => {
      const crowdsecToggle = page.getByRole('switch', { name: /crowdsec/i });
      await crowdsecToggle.click();
      await expect(page.getByText(/running/i)).toBeVisible({ timeout: 45000 });
    });

    await test.step('Verify same key is used', async () => {
      const newKeyPreview = await page.locator('code').filter({ hasText: /.../ }).textContent();
      expect(newKeyPreview).toBe(keyPreview);
    });
  });
});

Go Unit Tests

File: backend/internal/api/handlers/crowdsec_handler_bouncer_test.go

package handlers

import (
    "context"
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestEnsureBouncerRegistration_EnvVarPriority(t *testing.T) {
    // Set env var
    t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "test-key-from-env")

    key := getBouncerAPIKeyFromEnv()
    assert.Equal(t, "test-key-from-env", key)
}

func TestEnsureBouncerRegistration_FileFallback(t *testing.T) {
    // Create temp directory
    tmpDir := t.TempDir()
    keyFile := filepath.Join(tmpDir, "bouncer_key")

    // Write key to file
    err := os.WriteFile(keyFile, []byte("test-key-from-file\n"), 0600)
    require.NoError(t, err)

    key := readKeyFromFile(keyFile)
    assert.Equal(t, "test-key-from-file", key)
}

func TestSaveKeyToFile(t *testing.T) {
    tmpDir := t.TempDir()
    keyFile := filepath.Join(tmpDir, "subdir", "bouncer_key")

    err := saveKeyToFile(keyFile, "new-api-key")
    require.NoError(t, err)

    // Verify file exists with correct permissions
    info, err := os.Stat(keyFile)
    require.NoError(t, err)
    assert.Equal(t, os.FileMode(0600), info.Mode().Perm())

    // Verify content
    content, err := os.ReadFile(keyFile)
    require.NoError(t, err)
    assert.Equal(t, "new-api-key\n", string(content))
}

func TestGetCrowdSecAPIKey_PriorityOrder(t *testing.T) {
    tmpDir := t.TempDir()

    // Create key file
    keyFile := filepath.Join(tmpDir, "bouncer_key")
    os.WriteFile(keyFile, []byte("file-key"), 0600)

    // Test 1: Env var takes priority
    t.Run("env_var_priority", func(t *testing.T) {
        t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "env-key")
        key := getCrowdSecAPIKey()
        assert.Equal(t, "env-key", key)
    })

    // Test 2: File fallback when no env var
    t.Run("file_fallback", func(t *testing.T) {
        // Clear all env vars
        os.Unsetenv("CROWDSEC_API_KEY")
        os.Unsetenv("CHARON_SECURITY_CROWDSEC_API_KEY")
        // Note: Need to mock the file path for this test
    })
}

Upgrade Path

Existing Installations

Scenario Current State After Upgrade
No env var, no key file CrowdSec fails silently Auto-registers on enable
Invalid env var "access forbidden" errors Auto-heals, re-registers
Valid env var Works No change
Key file exists (from manual script) Never read Now used as fallback

Migration Steps

  1. No action required by users - upgrade happens automatically
  2. Existing env var keys continue to work (priority is preserved)
  3. First CrowdSec enable after upgrade triggers auto-registration if needed
  4. Container logs contain the new key for user reference

Risk Assessment

Risk Likelihood Impact Mitigation
Breaking existing valid env var keys Low High Env var always takes priority
cscli not available Low Medium Check exists before calling
Key file permission issues Low Medium Use 600 mode, catch errors
Race condition on startup Low Low Registration happens after LAPI ready
External CrowdSec users confused Medium Low Log message explains external setup

Implementation Checklist

Phase 1: Backend Core (Estimated: 3-4 hours)

  • Add ensureBouncerRegistration() method to crowdsec_handler.go
  • Add validateBouncerKey() method
  • Add registerAndSaveBouncer() method
  • Add logBouncerKeyBanner() method
  • Integrate into Start() method
  • Add helper functions (getBouncerAPIKeyFromEnv, readKeyFromFile, saveKeyToFile)
  • Write unit tests for new methods

Phase 2: Config Loading (Estimated: 1 hour)

  • Update getCrowdSecAPIKey() in caddy/config.go with file fallback
  • Add unit tests for priority order
  • Verify Caddy config regeneration uses new key

Phase 3: API Endpoints (Estimated: 1 hour)

  • Add GET /api/v1/admin/crowdsec/bouncer endpoint
  • Add GET /api/v1/admin/crowdsec/bouncer/key endpoint
  • Register routes
  • Add API tests

Phase 4: Frontend (Estimated: 2-3 hours)

  • Create CrowdSecBouncerKeyDisplay component
  • Add to Security page (CrowdSec section)
  • Add translations (en, other locales as needed)
  • Add copy-to-clipboard functionality
  • Write component tests

Phase 5: Docker Entrypoint (Estimated: 30 min)

  • Add key directory creation to docker-entrypoint.sh
  • Add symlink for backwards compatibility
  • Test container startup

Phase 6: Testing & Documentation (Estimated: 2-3 hours)

  • Write Playwright E2E tests
  • Update docs/guides/crowdsec-setup.md
  • Update docs/configuration.md with new key location
  • Update CHANGELOG.md
  • Manual testing of all scenarios

Files to Modify

File Changes
backend/internal/api/handlers/crowdsec_handler.go Add registration logic, new endpoints
backend/internal/caddy/config.go Update getCrowdSecAPIKey()
backend/internal/api/routes/routes.go Add bouncer routes
.docker/docker-entrypoint.sh Add key directory creation
frontend/src/components/CrowdSecBouncerKeyDisplay.tsx New component
frontend/src/pages/Security.tsx Import and use new component
frontend/src/i18n/en.json Add translations
docs/guides/crowdsec-setup.md Update documentation

References


Last Updated: 2026-02-03 Owner: TBD Reviewers: TBD