Files
Charon/frontend/src/pages/Security.tsx

653 lines
26 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink, Bell } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, Outlet } from 'react-router-dom'
import { startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec'
import { getSecurityStatus, type SecurityStatus } from '../api/security'
import { updateSetting } from '../api/settings'
import { CrowdSecKeyWarning } from '../components/CrowdSecKeyWarning'
import { PageShell } from '../components/layout/PageShell'
import { LiveLogViewer } from '../components/LiveLogViewer'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
Button,
Badge,
Alert,
Switch,
Skeleton,
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '../components/ui'
import { useSecurityConfig, useUpdateSecurityConfig, useGenerateBreakGlassToken } from '../hooks/useSecurity'
import { toast } from '../utils/toast'
// Skeleton loader for security layer cards
function SecurityCardSkeleton() {
return (
<Card className="flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</CardContent>
<CardFooter className="justify-between pt-4">
<Skeleton className="h-5 w-10" />
<Skeleton className="h-8 w-20 rounded-md" />
</CardFooter>
</Card>
)
}
// Loading skeleton for the entire security page
function SecurityPageSkeleton({ t }: { t: (key: string) => string }) {
return (
<PageShell
title={t('security.title')}
description={t('security.description')}
>
<Skeleton className="h-24 w-full rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<SecurityCardSkeleton />
<SecurityCardSkeleton />
<SecurityCardSkeleton />
<SecurityCardSkeleton />
</div>
</PageShell>
)
}
export default function Security() {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: status, isLoading } = useQuery({
queryKey: ['security-status'],
queryFn: getSecurityStatus,
})
const { data: securityConfig } = useSecurityConfig()
const [adminWhitelist, setAdminWhitelist] = useState<string>('')
useEffect(() => {
if (securityConfig && securityConfig.config) {
setAdminWhitelist(securityConfig.config.admin_whitelist || '')
}
}, [securityConfig])
const updateSecurityConfigMutation = useUpdateSecurityConfig()
const generateBreakGlassMutation = useGenerateBreakGlassToken()
const queryClient = useQueryClient()
const [crowdsecStatus, setCrowdsecStatus] = useState<{ running: boolean; pid?: number } | null>(null)
// Stable reference to prevent WebSocket reconnection loops in LiveLogViewer
const emptySecurityFilters = useMemo(() => ({}), [])
// Generic toggle mutation for per-service settings
const toggleServiceMutation = useMutation({
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
await updateSetting(key, enabled ? 'true' : 'false', 'security', 'bool')
},
onMutate: async ({ key, enabled }: { key: string; enabled: boolean }) => {
// Cancel ongoing queries to avoid race conditions
await queryClient.cancelQueries({ queryKey: ['security-status'] })
// Snapshot current state for rollback
const previous = queryClient.getQueryData(['security-status'])
// Optimistic update: parse key like "security.acl.enabled" -> section "acl"
queryClient.setQueryData(['security-status'], (old: unknown) => {
if (!old || typeof old !== 'object') return old
const oldStatus = old as SecurityStatus
const copy = { ...oldStatus }
// Extract section from key (e.g., "security.acl.enabled" -> "acl")
const parts = key.split('.')
const section = parts[1]
// CRITICAL: Spread existing section data to preserve fields like 'mode'
// Update ONLY the enabled field, keep everything else intact
if (section === 'acl') {
copy.acl = { ...copy.acl, enabled }
} else if (section === 'waf') {
// Preserve mode field (detection/prevention)
copy.waf = { ...copy.waf, enabled }
} else if (section === 'rate_limit') {
// Preserve mode field (log/block)
copy.rate_limit = { ...copy.rate_limit, enabled }
}
return copy
})
return { previous }
},
onError: (_err, _vars, context: unknown) => {
// Rollback on error
if (context && typeof context === 'object' && 'previous' in context) {
queryClient.setQueryData(['security-status'], context.previous)
}
const msg = _err instanceof Error ? _err.message : String(_err)
toast.error(`Failed to update setting: ${msg}`)
},
onSuccess: () => {
// Refresh data from server
queryClient.invalidateQueries({ queryKey: ['settings'] })
queryClient.invalidateQueries({ queryKey: ['security-status'] })
toast.success('Security setting updated')
},
})
const fetchCrowdsecStatus = async () => {
try {
const s = await statusCrowdsec()
setCrowdsecStatus(s)
} catch {
setCrowdsecStatus(null)
}
}
useEffect(() => { fetchCrowdsecStatus() }, [])
const crowdsecPowerMutation = useMutation({
mutationFn: async (enabled: boolean) => {
// Update setting first
await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool')
if (enabled) {
toast.info('Starting CrowdSec... This may take up to 30 seconds')
const result = await startCrowdsec()
// VERIFY: Check if it actually started
const status = await statusCrowdsec()
if (!status.running) {
// Revert the setting since process didn't start
await updateSetting('security.crowdsec.enabled', 'false', 'security', 'bool')
throw new Error('CrowdSec process failed to start. Check server logs for details.')
}
return result
} else {
await stopCrowdsec()
// VERIFY: Check if it actually stopped (with brief delay for cleanup)
await new Promise(resolve => setTimeout(resolve, 500))
const status = await statusCrowdsec()
if (status.running) {
throw new Error('CrowdSec process still running. Check server logs for details.')
}
return { enabled: false }
}
},
// No optimistic backend/status invalidation — server state is not updated until
// onSuccess. The UI does derive checked state from mutation variables while
// isPending to reflect the user's intent immediately (see crowdsecChecked).
onMutate: async (enabled: boolean) => {
if (enabled) {
queryClient.setQueryData(['crowdsec-starting'], { isStarting: true, startedAt: Date.now() })
}
},
onError: (err: unknown, enabled: boolean) => {
queryClient.setQueryData(['crowdsec-starting'], { isStarting: false })
const msg = err instanceof Error ? err.message : String(err)
toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`)
// Force refresh status from backend to ensure UI matches reality
queryClient.invalidateQueries({ queryKey: ['security-status'] })
fetchCrowdsecStatus()
},
onSuccess: async (result: { lapi_ready?: boolean; enabled?: boolean } | boolean) => {
queryClient.setQueryData(['crowdsec-starting'], { isStarting: false })
// Refresh all related queries to ensure consistency
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['security-status'] }),
queryClient.invalidateQueries({ queryKey: ['settings'] }),
fetchCrowdsecStatus(),
])
if (typeof result === 'object' && result.lapi_ready === true) {
toast.success('CrowdSec started and LAPI is ready')
} else if (typeof result === 'object' && result.lapi_ready === false) {
toast.warning('CrowdSec started but LAPI is still initializing. Please wait before enrolling.')
} else if (typeof result === 'object' && result.enabled === false) {
toast.success('CrowdSec stopped')
} else {
toast.success('CrowdSec started')
}
},
})
// Determine if any security operation is in progress
const isApplyingConfig =
toggleServiceMutation.isPending ||
updateSecurityConfigMutation.isPending ||
generateBreakGlassMutation.isPending ||
crowdsecPowerMutation.isPending
// Determine contextual message
const getMessage = () => {
if (toggleServiceMutation.isPending) {
return { message: t('security.threeHeadsTurn'), submessage: t('security.cerberusConfigUpdating') }
}
if (crowdsecPowerMutation.isPending) {
return crowdsecPowerMutation.variables
? { message: t('security.summoningGuardian'), submessage: t('security.crowdsecStarting') }
: { message: t('security.guardianRests'), submessage: t('security.crowdsecStopping') }
}
return { message: t('security.strengtheningGuard'), submessage: t('security.wardsActivating') }
}
const { message, submessage } = getMessage()
if (isLoading) {
return <SecurityPageSkeleton t={t} />
}
if (!status) {
return (
<PageShell
title={t('security.title')}
description={t('security.description')}
>
<Alert variant="error" title={t('common.error')}>
{t('security.failedToLoadConfiguration')}
</Alert>
</PageShell>
)
}
// During the crowdsecPowerMutation, use the mutation's argument as the authoritative
// checked state. Neither crowdsecStatus (local, stale) nor status.crowdsec.enabled
// (server, not yet invalidated) reflects the user's intent until onSuccess fires.
const crowdsecChecked = crowdsecPowerMutation.isPending
? (crowdsecPowerMutation.variables ?? (crowdsecStatus?.running ?? status.crowdsec.enabled))
: (crowdsecStatus?.running ?? status.crowdsec.enabled)
const cerberusDisabled = !status.cerberus?.enabled
const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
// Header actions
const headerActions = (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => navigate('/security/audit-logs')}
>
<Activity className="w-4 h-4 mr-2" />
{t('security.auditLogs')}
</Button>
<Button
variant="secondary"
onClick={() => navigate('/settings/notifications')}
>
<Bell className="w-4 h-4 mr-2" />
{t('security.notifications')}
</Button>
<Button
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
>
<ExternalLink className="w-4 h-4 mr-2" />
{t('common.docs')}
</Button>
</div>
)
return (
<TooltipProvider>
{isApplyingConfig && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="cerberus"
/>
)}
<PageShell
title={t('security.title')}
description={t('security.description')}
actions={headerActions}
>
{/* Cerberus Status Header */}
<Card className="flex items-center gap-4 p-6">
<div className={`p-3 rounded-lg ${status.cerberus?.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
<ShieldCheck className={`w-8 h-8 ${status.cerberus?.enabled ? 'text-success' : 'text-content-muted'}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-content-primary">{t('security.cerberusDashboard')}</h2>
<Badge variant={status.cerberus?.enabled ? 'success' : 'default'}>
{status.cerberus?.enabled ? t('security.cerberusActive') : t('security.cerberusDisabled')}
</Badge>
</div>
<p className="text-sm text-content-secondary mt-1">
{status.cerberus?.enabled
? t('security.cerberusReadyMessage')
: t('security.cerberusDisabledMessage')}
</p>
</div>
</Card>
{/* Cerberus Disabled Alert */}
{!status.cerberus?.enabled && (
<Alert variant="warning" title={t('security.featuresUnavailable')}>
<div className="space-y-2">
<p>
{t('security.featuresUnavailableMessage')}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
className="mt-2"
>
<ExternalLink className="w-3 h-3 mr-1.5" />
{t('security.learnMore')}
</Button>
</div>
</Alert>
)}
{/* CrowdSec Key Rejection Warning — suppressed during startup to avoid flashing before bouncer registration completes */}
{status.cerberus?.enabled && !crowdsecPowerMutation.isPending && (crowdsecStatus?.running ?? status.crowdsec.enabled) && (
<CrowdSecKeyWarning />
)}
{/* Admin Whitelist Section */}
{status.cerberus?.enabled && (
<Card>
<CardHeader>
<CardTitle>{t('security.adminWhitelist')}</CardTitle>
<CardDescription>{t('security.adminWhitelistDescription')}</CardDescription>
</CardHeader>
<CardContent>
<label className="text-sm text-content-secondary">{t('security.commaSeparatedCIDR')}</label>
<div className="flex gap-2 mt-2">
<input
className="flex-1 px-3 py-2 rounded-md border border-border bg-surface-elevated text-content-primary focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
value={adminWhitelist}
onChange={(e) => setAdminWhitelist(e.target.value)}
placeholder="192.168.1.0/24, 10.0.0.1"
/>
<Button
size="sm"
variant="primary"
onClick={() => updateSecurityConfigMutation.mutate({ name: 'default', admin_whitelist: adminWhitelist })}
>
{t('common.save')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="secondary"
onClick={() => generateBreakGlassMutation.mutate()}
>
{t('security.generateToken')}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('security.generateTokenTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
</CardContent>
</Card>
)}
<Outlet />
{/* Security Layer Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* CrowdSec - Layer 1: IP Reputation */}
<Card variant="interactive" className="flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer1')}</Badge>
<Badge variant="primary" size="sm">{t('security.ids')}</Badge>
</div>
<Badge variant={crowdsecPowerMutation.isPending && crowdsecPowerMutation.variables ? 'warning' : crowdsecChecked ? 'success' : 'default'}>
{crowdsecPowerMutation.isPending && crowdsecPowerMutation.variables ? t('security.crowdsec.starting') : crowdsecChecked ? t('common.enabled') : t('common.disabled')}
</Badge>
</div>
<div className="flex items-center gap-3 mt-3">
<div className={`p-2 rounded-lg ${crowdsecChecked ? 'bg-success/10' : 'bg-surface-muted'}`}>
<ShieldAlert className={`w-5 h-5 ${crowdsecChecked ? 'text-success' : 'text-content-muted'}`} />
</div>
<div>
<CardTitle className="text-base">{t('security.crowdsec.title')}</CardTitle>
<CardDescription>{t('security.crowdsecDescription')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-content-muted">
{crowdsecChecked
? t('security.crowdsecProtects')
: t('security.crowdsecDisabledDescription')}
</p>
{crowdsecStatus && (
<p className="text-xs text-content-muted mt-2">
{crowdsecStatus.running ? t('security.runningPid', { pid: crowdsecStatus.pid }) : t('security.processStopped')}
</p>
)}
</CardContent>
<CardFooter className="justify-between pt-4">
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={crowdsecChecked}
disabled={crowdsecToggleDisabled}
onCheckedChange={(checked) => crowdsecPowerMutation.mutate(checked)}
data-testid="toggle-crowdsec"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleCrowdsec')}</p>
</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/security/crowdsec')}
disabled={crowdsecControlsDisabled}
>
{t('common.configure')}
</Button>
</CardFooter>
</Card>
{/* ACL - Layer 2: Access Control */}
<Card variant="interactive" className="flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer2')}</Badge>
<Badge variant="primary" size="sm">{t('security.acl.badge')}</Badge>
</div>
<Badge variant={status.acl.enabled ? 'success' : 'default'}>
{status.acl.enabled ? t('common.enabled') : t('common.disabled')}
</Badge>
</div>
<div className="flex items-center gap-3 mt-3">
<div className={`p-2 rounded-lg ${status.acl.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
<Lock className={`w-5 h-5 ${status.acl.enabled ? 'text-success' : 'text-content-muted'}`} />
</div>
<div>
<CardTitle className="text-base">{t('security.accessControl')}</CardTitle>
<CardDescription>{t('security.aclDescription')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-content-muted">
{t('security.aclProtects')}
</p>
</CardContent>
<CardFooter className="justify-between pt-4">
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={status.acl.enabled}
disabled={!status.cerberus?.enabled}
onCheckedChange={(checked) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: checked })}
data-testid="toggle-acl"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleAcl')}</p>
</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/security/access-lists')}
>
{status.acl.enabled ? t('security.manageLists') : t('common.configure')}
</Button>
</CardFooter>
</Card>
{/* Coraza - Layer 3: Request Inspection */}
<Card variant="interactive" className="flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer3')}</Badge>
<Badge variant="primary" size="sm">{t('security.waf.badge')}</Badge>
</div>
<Badge variant={status.waf.enabled ? 'success' : 'default'}>
{status.waf.enabled ? t('common.enabled') : t('common.disabled')}
</Badge>
</div>
<div className="flex items-center gap-3 mt-3">
<div className={`p-2 rounded-lg ${status.waf.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
<Shield className={`w-5 h-5 ${status.waf.enabled ? 'text-success' : 'text-content-muted'}`} />
</div>
<div>
<CardTitle className="text-base">{t('security.corazaWaf')}</CardTitle>
<CardDescription>{t('security.wafDescription')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-content-muted">
{status.waf.enabled
? t('security.wafProtects')
: t('security.wafDisabledDescription')}
</p>
</CardContent>
<CardFooter className="justify-between pt-4">
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={status.waf.enabled}
disabled={!status.cerberus?.enabled}
onCheckedChange={(checked) => toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: checked })}
data-testid="toggle-waf"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleWaf')}</p>
</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/security/waf')}
>
{t('common.configure')}
</Button>
</CardFooter>
</Card>
{/* Rate Limiting - Layer 4: Volume Control */}
<Card variant="interactive" className="flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer4')}</Badge>
<Badge variant="primary" size="sm">{t('security.rate')}</Badge>
</div>
<Badge variant={status.rate_limit.enabled ? 'success' : 'default'}>
{status.rate_limit.enabled ? t('common.enabled') : t('common.disabled')}
</Badge>
</div>
<div className="flex items-center gap-3 mt-3">
<div className={`p-2 rounded-lg ${status.rate_limit.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
<Activity className={`w-5 h-5 ${status.rate_limit.enabled ? 'text-success' : 'text-content-muted'}`} />
</div>
<div>
<CardTitle className="text-base">{t('security.rateLimiting')}</CardTitle>
<CardDescription>{t('security.rateLimitDescription')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-content-muted">
{t('security.rateLimitProtects')}
</p>
</CardContent>
<CardFooter className="justify-between pt-4">
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={status.rate_limit.enabled}
disabled={!status.cerberus?.enabled}
onCheckedChange={(checked) => toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: checked })}
data-testid="toggle-rate-limit"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleRateLimit')}</p>
</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/security/rate-limiting')}
>
{t('common.configure')}
</Button>
</CardFooter>
</Card>
</div>
{/* Live Activity Section */}
{status.cerberus?.enabled && (
<LiveLogViewer mode="security" securityFilters={emptySecurityFilters} className="w-full" />
)}
</PageShell>
</TooltipProvider>
)
}