When CrowdSec is first enabled, the 10-60 second startup window caused the toggle to immediately flicker back to unchecked, the card badge to show 'Disabled' throughout startup, CrowdSecKeyWarning to flash before bouncer registration completed, and CrowdSecConfig to show alarming LAPI-not-ready banners to the user. Root cause: the toggle, badge, and warning conditions all read from stale sources (crowdsecStatus local state and status.crowdsec.enabled server data) which neither reflects user intent during a pending mutation. - Derive crowdsecChecked from crowdsecPowerMutation.variables during the pending window so the UI reflects intent immediately on click, not the lagging server state - Show a 'Starting...' badge in warning variant throughout the startup window so the user knows the operation is in progress - Suppress CrowdSecKeyWarning unconditionally while the mutation is pending, preventing the bouncer key alert from flashing before registration completes on the backend - Broadcast the mutation's running state to the QueryClient cache via a synthetic crowdsec-starting key so CrowdSecConfig.tsx can read it without prop drilling - In CrowdSecConfig, suppress the LAPI 'not running' (red) and 'initializing' (yellow) banners while the startup broadcast is active, with a 90-second safety cap to prevent stale state from persisting if the tab is closed mid-mutation - Add security.crowdsec.starting translation key to all five locales - Add two backend regression tests confirming that empty-string setting values are accepted (not rejected by binding validation), preventing silent re-introduction of the Issue 4 bug - Add nine RTL tests covering toggle stabilization, badge text, warning suppression, and LAPI banner suppression/expiry - Add four Playwright E2E tests using route interception to simulate the startup delay in a real browser context Fixes Issues 3 and 4 from the fresh-install bug report.
1371 lines
62 KiB
TypeScript
1371 lines
62 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { isAxiosError } from 'axios'
|
|
import { Shield, ShieldOff, Trash2, Search, AlertTriangle, ExternalLink } from 'lucide-react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate, Link } from 'react-router-dom'
|
|
|
|
import { createBackup } from '../api/backups'
|
|
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, type CrowdSecDecision, statusCrowdsec, type CrowdSecStatus, startCrowdsec } from '../api/crowdsec'
|
|
import { getFeatureFlags } from '../api/featureFlags'
|
|
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
|
|
import { getSecurityStatus } from '../api/security'
|
|
import { CrowdSecBouncerKeyDisplay } from '../components/CrowdSecBouncerKeyDisplay'
|
|
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
|
import { Button } from '../components/ui/Button'
|
|
import { Card } from '../components/ui/Card'
|
|
import { Input } from '../components/ui/Input'
|
|
import { CROWDSEC_PRESETS, type CrowdsecPreset } from '../data/crowdsecPresets'
|
|
import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment'
|
|
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
|
import { toast } from '../utils/toast'
|
|
|
|
export default function CrowdSecConfig() {
|
|
const { t } = useTranslation()
|
|
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<File | null>(null)
|
|
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
|
const [fileContent, setFileContent] = useState<string | null>(null)
|
|
const [selectedPresetSlug, setSelectedPresetSlug] = useState<string>('')
|
|
const [showBanModal, setShowBanModal] = useState(false)
|
|
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
|
|
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
|
|
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
|
|
const [presetPreview, setPresetPreview] = useState<string>('')
|
|
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
|
|
const [presetStatusMessage, setPresetStatusMessage] = useState<string | null>(null)
|
|
const [hubUnavailable, setHubUnavailable] = useState(false)
|
|
const [validationError, setValidationError] = useState<string | null>(null)
|
|
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null)
|
|
const queryClient = useQueryClient()
|
|
// Read the "CrowdSec is starting" signal broadcast by Security.tsx via the
|
|
// QueryClient cache. No HTTP call is made; this is pure in-memory coordination.
|
|
const { data: crowdsecStartingCache } = useQuery<{ isStarting: boolean; startedAt?: number }>({
|
|
queryKey: ['crowdsec-starting'],
|
|
queryFn: () => ({ isStarting: false, startedAt: 0 }),
|
|
staleTime: Infinity,
|
|
gcTime: Infinity,
|
|
})
|
|
|
|
// isStartingUp is true only while the mutation is genuinely running.
|
|
// The 90-second cap guards against stale cache if Security.tsx onSuccess/onError
|
|
// never fired (e.g., browser tab was closed mid-mutation).
|
|
const isStartingUp =
|
|
(crowdsecStartingCache?.isStarting === true) &&
|
|
Date.now() - (crowdsecStartingCache.startedAt ?? 0) < 90_000
|
|
|
|
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
|
|
// Note: CrowdSec mode is now controlled via Security Dashboard toggle
|
|
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 clearEnrollmentMutation = useClearConsoleEnrollment()
|
|
const [showReenrollForm, setShowReenrollForm] = useState(false)
|
|
const navigate = useNavigate()
|
|
const [initialCheckComplete, setInitialCheckComplete] = useState(false)
|
|
|
|
// Add initial delay to avoid false negative when LAPI is starting
|
|
useEffect(() => {
|
|
if (consoleEnrollmentEnabled && !initialCheckComplete) {
|
|
const timer = setTimeout(() => {
|
|
setInitialCheckComplete(true)
|
|
}, 3000) // Wait 3 seconds before first check
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [consoleEnrollmentEnabled, initialCheckComplete])
|
|
|
|
// Add LAPI status check with polling
|
|
const lapiStatusQuery = useQuery<CrowdSecStatus>({
|
|
queryKey: ['crowdsec-lapi-status'],
|
|
queryFn: statusCrowdsec,
|
|
enabled: consoleEnrollmentEnabled && initialCheckComplete,
|
|
refetchInterval: 5000, // Poll every 5 seconds
|
|
retry: false,
|
|
})
|
|
|
|
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 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)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when slug changes, not on mutation/preset object identity changes
|
|
}, [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 isConsolePendingAcceptance = normalizedConsoleStatus === 'pending_acceptance'
|
|
const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ')
|
|
const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—'
|
|
const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' || isConsolePendingAcceptance
|
|
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('')
|
|
setShowReenrollForm(false)
|
|
if (!consoleTenant.trim()) {
|
|
setConsoleTenant(tenantValue)
|
|
}
|
|
toast.success(
|
|
force
|
|
? 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.'
|
|
: 'Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.'
|
|
)
|
|
} 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 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 ||
|
|
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 (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 <div className="p-8 text-center text-white">{t('crowdsecConfig.loading')}</div>
|
|
if (error) return <div className="p-8 text-center text-red-500">{t('crowdsecConfig.loadError')}: {(error as Error).message}</div>
|
|
if (!status) return <div className="p-8 text-center text-gray-400">{t('crowdsecConfig.noStatus')}</div>
|
|
if (!status.crowdsec) return <div className="p-8 text-center text-red-500">{t('crowdsecConfig.noConfig')}</div>
|
|
|
|
return (
|
|
<>
|
|
{isApplyingConfig && (
|
|
<ConfigReloadOverlay
|
|
message={message}
|
|
submessage={submessage}
|
|
type="cerberus"
|
|
/>
|
|
)}
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold">{t('crowdsecConfig.title')}</h1>
|
|
|
|
{/* CrowdSec Bouncer API Key - moved from Security Dashboard */}
|
|
{status.cerberus?.enabled && status.crowdsec.enabled && (
|
|
<CrowdSecBouncerKeyDisplay />
|
|
)}
|
|
|
|
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-4">
|
|
<p className="text-sm text-blue-200">
|
|
<strong>{t('crowdsecConfig.note')}:</strong> {t('crowdsecConfig.noteText')}{' '}
|
|
<Link to="/security" className="text-blue-400 hover:text-blue-300 underline">{t('navigation.security')}</Link>.
|
|
</p>
|
|
</div>
|
|
|
|
{consoleEnrollmentEnabled && (
|
|
<Card data-testid="console-enrollment-card">
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
<div className="space-y-1">
|
|
<h3 className="text-md font-semibold">{t('crowdsecConfig.consoleEnrollment.title')}</h3>
|
|
<p className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.description')}</p>
|
|
<p className="text-xs text-gray-500">
|
|
{t('crowdsecConfig.consoleEnrollment.privacyNote')}
|
|
<a className="text-blue-400 hover:underline ml-1" href={consoleDocsHref} target="_blank" rel="noreferrer">{t('common.docs')}</a>
|
|
</p>
|
|
</div>
|
|
<div className="text-right text-sm text-gray-300 space-y-1">
|
|
<p className="font-semibold text-white capitalize" data-testid="console-status-label">{t('common.status')}: {consoleStatusLabel}</p>
|
|
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.lastHeartbeat')}: {consoleStatusQuery.data?.last_heartbeat_at ? new Date(consoleStatusQuery.data.last_heartbeat_at).toLocaleString() : '—'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{consoleStatusQuery.data?.last_error && (
|
|
<p className="text-xs text-yellow-300" data-testid="console-status-error">{t('crowdsecConfig.consoleEnrollment.lastError')}: {sanitizeSecret(consoleStatusQuery.data.last_error)}</p>
|
|
)}
|
|
{consoleErrors.submit && (
|
|
<p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.submit}</p>
|
|
)}
|
|
|
|
{/* Yellow warning: Process running but LAPI initializing */}
|
|
{lapiStatusQuery.data && lapiStatusQuery.data.running && !lapiStatusQuery.data.lapi_ready && initialCheckComplete && !isStartingUp && (
|
|
<div className="flex items-start gap-3 p-4 bg-yellow-900/20 border border-yellow-700/50 rounded-lg" data-testid="lapi-warning">
|
|
<AlertTriangle className="w-5 h-5 text-yellow-400 shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<p className="text-sm text-yellow-200 font-medium mb-2">
|
|
{t('crowdsecConfig.lapiInitializing')}
|
|
</p>
|
|
<p className="text-xs text-yellow-300 mb-3">
|
|
{t('crowdsecConfig.lapiInitializingDesc')}
|
|
{lapiStatusQuery.isRefetching && ` ${t('crowdsecConfig.checkingAgain')}`}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => lapiStatusQuery.refetch()}
|
|
disabled={lapiStatusQuery.isRefetching}
|
|
>
|
|
Check Now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Red warning: Process not running at all */}
|
|
{lapiStatusQuery.data && !lapiStatusQuery.data.running && initialCheckComplete && !isStartingUp && (
|
|
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-700/50 rounded-lg" data-testid="lapi-not-running-warning">
|
|
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<p className="text-sm text-red-200 font-medium mb-2">
|
|
{t('crowdsecConfig.notRunning')}
|
|
</p>
|
|
<p className="text-xs text-red-300 mb-3">
|
|
{t('crowdsecConfig.notRunningDesc')}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={async () => {
|
|
try {
|
|
await startCrowdsec();
|
|
toast.info(t('crowdsecConfig.starting'));
|
|
// Refetch status after a delay to allow startup
|
|
setTimeout(() => {
|
|
lapiStatusQuery.refetch();
|
|
}, 3000);
|
|
} catch {
|
|
toast.error(t('crowdsecConfig.startFailed'));
|
|
}
|
|
}}
|
|
>
|
|
{t('crowdsecConfig.startCrowdsec')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => lapiStatusQuery.refetch()}
|
|
disabled={lapiStatusQuery.isRefetching}
|
|
>
|
|
Check Now
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => navigate('/security')}
|
|
>
|
|
{t('crowdsecConfig.goToSecurity')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Input
|
|
id="console-enrollment-token"
|
|
label={t('crowdsecConfig.consoleEnrollment.enrollToken')}
|
|
type="password"
|
|
value={enrollmentToken}
|
|
onChange={(e) => setEnrollmentToken(e.target.value)}
|
|
placeholder={t('crowdsecConfig.consoleEnrollment.tokenPlaceholder')}
|
|
helperText={t('crowdsecConfig.consoleEnrollment.tokenHelper')}
|
|
error={consoleErrors.token}
|
|
errorTestId="console-enroll-error"
|
|
data-testid="console-enrollment-token"
|
|
/>
|
|
<Input
|
|
id="console-agent-name"
|
|
label={t('crowdsecConfig.consoleEnrollment.agentName')}
|
|
value={consoleAgentName}
|
|
onChange={(e) => setConsoleAgentName(e.target.value)}
|
|
error={consoleErrors.agent}
|
|
errorTestId="console-enroll-error"
|
|
data-testid="console-agent-name"
|
|
/>
|
|
<Input
|
|
id="console-tenant"
|
|
label={t('crowdsecConfig.consoleEnrollment.tenant')}
|
|
value={consoleTenant}
|
|
onChange={(e) => setConsoleTenant(e.target.value)}
|
|
helperText={t('crowdsecConfig.consoleEnrollment.tenantHelper')}
|
|
error={consoleErrors.tenant}
|
|
errorTestId="console-enroll-error"
|
|
data-testid="console-tenant"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="console-ack"
|
|
type="checkbox"
|
|
className="h-4 w-4 accent-blue-500"
|
|
checked={consoleAck}
|
|
onChange={(e) => setConsoleAck(e.target.checked)}
|
|
disabled={isConsolePending}
|
|
data-testid="console-ack-checkbox"
|
|
/>
|
|
<label htmlFor="console-ack" className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.ackText')}</label>
|
|
</div>
|
|
{consoleErrors.ack && <p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.ack}</p>}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
onClick={() => submitConsoleEnrollment(false)}
|
|
disabled={isConsolePending || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready) || !enrollmentToken.trim()}
|
|
isLoading={enrollConsoleMutation.isPending}
|
|
data-testid="console-enroll-btn"
|
|
title={
|
|
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
|
|
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReady')
|
|
: !enrollmentToken.trim()
|
|
? t('crowdsecConfig.consoleEnrollment.tokenRequired')
|
|
: undefined
|
|
}
|
|
>
|
|
{t('crowdsecConfig.consoleEnrollment.enroll')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => submitConsoleEnrollment(true)}
|
|
disabled={isConsolePending || !canRotateKey || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)}
|
|
isLoading={enrollConsoleMutation.isPending}
|
|
data-testid="console-rotate-btn"
|
|
title={
|
|
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
|
|
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReadyRotate')
|
|
: undefined
|
|
}
|
|
>
|
|
{t('crowdsecConfig.consoleEnrollment.rotateKey')}
|
|
</Button>
|
|
{isConsoleDegraded && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => submitConsoleEnrollment(true)}
|
|
disabled={isConsolePending || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)}
|
|
isLoading={enrollConsoleMutation.isPending}
|
|
data-testid="console-retry-btn"
|
|
title={
|
|
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
|
|
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReadyRetry')
|
|
: undefined
|
|
}
|
|
>
|
|
{t('crowdsecConfig.consoleEnrollment.retryEnrollment')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info box for pending acceptance status */}
|
|
{isConsolePendingAcceptance && (
|
|
<div className="bg-blue-900/30 border border-blue-500 rounded-lg p-4" data-testid="pending-acceptance-info">
|
|
<p className="text-sm text-blue-200">
|
|
<strong>{t('crowdsecConfig.consoleEnrollment.actionRequired')}:</strong> {t('crowdsecConfig.consoleEnrollment.pendingAcceptanceText')}{' '}
|
|
<a
|
|
href="https://app.crowdsec.net"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-blue-100"
|
|
>
|
|
app.crowdsec.net
|
|
</a>.
|
|
{t('crowdsecConfig.consoleEnrollment.pendingAcceptanceNote')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Re-enrollment Section - shown when enrolled or pending */}
|
|
{(normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'pending_acceptance') && (
|
|
<div className="border border-blue-500/30 bg-blue-500/5 rounded-lg p-4" data-testid="reenroll-section">
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-100">{t('crowdsecConfig.reenroll.title')}</h3>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{t('crowdsecConfig.reenroll.description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!showReenrollForm ? (
|
|
<div className="flex flex-wrap gap-3">
|
|
<a
|
|
href="https://app.crowdsec.net/security-engines"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 text-sm"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
{t('crowdsecConfig.reenroll.getNewKey')}
|
|
</a>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowReenrollForm(true)}
|
|
data-testid="show-reenroll-form-btn"
|
|
>
|
|
{t('crowdsecConfig.reenroll.withNewKey')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4 pt-2 border-t border-gray-700">
|
|
{/* Re-enrollment form */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-token">
|
|
{t('crowdsecConfig.reenroll.newEnrollmentKey')}
|
|
</label>
|
|
<Input
|
|
id="reenroll-token"
|
|
type="text"
|
|
value={enrollmentToken}
|
|
onChange={(e) => setEnrollmentToken(e.target.value)}
|
|
placeholder={t('crowdsecConfig.reenroll.keyPlaceholder')}
|
|
data-testid="reenroll-token-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-agent-name">
|
|
{t('crowdsecConfig.consoleEnrollment.agentName')}
|
|
</label>
|
|
<Input
|
|
id="reenroll-agent-name"
|
|
type="text"
|
|
value={consoleAgentName}
|
|
onChange={(e) => setConsoleAgentName(e.target.value)}
|
|
placeholder={t('crowdsecConfig.reenroll.agentPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-tenant">
|
|
{t('crowdsecConfig.reenroll.tenantOptional')}
|
|
</label>
|
|
<Input
|
|
id="reenroll-tenant"
|
|
type="text"
|
|
value={consoleTenant}
|
|
onChange={(e) => setConsoleTenant(e.target.value)}
|
|
placeholder={t('crowdsecConfig.reenroll.orgPlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => submitConsoleEnrollment(true)}
|
|
disabled={!enrollmentToken.trim() || enrollConsoleMutation.isPending}
|
|
isLoading={enrollConsoleMutation.isPending}
|
|
data-testid="reenroll-submit-btn"
|
|
>
|
|
{enrollConsoleMutation.isPending ? t('crowdsecConfig.reenroll.reenrolling') : t('crowdsecConfig.reenroll.reenroll')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setShowReenrollForm(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Clear enrollment option */}
|
|
<div className="pt-3 border-t border-gray-700">
|
|
<button
|
|
onClick={() => {
|
|
if (window.confirm(t('crowdsecConfig.reenroll.clearConfirm'))) {
|
|
clearEnrollmentMutation.mutate()
|
|
}
|
|
}}
|
|
className="text-sm text-gray-500 hover:text-gray-400"
|
|
disabled={clearEnrollmentMutation.isPending}
|
|
data-testid="clear-enrollment-btn"
|
|
>
|
|
{clearEnrollmentMutation.isPending ? t('crowdsecConfig.reenroll.clearing') : t('crowdsecConfig.reenroll.clearState')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm text-gray-400">
|
|
<div>
|
|
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.agent')}</p>
|
|
<p className="text-white">{consoleStatusQuery.data?.agent_name || consoleAgentName || '—'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.tenantLabel')}</p>
|
|
<p className="text-white">{consoleStatusQuery.data?.tenant || consoleTenant || '—'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.enrollmentToken')}</p>
|
|
<p className="text-white" data-testid="console-token-state">{consoleTokenState}</p>
|
|
</div>
|
|
<div className="md:col-span-3 flex flex-wrap gap-4 text-xs text-gray-500">
|
|
<span>{t('crowdsecConfig.consoleEnrollment.lastAttempt')}: {consoleStatusQuery.data?.last_attempt_at ? new Date(consoleStatusQuery.data.last_attempt_at).toLocaleString() : '—'}</span>
|
|
<span>{t('crowdsecConfig.consoleEnrollment.enrolledAt')}: {consoleStatusQuery.data?.enrolled_at ? new Date(consoleStatusQuery.data.enrolled_at).toLocaleString() : '—'}</span>
|
|
{consoleStatusQuery.data?.correlation_id && <span>{t('crowdsecConfig.consoleEnrollment.correlationId')}: {consoleStatusQuery.data.correlation_id}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<h3 className="text-md font-semibold">{t('crowdsecConfig.packages.title')}</h3>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleExport}
|
|
disabled={importMutation.isPending || backupMutation.isPending}
|
|
>
|
|
{t('crowdsecConfig.packages.export')}
|
|
</Button>
|
|
<Button onClick={handleImport} disabled={!file || importMutation.isPending || backupMutation.isPending} data-testid="import-btn">
|
|
{importMutation.isPending ? t('crowdsecConfig.packages.importing') : t('crowdsecConfig.packages.import')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-400">{t('crowdsecConfig.packages.description')}</p>
|
|
<input
|
|
type="file"
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
data-testid="import-file"
|
|
accept=".tar.gz,.zip"
|
|
aria-label={t('crowdsecConfig.packages.selectFile') || "Select CrowdSec package"}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<h3 className="text-md font-semibold">{t('crowdsecConfig.presets.title')}</h3>
|
|
<p className="text-sm text-gray-400">{t('crowdsecConfig.presets.description')}</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('crowdsecConfig.presets.searchPlaceholder')}
|
|
aria-label={t('crowdsecConfig.presets.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={sortBy}
|
|
aria-label={t('crowdsecConfig.presets.sortBy') || "Sort presets"}
|
|
onChange={(e) => setSortBy(e.target.value as 'alpha' | 'type' | 'source')}
|
|
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
|
>
|
|
<option value="alpha">{t('crowdsecConfig.presets.sortAlpha')}</option>
|
|
<option value="source">{t('crowdsecConfig.presets.sortSource')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="border border-gray-700 rounded-lg max-h-60 overflow-y-auto bg-gray-900">
|
|
{filteredPresets.length > 0 ? (
|
|
filteredPresets.map((preset) => (
|
|
<div
|
|
key={preset.slug}
|
|
onClick={() => setSelectedPresetSlug(preset.slug)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
setSelectedPresetSlug(preset.slug)
|
|
}
|
|
}}
|
|
aria-pressed={selectedPresetSlug === preset.slug}
|
|
className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 outline-none focus-visible:bg-gray-800 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${
|
|
selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-start mb-1">
|
|
<span className="font-medium text-white">{preset.title}</span>
|
|
{preset.source && (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
preset.source === 'charon-curated' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'
|
|
}`}>
|
|
{preset.source === 'charon-curated' ? t('crowdsecConfig.presets.curated') : t('crowdsecConfig.presets.hub')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-400 line-clamp-1">{preset.description}</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="p-4 text-center text-gray-500 text-sm">{t('crowdsecConfig.presets.noPresets', { query: searchQuery })}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
|
disabled={!selectedPreset || pullPresetMutation.isPending}
|
|
isLoading={pullPresetMutation.isPending}
|
|
>
|
|
{t('crowdsecConfig.presets.pullPreview')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleApplyPreset}
|
|
disabled={presetActionDisabled}
|
|
isLoading={isApplyingPreset}
|
|
data-testid="apply-preset-btn"
|
|
>
|
|
{t('crowdsecConfig.presets.applyPreset')}
|
|
</Button>
|
|
</div>
|
|
|
|
{validationError && (
|
|
<p className="text-sm text-red-400" data-testid="preset-validation-error">{validationError}</p>
|
|
)}
|
|
|
|
{presetStatusMessage && (
|
|
<p className="text-sm text-yellow-300" data-testid="preset-status">{presetStatusMessage}</p>
|
|
)}
|
|
|
|
{hubUnavailable && (
|
|
<div className="flex flex-wrap gap-2 items-center text-sm text-red-300" data-testid="preset-hub-unavailable">
|
|
<span>{t('crowdsecConfig.presets.hubUnreachable')}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
|
disabled={pullPresetMutation.isPending}
|
|
>
|
|
{t('crowdsecConfig.presets.retry')}
|
|
</Button>
|
|
{selectedPreset?.cached && (
|
|
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>{t('crowdsecConfig.presets.useCached')}</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selectedPreset && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold text-white">{selectedPreset.title}</p>
|
|
<p className="text-sm text-gray-400">{selectedPreset.description}</p>
|
|
{selectedPreset.warning && (
|
|
<p className="text-xs text-yellow-300" data-testid="preset-warning">{selectedPreset.warning}</p>
|
|
)}
|
|
<p className="text-xs text-gray-500">{t('crowdsecConfig.presets.targetFile')}: {selectedPath ?? t('crowdsecConfig.presets.selectFileBelow')} </p>
|
|
</div>
|
|
{presetMeta && (
|
|
<div className="text-xs text-gray-400 flex flex-wrap gap-3" data-testid="preset-meta">
|
|
<span>{t('crowdsecConfig.presets.cacheKey')}: {presetMeta.cacheKey || '—'}</span>
|
|
<span>{t('crowdsecConfig.presets.etag')}: {presetMeta.etag || '—'}</span>
|
|
<span>{t('crowdsecConfig.presets.source')}: {presetMeta.source || selectedPreset.source || '—'}</span>
|
|
<span>{t('crowdsecConfig.presets.fetched')}: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-xs text-gray-400 mb-2">{t('crowdsecConfig.presets.previewYaml')}</p>
|
|
<pre
|
|
className="bg-gray-900 border border-gray-800 rounded-lg p-3 text-sm text-gray-200 whitespace-pre-wrap"
|
|
data-testid="preset-preview"
|
|
>
|
|
{presetPreview || selectedPreset.content || t('crowdsecConfig.presets.previewUnavailable')}
|
|
</pre>
|
|
</div>
|
|
|
|
{applyInfo && (
|
|
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
|
|
<p>{t('common.status')}: {applyInfo.status || t('crowdsecConfig.presets.applied')}</p>
|
|
{applyInfo.backup && <p>{t('crowdsecConfig.presets.backup')}: {applyInfo.backup}</p>}
|
|
{applyInfo.reloadHint && <p>{t('crowdsecConfig.presets.reload')}: {t('crowdsecConfig.presets.required')}</p>}
|
|
{applyInfo.usedCscli !== undefined && <p>{t('crowdsecConfig.presets.method')}: {applyInfo.usedCscli ? 'cscli' : t('crowdsecConfig.presets.filesystem')}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-400">
|
|
{selectedPreset.cached && (
|
|
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>
|
|
{t('crowdsecConfig.presets.loadCached')}
|
|
</Button>
|
|
)}
|
|
{selectedPresetRequiresHub && hubUnavailable && (
|
|
<span className="text-red-300">{t('crowdsecConfig.presets.applyDisabled')}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{presetCatalog.length === 0 && (
|
|
<p className="text-sm text-gray-500">{t('crowdsecConfig.presets.noPresets')}</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<h3 className="text-md font-semibold">{t('crowdsecConfig.files.title')}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
|
value={selectedPath ?? ''}
|
|
onChange={(e) => handleReadFile(e.target.value)}
|
|
data-testid="crowdsec-file-select"
|
|
aria-label={t('crowdsecConfig.files.selectFile')}
|
|
>
|
|
<option value="">{t('crowdsecConfig.files.selectFile')}</option>
|
|
{listMutation.data?.files?.map((f) => (
|
|
<option value={f} key={f}>{f}</option>
|
|
))}
|
|
</select>
|
|
<Button variant="secondary" onClick={() => listMutation.refetch()}>{t('crowdsecConfig.files.refresh')}</Button>
|
|
</div>
|
|
<textarea
|
|
value={fileContent ?? ''}
|
|
onChange={(e) => setFileContent(e.target.value)}
|
|
rows={12}
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white"
|
|
aria-label={t('crowdsecConfig.files.content') || "File content"}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>{t('common.save')}</Button>
|
|
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>{t('common.close')}</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Banned IPs Section */}
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5 text-red-400" />
|
|
<h3 className="text-md font-semibold">{t('crowdsecConfig.bannedIps.title')}</h3>
|
|
</div>
|
|
<Button
|
|
data-testid="ban-ip-trigger"
|
|
onClick={() => setShowBanModal(true)}
|
|
disabled={status.crowdsec.mode === 'disabled'}
|
|
size="sm"
|
|
>
|
|
<ShieldOff className="h-4 w-4 mr-1" />
|
|
{t('crowdsecConfig.bannedIps.banIp')}
|
|
</Button>
|
|
</div>
|
|
|
|
{status.crowdsec.mode === 'disabled' ? (
|
|
<p className="text-sm text-gray-500">{t('crowdsecConfig.bannedIps.enableFirst')}</p>
|
|
) : decisionsQuery.isLoading ? (
|
|
<p className="text-sm text-gray-400">{t('crowdsecConfig.bannedIps.loading')}</p>
|
|
) : decisionsQuery.error ? (
|
|
<p className="text-sm text-red-400">{t('crowdsecConfig.bannedIps.loadFailed')}</p>
|
|
) : !decisionsQuery.data?.decisions?.length ? (
|
|
<p className="text-sm text-gray-500">{t('crowdsecConfig.bannedIps.none')}</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-700">
|
|
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.ip')}</th>
|
|
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.reason')}</th>
|
|
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.duration')}</th>
|
|
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.bannedAt')}</th>
|
|
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.source')}</th>
|
|
<th className="text-right py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{decisionsQuery.data.decisions.map((decision) => (
|
|
<tr
|
|
key={decision.id}
|
|
className="border-b border-gray-800 hover:bg-gray-800/50 focus:outline-none focus-visible:bg-gray-800 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
|
tabIndex={0}
|
|
>
|
|
<td className="py-2 px-3 font-mono text-white">{decision.ip}</td>
|
|
<td className="py-2 px-3 text-gray-300">{decision.reason || '-'}</td>
|
|
<td className="py-2 px-3 text-gray-300">{decision.duration}</td>
|
|
<td className="py-2 px-3 text-gray-300">
|
|
{decision.created_at ? new Date(decision.created_at).toLocaleString() : '-'}
|
|
</td>
|
|
<td className="py-2 px-3 text-gray-300">{decision.source || 'manual'}</td>
|
|
<td className="py-2 px-3 text-right">
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setConfirmUnban(decision)}
|
|
>
|
|
<Trash2 className="h-3 w-3 mr-1" />
|
|
{t('crowdsecConfig.bannedIps.unban')}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Ban IP Modal */}
|
|
{showBanModal && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="ban-modal-title"
|
|
>
|
|
{/* Layer 1: Background overlay (z-40) */}
|
|
<div
|
|
className="absolute inset-0 bg-black/60"
|
|
onClick={() => setShowBanModal(false)}
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={t('common.close')}
|
|
/>
|
|
|
|
{/* Layer 3: Form content (pointer-events-auto) */}
|
|
<div
|
|
className="relative bg-dark-card rounded-lg p-6 w-[480px] max-w-full shadow-xl"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') setShowBanModal(false)
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) banMutation.mutate()
|
|
}}
|
|
>
|
|
<h3 id="ban-modal-title" className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
|
<ShieldOff className="h-5 w-5 text-red-400" />
|
|
{t('crowdsecConfig.banModal.title')}
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<Input
|
|
id="ban-ip"
|
|
label={t('crowdsecConfig.banModal.ipLabel')}
|
|
placeholder="192.168.1.100"
|
|
value={banForm.ip}
|
|
onChange={(e) => setBanForm({ ...banForm, ip: e.target.value })}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (banForm.ip.trim()) banMutation.mutate()
|
|
}
|
|
}}
|
|
/>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-1.5" htmlFor="ban-duration">{t('crowdsecConfig.banModal.durationLabel')}</label>
|
|
<select
|
|
id="ban-duration"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
value={banForm.duration}
|
|
onChange={(e) => setBanForm({ ...banForm, duration: e.target.value })}
|
|
aria-label={t('crowdsecConfig.banModal.durationLabel')}
|
|
>
|
|
<option value="1h">{t('crowdsecConfig.banModal.duration1h')}</option>
|
|
<option value="4h">{t('crowdsecConfig.banModal.duration4h')}</option>
|
|
<option value="24h">{t('crowdsecConfig.banModal.duration24h')}</option>
|
|
<option value="7d">{t('crowdsecConfig.banModal.duration7d')}</option>
|
|
<option value="30d">{t('crowdsecConfig.banModal.duration30d')}</option>
|
|
<option value="permanent">{t('crowdsecConfig.banModal.durationPermanent')}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-1.5" htmlFor="ban-reason">{t('crowdsecConfig.banModal.reasonLabel')}</label>
|
|
<textarea
|
|
id="ban-reason"
|
|
placeholder={t('crowdsecConfig.banModal.reasonPlaceholder')}
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
rows={3}
|
|
value={banForm.reason}
|
|
onChange={(e) => setBanForm({ ...banForm, reason: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
if (banForm.ip.trim()) banMutation.mutate()
|
|
}
|
|
}}
|
|
aria-label={t('crowdsecConfig.banModal.reasonLabel')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 justify-end mt-6">
|
|
<Button variant="secondary" onClick={() => setShowBanModal(false)}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => banMutation.mutate()}
|
|
isLoading={banMutation.isPending}
|
|
disabled={!banForm.ip.trim()}
|
|
>
|
|
{t('crowdsecConfig.banModal.submit')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Unban Confirmation Modal */}
|
|
{confirmUnban && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="unban-modal-title"
|
|
>
|
|
<div
|
|
className="absolute inset-0 bg-black/60"
|
|
onClick={() => setConfirmUnban(null)}
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={t('common.close')}
|
|
/>
|
|
<div
|
|
className="relative bg-dark-card rounded-lg p-6 w-[400px] max-w-full shadow-xl"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') setConfirmUnban(null)
|
|
if (e.key === 'Enter') unbanMutation.mutate(confirmUnban.ip)
|
|
}}
|
|
>
|
|
<h3 id="unban-modal-title" className="text-xl font-semibold text-white mb-4">{t('crowdsecConfig.unbanModal.title')}</h3>
|
|
<p className="text-gray-300 mb-6">
|
|
{t('crowdsecConfig.unbanModal.confirm')} <span className="font-mono text-white">{confirmUnban.ip}</span>?
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setConfirmUnban(null)}
|
|
autoFocus
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => unbanMutation.mutate(confirmUnban.ip)}
|
|
isLoading={unbanMutation.isPending}
|
|
>
|
|
{t('crowdsecConfig.unbanModal.submit')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|