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

469 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { useNavigate, Outlet } from 'react-router-dom'
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react'
import { getSecurityStatus } from '../api/security'
import { useSecurityConfig, useUpdateSecurityConfig, useGenerateBreakGlassToken, useRuleSets } from '../hooks/useSecurity'
import { exportCrowdsecConfig, startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec'
import { updateSetting } from '../api/settings'
import { Switch } from '../components/ui/Switch'
import { toast } from '../utils/toast'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { ConfigReloadOverlay } from '../components/LoadingStates'
export default function Security() {
const navigate = useNavigate()
const { data: status, isLoading } = useQuery({
queryKey: ['security-status'],
queryFn: getSecurityStatus,
})
const { data: securityConfig } = useSecurityConfig()
const { data: ruleSetsData } = useRuleSets()
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)
// 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 }) => {
await queryClient.cancelQueries({ queryKey: ['security-status'] })
const previous = queryClient.getQueryData(['security-status'])
queryClient.setQueryData(['security-status'], (old: any) => {
if (!old) return old
const parts = key.split('.')
const section = parts[1]
const field = parts[2]
const copy = { ...old }
if (copy[section]) {
copy[section] = { ...copy[section], [field]: enabled }
}
return copy
})
return { previous }
},
onError: (_err, _vars, context: any) => {
if (context?.previous) queryClient.setQueryData(['security-status'], context.previous)
const msg = _err instanceof Error ? _err.message : String(_err)
toast.error(`Failed to update setting: ${msg}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
queryClient.invalidateQueries({ queryKey: ['security-status'] })
toast.success('Security setting updated')
},
})
const toggleCerberusMutation = useMutation({
mutationFn: async (enabled: boolean) => {
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
},
onMutate: async (enabled: boolean) => {
await queryClient.cancelQueries({ queryKey: ['security-status'] })
const previous = queryClient.getQueryData(['security-status'])
if (previous) {
queryClient.setQueryData(['security-status'], (old: any) => {
const copy = JSON.parse(JSON.stringify(old))
if (!copy.cerberus) copy.cerberus = {}
copy.cerberus.enabled = enabled
return copy
})
}
return { previous }
},
onError: (_err, _vars, context: any) => {
if (context?.previous) queryClient.setQueryData(['security-status'], context.previous)
},
// onSuccess: already set below
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
queryClient.invalidateQueries({ queryKey: ['security-status'] })
},
})
const fetchCrowdsecStatus = async () => {
try {
const s = await statusCrowdsec()
setCrowdsecStatus(s)
} catch {
setCrowdsecStatus(null)
}
}
useEffect(() => { fetchCrowdsecStatus() }, [])
const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
// Determine if any security operation is in progress
const isApplyingConfig =
toggleCerberusMutation.isPending ||
toggleServiceMutation.isPending ||
updateSecurityConfigMutation.isPending ||
generateBreakGlassMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending
// Determine contextual message
const getMessage = () => {
if (toggleCerberusMutation.isPending) {
return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
}
if (toggleServiceMutation.isPending) {
return { message: 'Three heads turn...', submessage: 'Security configuration updating' }
}
if (startMutation.isPending) {
return { message: 'Summoning the guardian...', submessage: 'Intrusion prevention rising' }
}
if (stopMutation.isPending) {
return { message: 'Guardian rests...', submessage: 'Intrusion prevention pausing' }
}
return { message: 'Strengthening the guard...', submessage: 'Protective wards activating' }
}
const { message, submessage } = getMessage()
if (isLoading) {
return <div className="p-8 text-center">Loading security status...</div>
}
if (!status) {
return <div className="p-8 text-center text-red-500">Failed to load security status</div>
}
// const suiteDisabled = !(status?.cerberus?.enabled ?? false)
// Replace the previous early-return that instructed enabling via env vars.
// If allDisabled, show a banner and continue to render the dashboard with disabled controls.
const headerBanner = (!status.cerberus?.enabled) ? (
<div className="flex flex-col items-center justify-center text-center space-y-4 p-6 bg-gray-900/5 dark:bg-gray-800 rounded-lg">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-gray-400" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Security Suite Disabled</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg">
Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below.
</p>
<Button
variant="primary"
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Documentation
</Button>
</div>
) : null
return (
<>
{isApplyingConfig && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="cerberus"
/>
)}
<div className="space-y-6">
{headerBanner}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ShieldCheck className="w-8 h-8 text-green-500" />
Security Dashboard
</h1>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
<Switch
checked={status?.cerberus?.enabled ?? false}
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
data-testid="toggle-cerberus"
/>
</div>
<div/>
<Button
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Documentation
</Button>
</div>
<div className="mt-4 p-4 bg-gray-800 rounded-lg">
<label className="text-sm text-gray-400">Admin whitelist (comma-separated CIDR/IPs)</label>
<div className="flex gap-2 mt-2">
<input className="flex-1 p-2 rounded bg-gray-700 text-white" value={adminWhitelist} onChange={(e) => setAdminWhitelist(e.target.value)} />
<Button size="sm" variant="primary" onClick={() => updateSecurityConfigMutation.mutate({ name: 'default', admin_whitelist: adminWhitelist })}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => generateBreakGlassMutation.mutate()}>Generate Token</Button>
</div>
</div>
<Outlet />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* CrowdSec - Layer 1: IP Reputation (first line of defense) */}
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="text-xs text-gray-400 mb-2">🛡 Layer 1: IP Reputation</div>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
<div className="flex items-center gap-3">
<Switch
checked={status.crowdsec.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => {
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
}}
data-testid="toggle-crowdsec"
/>
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.crowdsec.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{status.crowdsec.enabled
? `Protects against: Known attackers, botnets, brute-force`
: 'Intrusion Prevention System'}
</p>
{crowdsecStatus && (
<p className="text-xs text-gray-500 dark:text-gray-400">{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}</p>
)}
{status.crowdsec.enabled && (
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/tasks/logs?search=crowdsec')}
>
View Logs
</Button>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={async () => {
// download config
try {
const resp = await exportCrowdsecConfig()
const url = window.URL.createObjectURL(new Blob([resp]))
const a = document.createElement('a')
a.href = url
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
}
}}
>
Export
</Button>
<Button variant="secondary" size="sm" className="w-full" onClick={() => navigate('/security/crowdsec')}>
Configure
</Button>
<div className="flex gap-2 w-full">
<Button
variant="primary"
size="sm"
className="w-full"
onClick={() => startMutation.mutate()}
data-testid="crowdsec-start"
isLoading={startMutation.isPending}
disabled={!!crowdsecStatus?.running}
>
Start
</Button>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => stopMutation.mutate()}
data-testid="crowdsec-stop"
isLoading={stopMutation.isPending}
disabled={!crowdsecStatus?.running}
>
Stop
</Button>
</div>
</div>
)}
</div>
</Card>
{/* ACL - Layer 2: Access Control (IP/Geo filtering) */}
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="text-xs text-gray-400 mb-2">🔒 Layer 2: Access Control</div>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Access Control</h3>
<div className="flex items-center gap-3">
<Switch
checked={status.acl.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
data-testid="toggle-acl"
/>
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.acl.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Protects against: Unauthorized IPs, geo-based attacks, insider threats
</p>
{status.acl.enabled && (
<div className="mt-4">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/security/access-lists')}
>
Manage Lists
</Button>
</div>
)}
{!status.acl.enabled && (
<div className="mt-4">
<Button size="sm" variant="secondary" onClick={() => navigate('/security/access-lists')}>Configure</Button>
</div>
)}
</div>
</Card>
{/* WAF - Layer 3: Request Inspection */}
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="text-xs text-gray-400 mb-2">🛡 Layer 3: Request Inspection</div>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
<div className="flex items-center gap-3">
<Switch
checked={status.waf.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: e.target.checked })}
data-testid="toggle-waf"
/>
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.waf.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{status.waf.enabled
? `Protects against: SQL injection, XSS, RCE, zero-day exploits*`
: 'Web Application Firewall'}
</p>
{status.waf.enabled && (
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">WAF Mode</label>
<select
value={securityConfig?.config?.waf_mode || 'block'}
onChange={(e) => updateSecurityConfigMutation.mutate({ name: 'default', waf_mode: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white"
data-testid="waf-mode-select"
>
<option value="block">Block (deny malicious requests)</option>
<option value="monitor">Monitor (log only, don't block)</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Active Rule Set</label>
<select
value={securityConfig?.config?.waf_rules_source || ''}
onChange={(e) => updateSecurityConfigMutation.mutate({ name: 'default', waf_rules_source: e.target.value || undefined })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white"
data-testid="waf-ruleset-select"
>
<option value="">None (all rule sets)</option>
{ruleSetsData?.rulesets?.map((rs) => (
<option key={rs.id} value={rs.name}>
{rs.name} ({rs.mode === 'blocking' ? 'blocking' : 'detection'})
</option>
))}
</select>
{(!ruleSetsData?.rulesets || ruleSetsData.rulesets.length === 0) && (
<p className="text-xs text-yellow-500 mt-1">
No rule sets configured. Add one below.
</p>
)}
</div>
</div>
)}
<div className="mt-4">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/security/waf')}
>
{status.waf.enabled ? 'Manage Rule Sets' : 'Configure'}
</Button>
</div>
</div>
</Card>
{/* Rate Limiting - Layer 4: Volume Control */}
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="text-xs text-gray-400 mb-2">⚡ Layer 4: Volume Control</div>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
<div className="flex items-center gap-3">
<Switch
checked={status.rate_limit.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: e.target.checked })}
data-testid="toggle-rate-limit"
/>
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.rate_limit.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Protects against: DDoS attacks, credential stuffing, API abuse
</p>
{status.rate_limit.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" className="w-full" onClick={() => navigate('/security/rate-limiting')}>
Configure Limits
</Button>
</div>
)}
{!status.rate_limit.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" onClick={() => navigate('/security/rate-limiting')}>Configure</Button>
</div>
)}
</div>
</Card>
</div>
</div>
</>
)
}