import { useEffect, useMemo, useState } from 'react' import { isAxiosError } from 'axios' import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' import { Input } from '../components/ui/Input' import { Switch } from '../components/ui/Switch' import { getSecurityStatus } from '../api/security' import { getFeatureFlags } from '../api/featureFlags' import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec' import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets' import { createBackup } from '../api/backups' import { updateSetting } from '../api/settings' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../utils/toast' import { ConfigReloadOverlay } from '../components/LoadingStates' import { Shield, ShieldOff, Trash2, Search } from 'lucide-react' import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport' import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets' import { useConsoleStatus, useEnrollConsole } from '../hooks/useConsoleEnrollment' export default function CrowdSecConfig() { const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus }) const [searchQuery, setSearchQuery] = useState('') const [sortBy, setSortBy] = useState<'alpha' | 'type' | 'source'>('alpha') const [file, setFile] = useState(null) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) const [selectedPresetSlug, setSelectedPresetSlug] = useState('') const [showBanModal, setShowBanModal] = useState(false) const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' }) const [confirmUnban, setConfirmUnban] = useState(null) const [isApplyingPreset, setIsApplyingPreset] = useState(false) const [presetPreview, setPresetPreview] = useState('') const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null) const [presetStatusMessage, setPresetStatusMessage] = useState(null) const [hubUnavailable, setHubUnavailable] = useState(false) const [validationError, setValidationError] = useState(null) const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null) const queryClient = useQueryClient() const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled' const { data: featureFlags } = useQuery({ queryKey: ['feature-flags'], queryFn: getFeatureFlags }) const consoleEnrollmentEnabled = Boolean(featureFlags?.['feature.crowdsec.console_enrollment']) const [enrollmentToken, setEnrollmentToken] = useState('') const [consoleTenant, setConsoleTenant] = useState('') const [consoleAgentName, setConsoleAgentName] = useState((typeof window !== 'undefined' && window.location?.hostname) || 'charon-agent') const [consoleAck, setConsoleAck] = useState(false) const [consoleErrors, setConsoleErrors] = useState<{ token?: string; agent?: string; tenant?: string; ack?: string; submit?: string }>({}) const consoleStatusQuery = useConsoleStatus(consoleEnrollmentEnabled) const enrollConsoleMutation = useEnrollConsole() const backupMutation = useMutation({ mutationFn: () => createBackup() }) const importMutation = useMutation({ mutationFn: async (file: File) => { return await importCrowdsecConfig(file) }, onSuccess: () => { toast.success('CrowdSec config imported (backup created)') queryClient.invalidateQueries({ queryKey: ['security-status'] }) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to import') } }) const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles }) const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) }) const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } }) const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: (_data, mode) => { queryClient.invalidateQueries({ queryKey: ['security-status'] }) toast.success(mode === 'disabled' ? 'CrowdSec disabled' : 'CrowdSec set to Local mode') }, onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Failed to update mode' toast.error(msg) }, }) const presetsQuery = useQuery({ queryKey: ['crowdsec-presets'], queryFn: listCrowdsecPresets, enabled: !!status?.crowdsec, retry: false, }) type PresetCatalogEntry = CrowdsecPreset & { requiresHub: boolean available?: boolean cached?: boolean cacheKey?: string etag?: string retrievedAt?: string source?: string } const presetCatalog: PresetCatalogEntry[] = useMemo(() => { const remotePresets = presetsQuery.data?.presets const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset])) if (remotePresets?.length) { return remotePresets.map((preset) => { const local = localMap.get(preset.slug) return { slug: preset.slug, title: preset.title || local?.title || preset.slug, description: local?.description || preset.summary, content: local?.content || '', tags: local?.tags || preset.tags, warning: local?.warning, requiresHub: Boolean(preset.requires_hub), available: preset.available, cached: preset.cached, cacheKey: preset.cache_key, etag: preset.etag, retrievedAt: preset.retrieved_at, source: preset.source, } }) } return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' })) }, [presetsQuery.data]) const filteredPresets = useMemo(() => { let result = [...presetCatalog] if (searchQuery) { const query = searchQuery.toLowerCase() result = result.filter( (p) => p.title.toLowerCase().includes(query) || p.description?.toLowerCase().includes(query) || p.slug.toLowerCase().includes(query) ) } result.sort((a, b) => { if (sortBy === 'alpha') { return a.title.localeCompare(b.title) } if (sortBy === 'source') { const sourceA = a.source || 'z' const sourceB = b.source || 'z' if (sourceA !== sourceB) return sourceA.localeCompare(sourceB) return a.title.localeCompare(b.title) } return a.title.localeCompare(b.title) }) return result }, [presetCatalog, searchQuery, sortBy]) useEffect(() => { if (!presetCatalog.length) return if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) { setSelectedPresetSlug(presetCatalog[0].slug) } }, [presetCatalog, selectedPresetSlug]) useEffect(() => { if (consoleStatusQuery.data?.agent_name) { setConsoleAgentName((prev) => prev || consoleStatusQuery.data?.agent_name || prev) } if (consoleStatusQuery.data?.tenant) { setConsoleTenant((prev) => prev || consoleStatusQuery.data?.tenant || prev) } }, [consoleStatusQuery.data?.agent_name, consoleStatusQuery.data?.tenant]) const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug) const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false const pullPresetMutation = useMutation({ mutationFn: (slug: string) => pullCrowdsecPreset(slug), onSuccess: (data) => { setPresetPreview(data.preview) setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source }) setPresetStatusMessage('Preview fetched from hub') setHubUnavailable(false) setValidationError(null) }, onError: (err: unknown) => { setPresetStatusMessage(null) if (isAxiosError(err)) { if (err.response?.status === 400) { setValidationError(err.response.data?.error || 'Preset slug is invalid') return } if (err.response?.status === 503) { setHubUnavailable(true) setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.') return } setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview') } else { setPresetStatusMessage('Failed to pull preset preview') } }, }) useEffect(() => { if (!selectedPreset) return setValidationError(null) setPresetStatusMessage(null) setApplyInfo(null) setPresetMeta({ cacheKey: selectedPreset.cacheKey, etag: selectedPreset.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source, }) setPresetPreview(selectedPreset.content || '') pullPresetMutation.mutate(selectedPreset.slug) }, [selectedPreset?.slug]) const loadCachedPreview = async () => { if (!selectedPreset) return try { const cached = await getCrowdsecPresetCache(selectedPreset.slug) setPresetPreview(cached.preview) setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source }) setPresetStatusMessage('Loaded cached preview') setHubUnavailable(false) } catch (err) { const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview' toast.error(msg) } } const normalizedConsoleStatus = consoleStatusQuery.data?.status === 'failed' ? 'degraded' : consoleStatusQuery.data?.status || 'not_enrolled' const isConsoleDegraded = normalizedConsoleStatus === 'degraded' const isConsolePending = enrollConsoleMutation.isPending || normalizedConsoleStatus === 'enrolling' const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ') const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—' const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' const consoleDocsHref = 'https://wikid82.github.io/charon/security/' const sanitizeSecret = (msg: string) => msg.replace(/\b[A-Za-z0-9]{10,64}\b/g, '***') const sanitizeErrorMessage = (err: unknown) => { if (isAxiosError(err)) { return sanitizeSecret(err.response?.data?.error || err.message) } if (err instanceof Error) return sanitizeSecret(err.message) return 'Console enrollment failed' } const validateConsoleEnrollment = (options?: { allowMissingTenant?: boolean; requireAck?: boolean }) => { const nextErrors: { token?: string; agent?: string; tenant?: string; ack?: string } = {} if (!enrollmentToken.trim()) { nextErrors.token = 'Enrollment token is required' } if (!consoleAgentName.trim()) { nextErrors.agent = 'Agent name is required' } if (!consoleTenant.trim() && !options?.allowMissingTenant) { nextErrors.tenant = 'Tenant / organization is required' } if (options?.requireAck && !consoleAck) { nextErrors.ack = 'You must acknowledge the console data-sharing notice' } setConsoleErrors(nextErrors) return Object.keys(nextErrors).length === 0 } const submitConsoleEnrollment = async (force = false) => { const allowMissingTenant = force && !consoleTenant.trim() const requireAck = normalizedConsoleStatus === 'not_enrolled' if (!validateConsoleEnrollment({ allowMissingTenant, requireAck })) return const tenantValue = consoleTenant.trim() || consoleStatusQuery.data?.tenant || consoleAgentName || 'charon-agent' try { await enrollConsoleMutation.mutateAsync({ enrollment_key: enrollmentToken.trim(), tenant: tenantValue, agent_name: consoleAgentName.trim(), force, }) setConsoleErrors({}) setEnrollmentToken('') if (!consoleTenant.trim()) { setConsoleTenant(tenantValue) } toast.success(force ? 'Enrollment token rotated' : 'Enrollment submitted') } catch (err) { const message = sanitizeErrorMessage(err) setConsoleErrors((prev) => ({ ...prev, submit: message })) toast.error(message) } } // Banned IPs queries and mutations const decisionsQuery = useQuery({ queryKey: ['crowdsec-decisions'], queryFn: listCrowdsecDecisions, enabled: isLocalMode, }) const banMutation = useMutation({ mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason), onSuccess: () => { toast.success(`IP ${banForm.ip} has been banned`) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setShowBanModal(false) setBanForm({ ip: '', duration: '24h', reason: '' }) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to ban IP') }, }) const unbanMutation = useMutation({ mutationFn: (ip: string) => unbanIP(ip), onSuccess: (_, ip) => { toast.success(`IP ${ip} has been unbanned`) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setConfirmUnban(null) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to unban IP') }, }) const handleExport = async () => { const defaultName = buildCrowdsecExportFilename() const filename = promptCrowdsecFilename(defaultName) if (!filename) return try { const blob = await exportCrowdsecConfig() downloadCrowdsecExport(blob, filename) toast.success('CrowdSec configuration exported') } catch { toast.error('Failed to export CrowdSec configuration') } } const handleImport = async () => { if (!file) return try { await backupMutation.mutateAsync() await importMutation.mutateAsync(file) setFile(null) } catch { // handled in onError } } const handleReadFile = async (path: string) => { setSelectedPath(path) await readMutation.mutateAsync(path) } const handleSaveFile = async () => { if (!selectedPath || fileContent === null) return try { await backupMutation.mutateAsync() await writeMutation.mutateAsync({ path: selectedPath, content: fileContent }) } catch { // handled } } const handleModeToggle = (nextEnabled: boolean) => { const mode = nextEnabled ? 'local' : 'disabled' updateModeMutation.mutate(mode) } const applyPresetLocally = async (reason?: string) => { if (!selectedPreset) { toast.error('Select a preset to apply') return } const targetPath = selectedPath ?? listMutation.data?.files?.[0] if (!targetPath) { toast.error('Select a configuration file to apply the preset') return } const content = presetPreview || selectedPreset.content if (!content) { toast.error('Preset preview is unavailable; retry pulling before applying') return } try { await backupMutation.mutateAsync() await writeCrowdsecFile(targetPath, content) queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) setSelectedPath(targetPath) setFileContent(content) setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey }) toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)') } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to apply preset locally' toast.error(msg) } } const handleApplyPreset = async () => { if (!selectedPreset) { toast.error('Select a preset to apply') return } setIsApplyingPreset(true) setApplyInfo(null) setValidationError(null) try { const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey }) setApplyInfo({ status: res.status, backup: res.backup, reloadHint: res.reload_hint, usedCscli: res.used_cscli, cacheKey: res.cache_key, }) const reloadNote = res.reload_hint ? ' (reload required)' : '' toast.success(`Preset applied via backend${reloadNote}`) if (res.backup) { setPresetStatusMessage(`Backup stored at ${res.backup}`) } } catch (err) { if (isAxiosError(err)) { if (err.response?.status === 501) { toast.info('Preset apply is not available on the server; applying locally instead') await applyPresetLocally('Backend apply unavailable') return } if (err.response?.status === 400) { setValidationError(err.response?.data?.error || 'Preset validation failed') toast.error('Preset validation failed') return } if (err.response?.status === 503) { setHubUnavailable(true) setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.') toast.error('Hub unavailable; retry pull/apply or use cached copy') return } const errorMsg = err.response?.data?.error || err.message const backupPath = (err.response?.data as { backup?: string })?.backup // Check if error is due to missing cache if (errorMsg.includes('not cached') || errorMsg.includes('Pull the preset first')) { toast.error(errorMsg) setValidationError('Preset must be pulled before applying. Click "Pull Preview" first.') return } if (backupPath) { setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey }) toast.error(`Apply failed: ${errorMsg}. Backup created at ${backupPath}`) return } toast.error(`Apply failed: ${errorMsg}`) } else { toast.error('Failed to apply preset') } } finally { setIsApplyingPreset(false) } } const presetActionDisabled = !selectedPreset || isApplyingPreset || backupMutation.isPending || pullPresetMutation.isPending || (selectedPresetRequiresHub && hubUnavailable) // Determine if any operation is in progress const isApplyingConfig = importMutation.isPending || writeMutation.isPending || updateModeMutation.isPending || backupMutation.isPending || pullPresetMutation.isPending || isApplyingPreset || banMutation.isPending || unbanMutation.isPending // Determine contextual message const getMessage = () => { if (pullPresetMutation.isPending) { return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' } } if (isApplyingPreset) { return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' } } if (importMutation.isPending) { return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' } } if (writeMutation.isPending) { return { message: 'Guardian inscribes...', submessage: 'Saving configuration file' } } if (updateModeMutation.isPending) { return { message: 'Three heads turn...', submessage: 'CrowdSec mode updating' } } if (banMutation.isPending) { return { message: 'Guardian raises shield...', submessage: 'Banning IP address' } } if (unbanMutation.isPending) { return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' } } return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' } } const { message, submessage } = getMessage() if (isLoading) return
Loading CrowdSec configuration...
if (error) return
Failed to load security status: {(error as Error).message}
if (!status) return
No security status available
if (!status.crowdsec) return
CrowdSec configuration not found in security status
return ( <> {isApplyingConfig && ( )}

CrowdSec Configuration

CrowdSec Mode

{isLocalMode ? 'CrowdSec runs locally; disable to pause decisions.' : 'CrowdSec decisions are paused; enable to resume local protection.'}

Disabled handleModeToggle(e.target.checked)} disabled={updateModeMutation.isPending} data-testid="crowdsec-mode-toggle" /> Local
{consoleEnrollmentEnabled && (

CrowdSec Console Enrollment

Register this engine with the CrowdSec console using an enrollment key. This flow is opt-in.

Enrollment shares heartbeat metadata with crowdsec.net; secrets and configuration files are not sent. View docs

Status: {consoleStatusLabel}

Last heartbeat: {consoleStatusQuery.data?.last_heartbeat_at ? new Date(consoleStatusQuery.data.last_heartbeat_at).toLocaleString() : '—'}

{consoleStatusQuery.data?.last_error && (

Last error: {sanitizeSecret(consoleStatusQuery.data.last_error)}

)} {consoleErrors.submit && (

{consoleErrors.submit}

)}
setEnrollmentToken(e.target.value)} placeholder="Paste token or cscli console enroll " helperText="Token is not displayed after submit. You may paste the full cscli command string." error={consoleErrors.token} errorTestId="console-enroll-error" data-testid="console-enrollment-token" /> setConsoleAgentName(e.target.value)} error={consoleErrors.agent} errorTestId="console-enroll-error" data-testid="console-agent-name" /> setConsoleTenant(e.target.value)} helperText="Shown in the console when grouping agents." error={consoleErrors.tenant} errorTestId="console-enroll-error" data-testid="console-tenant" />
setConsoleAck(e.target.checked)} disabled={isConsolePending} data-testid="console-ack-checkbox" /> I understand this enrolls the engine with the CrowdSec console and shares heartbeat metadata.
{consoleErrors.ack &&

{consoleErrors.ack}

}
{isConsoleDegraded && ( )}

Agent

{consoleStatusQuery.data?.agent_name || consoleAgentName || '—'}

Tenant

{consoleStatusQuery.data?.tenant || consoleTenant || '—'}

Enrollment token

{consoleTokenState}

Last attempt: {consoleStatusQuery.data?.last_attempt_at ? new Date(consoleStatusQuery.data.last_attempt_at).toLocaleString() : '—'} Enrolled at: {consoleStatusQuery.data?.enrolled_at ? new Date(consoleStatusQuery.data.enrolled_at).toLocaleString() : '—'} {consoleStatusQuery.data?.correlation_id && Correlation ID: {consoleStatusQuery.data.correlation_id}}
)}

Configuration Packages

Import or export CrowdSec configuration packages. A backup is created before imports.

setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />

CrowdSec Presets

Select a curated preset, preview it, then apply with an automatic backup.

setSearchQuery(e.target.value)} className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{filteredPresets.length > 0 ? ( filteredPresets.map((preset) => (
setSelectedPresetSlug(preset.slug)} className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 ${ selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent' }`} >
{preset.title} {preset.source && ( {preset.source === 'charon-curated' ? 'Curated' : 'Hub'} )}

{preset.description}

)) ) : (
No presets found matching "{searchQuery}"
)}
{validationError && (

{validationError}

)} {presetStatusMessage && (

{presetStatusMessage}

)} {hubUnavailable && (
Hub unreachable. Retry pull or load cached copy if available. {selectedPreset?.cached && ( )}
)} {selectedPreset && (

{selectedPreset.title}

{selectedPreset.description}

{selectedPreset.warning && (

{selectedPreset.warning}

)}

Target file: {selectedPath ?? 'Select a file below (used for local fallback)'}

{presetMeta && (
Cache key: {presetMeta.cacheKey || '—'} Etag: {presetMeta.etag || '—'} Source: {presetMeta.source || selectedPreset.source || '—'} Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}
)}

Preset preview (YAML)

                  {presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
                
{applyInfo && (

Status: {applyInfo.status || 'applied'}

{applyInfo.backup &&

Backup: {applyInfo.backup}

} {applyInfo.reloadHint &&

Reload: Required

} {applyInfo.usedCscli !== undefined &&

Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}

}
)}
{selectedPreset.cached && ( )} {selectedPresetRequiresHub && hubUnavailable && ( Apply disabled while hub is offline. )}
)} {presetCatalog.length === 0 && (

No presets available. Ensure Cerberus is enabled.

)}

Edit Configuration Files