469 lines
20 KiB
TypeScript
469 lines
20 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|