feat: Add CrowdSec configuration management and export functionality
- Implemented CrowdSec configuration page with import/export capabilities. - Added API endpoints for exporting, importing, listing, reading, and writing CrowdSec configuration files. - Enhanced security handler to support runtime overrides for CrowdSec mode and API URL. - Updated frontend components to include CrowdSec settings in the UI. - Added tests for CrowdSec configuration management and security handler behavior. - Improved user experience with toast notifications for successful operations and error handling.
This commit is contained in:
@@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
|
||||
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
|
||||
const Certificates = lazy(() => import('./pages/Certificates'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="crowdsec" element={<CrowdSecConfig />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -24,4 +24,24 @@ export async function importCrowdsecConfig(file: File) {
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig }
|
||||
export async function exportCrowdsecConfig() {
|
||||
const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function listCrowdsecFiles() {
|
||||
const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function readCrowdsecFile(path: string) {
|
||||
const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function writeCrowdsecFile(path: string, content: string) {
|
||||
const resp = await client.post('/admin/crowdsec/file', { path, content })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile }
|
||||
|
||||
@@ -216,7 +216,7 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Type *
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect
|
||||
</a>
|
||||
{' • '}
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
icon: '⚙️',
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'CrowdSec', path: '/settings/crowdsec', icon: '🛡️' },
|
||||
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function AccessLists() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type', '_blank')}
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security#acl-best-practices-by-service-type', '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Best Practices
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } from '../api/crowdsec'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const backupMutation = useMutation({ mutationFn: () => createBackup() })
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
return await importCrowdsecConfig(file)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('CrowdSec config imported (backup created)')
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to import')
|
||||
}
|
||||
})
|
||||
|
||||
const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles })
|
||||
const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) })
|
||||
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
|
||||
const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) })
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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 handleModeChange = async (mode: string) => {
|
||||
updateModeMutation.mutate(mode)
|
||||
if (mode === 'external') {
|
||||
toast.error('External CrowdSec mode is not supported in this release')
|
||||
} else {
|
||||
toast.success('CrowdSec mode saved (restart may be required)')
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) return <div className="p-8 text-center">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">CrowdSec Configuration</h1>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Mode</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm text-gray-400">Mode:</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={status.crowdsec.mode} onChange={(e) => handleModeChange(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white">
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="external">External (unsupported)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{status.crowdsec.mode === 'disabled' && (
|
||||
<p className="text-xs text-yellow-500">Note: External CrowdSec mode is not supported in this build.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={handleExport}>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Import Configuration</h3>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImport} disabled={!file || importMutation.isPending} data-testid="import-btn">
|
||||
{importMutation.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Edit Configuration Files</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)}>
|
||||
<option value="">Select a file...</option>
|
||||
{listMutation.data?.files.map((f) => (
|
||||
<option value={f} key={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="secondary" onClick={() => listMutation.refetch()}>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" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>Save</Button>
|
||||
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+172
-46
@@ -2,8 +2,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig } 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'
|
||||
|
||||
@@ -14,6 +16,21 @@ export default function Security() {
|
||||
queryFn: getSecurityStatus,
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
// 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')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
toast.success('Security setting updated')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`Failed to update setting: ${msg}`)
|
||||
},
|
||||
})
|
||||
const toggleCerberusMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
@@ -34,51 +51,84 @@ export default function Security() {
|
||||
|
||||
const allDisabled = !status?.crowdsec?.enabled && !status?.waf?.enabled && !status?.rate_limit?.enabled && !status?.acl?.enabled
|
||||
|
||||
|
||||
if (allDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-6">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full">
|
||||
<Shield className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Security Services Not Enabled</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Charon supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting.
|
||||
These are optional and can be enabled via environment variables.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Implementation Guide
|
||||
</Button>
|
||||
// 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 = allDisabled ? (
|
||||
<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 (
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// enable all services
|
||||
const keys = [
|
||||
'security.crowdsec.enabled',
|
||||
'security.waf.enabled',
|
||||
'security.acl.enabled',
|
||||
'security.rate_limit.enabled',
|
||||
]
|
||||
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: true }))
|
||||
}}
|
||||
data-testid="enable-all-btn"
|
||||
>
|
||||
Enable All
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const keys = [
|
||||
'security.crowdsec.enabled',
|
||||
'security.waf.enabled',
|
||||
'security.acl.enabled',
|
||||
'security.rate_limit.enabled',
|
||||
]
|
||||
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: false }))
|
||||
}}
|
||||
data-testid="disable-all-btn"
|
||||
>
|
||||
Disable All
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
@@ -91,7 +141,22 @@ export default function Security() {
|
||||
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
|
||||
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.crowdsec.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => {
|
||||
// pre-validate if enabling external CrowdSec without API URL
|
||||
if (e.target.checked && status.crowdsec?.mode === 'external') {
|
||||
toast.error('External CrowdSec mode is not supported in this release')
|
||||
return
|
||||
}
|
||||
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">
|
||||
@@ -103,16 +168,43 @@ export default function Security() {
|
||||
: 'Intrusion Prevention System'}
|
||||
</p>
|
||||
{status.crowdsec.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</div>
|
||||
<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('/settings/crowdsec')}>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -121,7 +213,15 @@ export default function Security() {
|
||||
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
|
||||
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<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">
|
||||
@@ -137,7 +237,15 @@ export default function Security() {
|
||||
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Access Control</h3>
|
||||
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<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">
|
||||
@@ -158,6 +266,11 @@ export default function Security() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/access-lists')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -165,7 +278,15 @@ export default function Security() {
|
||||
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
|
||||
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<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">
|
||||
@@ -181,6 +302,11 @@ export default function Security() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.rate_limit.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/settings/system')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from '../utils/toast'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
|
||||
import client from '../api/client'
|
||||
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig } from '../api/crowdsec'
|
||||
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig } from '../api/crowdsec'
|
||||
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
|
||||
|
||||
interface HealthResponse {
|
||||
@@ -356,6 +356,22 @@ export default function SystemSettings() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => startMutation.mutate()} isLoading={startMutation.isPending}>Start</Button>
|
||||
<Button onClick={() => stopMutation.mutate()} isLoading={stopMutation.isPending} variant="secondary">Stop</Button>
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
const b = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([b]))
|
||||
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')
|
||||
}
|
||||
}} variant="secondary">Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('exports config when clicking Export', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('uploads a file and calls import on Import (backup before save)', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' } as any)
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const input = screen.getByTestId('import-file') as HTMLInputElement
|
||||
const file = new File(['dummy'], 'cfg.tar.gz')
|
||||
await userEvent.upload(input, file)
|
||||
const btn = screen.getByTestId('import-btn')
|
||||
await userEvent.click(btn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('lists files, reads file content and can save edits (backup before save)', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] } as any)
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' } as any)
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' } as any)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
// wait for file list
|
||||
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const select = selects[1]
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('rule1')
|
||||
// edit and save
|
||||
await userEvent.clear(textarea)
|
||||
await userEvent.type(textarea, 'updated')
|
||||
const saveBtn = screen.getByText('Save')
|
||||
await userEvent.click(saveBtn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
|
||||
})
|
||||
|
||||
it('persists crowdsec.mode via settings when changed', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'disabled', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const modeSelect = selects[0]
|
||||
await userEvent.selectOptions(modeSelect, 'local')
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as api from '../../api/security'
|
||||
import type { SecurityStatus } from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/crowdsec')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Security page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows banner when all services are disabled and links to docs', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Documentation')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders per-service toggles and calls updateSetting on change', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
await userEvent.click(crowdsecToggle)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
||||
// Test Enable All toggles also call updateSetting for multiple keys
|
||||
const enableAllBtn = screen.getByTestId('enable-all-btn')
|
||||
await userEvent.click(enableAllBtn)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('calls export endpoint when clicking Export', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables service toggles when cerberus is off', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user