feat(crowdsec): CrowdSec IDS/IPS Management — v1.2.61
- Backend: internal/crowdsec/service.go — vollständige cscli-Wrapper (Decisions, Alerts, Bouncers, Machines, Collections, ServiceStatus) - Handler: 12 REST-Endpoints mit Audit-Logging unter /crowdsec/* - Migration 0036: crowdsec_settings-Tabelle - postinst: CrowdSec-Auto-Install (crowdsec + crowdsec-firewall-bouncer-nftables) inkl. sudoers-Einträge für alle cscli-Operationen - systemd: /var/lib/crowdsec in ReadWritePaths - UI: CrowdSec-Page mit StatusStrip + 5 Tabs (Decisions, Alerts, Bouncers, Machines, Collections), Sidebar-Eintrag, i18n EN+DE - firewall: flush ruleset → flush table inet edgeguard (CrowdSec-nftables-Table bleibt bei Firewall-Render erhalten) - cluster: Firewall-Reload nur bei echter IP-Änderung, nicht bei jedem periodischen Secondary-Heartbeat (verhindert nftables-Counter-Reset) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const AlertsPage = lazy(() => import('./pages/Alerts'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
const UsersPage = lazy(() => import('./pages/Users'))
|
||||
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -141,6 +142,7 @@ export default function App() {
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/crowdsec" element={<CrowdSecPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
NodeIndexOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
@@ -76,6 +77,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/firewall/live', labelKey: 'nav.firewallLive', icon: <EyeOutlined />, child: true },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"alerts": "Alarme",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
"crowdsec": "CrowdSec IDS",
|
||||
"section": {
|
||||
"overview": "Übersicht",
|
||||
"routing": "Routing",
|
||||
@@ -1679,5 +1680,82 @@
|
||||
"next": "Weiter",
|
||||
"showing": "Zeile {{from}}–{{to}}"
|
||||
}
|
||||
},
|
||||
"cs": {
|
||||
"title": "CrowdSec IDS",
|
||||
"intro": "Kollaborative Intrusion-Detection: Log-basierte Erkennung + Community-Blocklisten via nftables-Bouncer.",
|
||||
"notInstalled": "CrowdSec ist nicht installiert. Bitte das Paket crowdsec + crowdsec-firewall-bouncer-nftables installieren.",
|
||||
"status": {
|
||||
"agent": "Agent",
|
||||
"bouncer": "Bouncer",
|
||||
"decisions": "Aktive Sperren",
|
||||
"alerts": "Alarme",
|
||||
"running": "Aktiv",
|
||||
"stopped": "Gestoppt",
|
||||
"notInstalled": "Nicht installiert"
|
||||
},
|
||||
"tabs": {
|
||||
"decisions": "Entscheidungen",
|
||||
"alerts": "Alarme",
|
||||
"bouncers": "Bouncers",
|
||||
"machines": "Maschinen",
|
||||
"collections": "Collections"
|
||||
},
|
||||
"decision": {
|
||||
"ip": "IP-Adresse",
|
||||
"reason": "Grund",
|
||||
"origin": "Herkunft",
|
||||
"duration": "Dauer",
|
||||
"country": "Land",
|
||||
"as": "AS-Name",
|
||||
"type": "Typ",
|
||||
"unban": "Entsperren",
|
||||
"banModal": "Manuell sperren",
|
||||
"banBtn": "IP sperren",
|
||||
"confirmUnban": "IP {{ip}} wirklich entsperren?",
|
||||
"addSuccess": "IP {{ip}} wurde gesperrt.",
|
||||
"deleteSuccess": "Sperre aufgehoben."
|
||||
},
|
||||
"alert": {
|
||||
"id": "ID",
|
||||
"scenario": "Szenario",
|
||||
"events": "Events",
|
||||
"sourceIP": "Quell-IP",
|
||||
"country": "Land",
|
||||
"start": "Beginn",
|
||||
"stop": "Ende",
|
||||
"delete": "Verwerfen",
|
||||
"confirmDelete": "Alarm #{{id}} wirklich verwerfen?"
|
||||
},
|
||||
"bouncer": {
|
||||
"name": "Name",
|
||||
"ip": "IP",
|
||||
"validKey": "Key gültig",
|
||||
"version": "Version",
|
||||
"lastPull": "Letzter Pull",
|
||||
"type": "Typ",
|
||||
"delete": "Entfernen",
|
||||
"confirmDelete": "Bouncer {{name}} wirklich entfernen?"
|
||||
},
|
||||
"machine": {
|
||||
"id": "Machine-ID",
|
||||
"created": "Angelegt",
|
||||
"lastPush": "Letzter Push",
|
||||
"validated": "Validiert",
|
||||
"version": "Version",
|
||||
"delete": "Entfernen",
|
||||
"confirmDelete": "Maschine {{id}} wirklich entfernen?"
|
||||
},
|
||||
"collection": {
|
||||
"name": "Collection",
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"author": "Author",
|
||||
"install": "Installieren",
|
||||
"remove": "Entfernen",
|
||||
"enabled": "Installiert",
|
||||
"disabled": "Nicht installiert",
|
||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"alerts": "Alerts",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
"crowdsec": "CrowdSec IDS",
|
||||
"section": {
|
||||
"overview": "Overview",
|
||||
"routing": "Routing",
|
||||
@@ -1679,5 +1680,82 @@
|
||||
"next": "Next",
|
||||
"showing": "Row {{from}}–{{to}}"
|
||||
}
|
||||
},
|
||||
"cs": {
|
||||
"title": "CrowdSec IDS",
|
||||
"intro": "Collaborative intrusion detection: log-based detection + community blocklists via nftables bouncer.",
|
||||
"notInstalled": "CrowdSec is not installed. Please install the crowdsec + crowdsec-firewall-bouncer-nftables packages.",
|
||||
"status": {
|
||||
"agent": "Agent",
|
||||
"bouncer": "Bouncer",
|
||||
"decisions": "Active bans",
|
||||
"alerts": "Alerts",
|
||||
"running": "Active",
|
||||
"stopped": "Stopped",
|
||||
"notInstalled": "Not installed"
|
||||
},
|
||||
"tabs": {
|
||||
"decisions": "Decisions",
|
||||
"alerts": "Alerts",
|
||||
"bouncers": "Bouncers",
|
||||
"machines": "Machines",
|
||||
"collections": "Collections"
|
||||
},
|
||||
"decision": {
|
||||
"ip": "IP address",
|
||||
"reason": "Reason",
|
||||
"origin": "Origin",
|
||||
"duration": "Duration",
|
||||
"country": "Country",
|
||||
"as": "AS name",
|
||||
"type": "Type",
|
||||
"unban": "Unban",
|
||||
"banModal": "Manual ban",
|
||||
"banBtn": "Ban IP",
|
||||
"confirmUnban": "Really unban IP {{ip}}?",
|
||||
"addSuccess": "IP {{ip}} has been banned.",
|
||||
"deleteSuccess": "Ban removed."
|
||||
},
|
||||
"alert": {
|
||||
"id": "ID",
|
||||
"scenario": "Scenario",
|
||||
"events": "Events",
|
||||
"sourceIP": "Source IP",
|
||||
"country": "Country",
|
||||
"start": "Start",
|
||||
"stop": "End",
|
||||
"delete": "Dismiss",
|
||||
"confirmDelete": "Really dismiss alert #{{id}}?"
|
||||
},
|
||||
"bouncer": {
|
||||
"name": "Name",
|
||||
"ip": "IP",
|
||||
"validKey": "Key valid",
|
||||
"version": "Version",
|
||||
"lastPull": "Last pull",
|
||||
"type": "Type",
|
||||
"delete": "Remove",
|
||||
"confirmDelete": "Really remove bouncer {{name}}?"
|
||||
},
|
||||
"machine": {
|
||||
"id": "Machine ID",
|
||||
"created": "Created",
|
||||
"lastPush": "Last push",
|
||||
"validated": "Validated",
|
||||
"version": "Version",
|
||||
"delete": "Remove",
|
||||
"confirmDelete": "Really remove machine {{id}}?"
|
||||
},
|
||||
"collection": {
|
||||
"name": "Collection",
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"author": "Author",
|
||||
"install": "Install",
|
||||
"remove": "Remove",
|
||||
"enabled": "Installed",
|
||||
"disabled": "Not installed",
|
||||
"confirmRemove": "Really remove collection {{name}}?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
533
management-ui/src/pages/CrowdSec/index.tsx
Normal file
533
management-ui/src/pages/CrowdSec/index.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Tabs,
|
||||
Table,
|
||||
} from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
RadarChartOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import type {
|
||||
AddDecisionBody,
|
||||
Alert as CSAlert,
|
||||
Bouncer,
|
||||
CrowdSecStatus,
|
||||
Decision,
|
||||
HubItem,
|
||||
Machine,
|
||||
} from './types'
|
||||
|
||||
// ---------- API helpers -----------------------------------------------------
|
||||
|
||||
async function fetchStatus(): Promise<CrowdSecStatus> {
|
||||
const r = await apiClient.get('/crowdsec/status')
|
||||
if (isEnvelope(r.data)) return r.data.data as CrowdSecStatus
|
||||
return r.data as CrowdSecStatus
|
||||
}
|
||||
|
||||
async function fetchDecisions(): Promise<Decision[]> {
|
||||
const r = await apiClient.get('/crowdsec/decisions')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { decisions: Decision[] }).decisions ?? []
|
||||
}
|
||||
|
||||
async function fetchAlerts(): Promise<CSAlert[]> {
|
||||
const r = await apiClient.get('/crowdsec/alerts')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { alerts: CSAlert[] }).alerts ?? []
|
||||
}
|
||||
|
||||
async function fetchBouncers(): Promise<Bouncer[]> {
|
||||
const r = await apiClient.get('/crowdsec/bouncers')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { bouncers: Bouncer[] }).bouncers ?? []
|
||||
}
|
||||
|
||||
async function fetchMachines(): Promise<Machine[]> {
|
||||
const r = await apiClient.get('/crowdsec/machines')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { machines: Machine[] }).machines ?? []
|
||||
}
|
||||
|
||||
async function fetchCollections(): Promise<HubItem[]> {
|
||||
const r = await apiClient.get('/crowdsec/collections')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { collections: HubItem[] }).collections ?? []
|
||||
}
|
||||
|
||||
// ---------- Status strip ----------------------------------------------------
|
||||
|
||||
function StatusStrip({ status }: { status: CrowdSecStatus | undefined }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const agentColor = status?.agent_running ? 'green' : 'red'
|
||||
const bouncerColor = status?.bouncer_running ? 'green' : 'red'
|
||||
const agentLabel = status?.agent_running ? t('cs.status.running') : t('cs.status.stopped')
|
||||
const bouncerLabel = status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped')
|
||||
|
||||
return (
|
||||
<div className="fw-kpi-strip">
|
||||
<div className="fw-kpi-card">
|
||||
<div className="fw-kpi-label">{t('cs.status.agent')}</div>
|
||||
<div className="fw-kpi-value">
|
||||
<Tag
|
||||
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
color={agentColor}
|
||||
>
|
||||
{agentLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
{status?.version && (
|
||||
<div className="fw-kpi-sub">{status.version}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fw-kpi-card">
|
||||
<div className="fw-kpi-label">{t('cs.status.bouncer')}</div>
|
||||
<div className="fw-kpi-value">
|
||||
<Tag
|
||||
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
color={bouncerColor}
|
||||
>
|
||||
{bouncerLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fw-kpi-card">
|
||||
<div className="fw-kpi-label">{t('cs.status.decisions')}</div>
|
||||
<div className="fw-kpi-value">{status?.decision_count ?? '–'}</div>
|
||||
<div className="fw-kpi-sub">{t('cs.tabs.decisions')}</div>
|
||||
</div>
|
||||
<div className="fw-kpi-card">
|
||||
<div className="fw-kpi-label">{t('cs.status.alerts')}</div>
|
||||
<div className="fw-kpi-value">{status?.alert_count ?? '–'}</div>
|
||||
<div className="fw-kpi-sub">{t('cs.tabs.alerts')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Decisions tab ---------------------------------------------------
|
||||
|
||||
function DecisionsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [banModalOpen, setBanModalOpen] = useState(false)
|
||||
const [form] = Form.useForm<AddDecisionBody>()
|
||||
|
||||
const { data: decisions, isLoading } = useQuery({
|
||||
queryKey: ['crowdsec', 'decisions'],
|
||||
queryFn: fetchDecisions,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const unban = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/crowdsec/decisions?id=${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const addDecision = useMutation({
|
||||
mutationFn: (body: AddDecisionBody) => apiClient.post('/crowdsec/decisions', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowdsec'] })
|
||||
setBanModalOpen(false)
|
||||
form.resetFields()
|
||||
},
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: t('cs.decision.ip'), dataIndex: 'value', key: 'value' },
|
||||
{ title: t('cs.decision.reason'), dataIndex: 'reason', key: 'reason' },
|
||||
{ title: t('cs.decision.origin'), dataIndex: 'origin', key: 'origin' },
|
||||
{ title: t('cs.decision.duration'), dataIndex: 'duration', key: 'duration' },
|
||||
{ title: t('cs.decision.type'), dataIndex: 'type', key: 'type',
|
||||
render: (v: string) => <Tag color={v === 'ban' ? 'red' : 'orange'}>{v}</Tag> },
|
||||
{ title: t('cs.decision.country'), dataIndex: 'country', key: 'country' },
|
||||
{ title: t('cs.decision.as'), dataIndex: 'as', key: 'as' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: Decision) => (
|
||||
<Popconfirm
|
||||
title={t('cs.decision.confirmUnban', { ip: row.value })}
|
||||
onConfirm={() => unban.mutate(row.id)}
|
||||
okText={t('cs.decision.unban')}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<StopOutlined />}>
|
||||
{t('cs.decision.unban')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => setBanModalOpen(true)}
|
||||
>
|
||||
{t('cs.decision.banBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={decisions ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
<Modal
|
||||
title={t('cs.decision.banModal')}
|
||||
open={banModalOpen}
|
||||
onCancel={() => { setBanModalOpen(false); form.resetFields() }}
|
||||
onOk={() => form.submit()}
|
||||
okButtonProps={{ danger: true, loading: addDecision.isPending }}
|
||||
okText={t('cs.decision.banBtn')}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ duration: '24h', reason: 'manual ban', type: 'ban' }}
|
||||
onFinish={(vals) => addDecision.mutate(vals)}
|
||||
>
|
||||
<Form.Item name="ip" label={t('cs.decision.ip')} rules={[{ required: true }]}>
|
||||
<Input placeholder="1.2.3.4" />
|
||||
</Form.Item>
|
||||
<Form.Item name="duration" label={t('cs.decision.duration')} rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select.Option value="1h">1h</Select.Option>
|
||||
<Select.Option value="12h">12h</Select.Option>
|
||||
<Select.Option value="24h">24h</Select.Option>
|
||||
<Select.Option value="168h">7d</Select.Option>
|
||||
<Select.Option value="720h">30d</Select.Option>
|
||||
<Select.Option value="8760h">permanent (1y)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label={t('cs.decision.reason')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label={t('cs.decision.type')}>
|
||||
<Select>
|
||||
<Select.Option value="ban">ban</Select.Option>
|
||||
<Select.Option value="captcha">captcha</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Alerts tab ------------------------------------------------------
|
||||
|
||||
function AlertsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: alerts, isLoading } = useQuery({
|
||||
queryKey: ['crowdsec', 'alerts'],
|
||||
queryFn: fetchAlerts,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const deleteAlert = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/crowdsec/alerts/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: t('cs.alert.id'), dataIndex: 'id', key: 'id', width: 70 },
|
||||
{ title: t('cs.alert.scenario'), dataIndex: 'scenario', key: 'scenario' },
|
||||
{ title: t('cs.alert.events'), dataIndex: 'events_count', key: 'events_count', width: 80 },
|
||||
{ title: t('cs.alert.sourceIP'), key: 'sourceIP',
|
||||
render: (_: unknown, row: CSAlert) => row.source?.ip ?? row.source?.value ?? '–' },
|
||||
{ title: t('cs.alert.country'), key: 'country',
|
||||
render: (_: unknown, row: CSAlert) => row.source?.cn ?? '–' },
|
||||
{ title: t('cs.alert.start'), dataIndex: 'start_at', key: 'start_at' },
|
||||
{ title: t('cs.alert.stop'), dataIndex: 'stop_at', key: 'stop_at' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: CSAlert) => (
|
||||
<Popconfirm
|
||||
title={t('cs.alert.confirmDelete', { id: row.id })}
|
||||
onConfirm={() => deleteAlert.mutate(row.id)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('cs.alert.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={alerts ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Bouncers tab ----------------------------------------------------
|
||||
|
||||
function BouncersTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: bouncers, isLoading } = useQuery({
|
||||
queryKey: ['crowdsec', 'bouncers'],
|
||||
queryFn: fetchBouncers,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const deleteBouncer = useMutation({
|
||||
mutationFn: (name: string) => apiClient.delete(`/crowdsec/bouncers/${encodeURIComponent(name)}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: t('cs.bouncer.name'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('cs.bouncer.ip'), dataIndex: 'ip_address', key: 'ip_address' },
|
||||
{ title: t('cs.bouncer.validKey'), dataIndex: 'revoked', key: 'revoked',
|
||||
render: (v: boolean) => !v
|
||||
? <Tag color="green"><CheckCircleOutlined /> OK</Tag>
|
||||
: <Tag color="red"><CloseCircleOutlined /> revoked</Tag> },
|
||||
{ title: t('cs.bouncer.version'), dataIndex: 'version', key: 'version' },
|
||||
{ title: t('cs.bouncer.type'), dataIndex: 'type', key: 'type' },
|
||||
{ title: t('cs.bouncer.lastPull'), dataIndex: 'last_pull', key: 'last_pull' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: Bouncer) => (
|
||||
<Popconfirm
|
||||
title={t('cs.bouncer.confirmDelete', { name: row.name })}
|
||||
onConfirm={() => deleteBouncer.mutate(row.name)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('cs.bouncer.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="name"
|
||||
loading={isLoading}
|
||||
dataSource={bouncers ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Machines tab ----------------------------------------------------
|
||||
|
||||
function MachinesTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: machines, isLoading } = useQuery({
|
||||
queryKey: ['crowdsec', 'machines'],
|
||||
queryFn: fetchMachines,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const deleteMachine = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/crowdsec/machines/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: t('cs.machine.id'), dataIndex: 'machineId', key: 'machineId' },
|
||||
{ title: t('cs.machine.created'), dataIndex: 'created_at', key: 'created_at' },
|
||||
{ title: t('cs.machine.lastPush'), dataIndex: 'last_push', key: 'last_push' },
|
||||
{ title: t('cs.machine.validated'), dataIndex: 'isValidated', key: 'isValidated',
|
||||
render: (v: boolean) => v
|
||||
? <Tag color="green">ja</Tag>
|
||||
: <Tag color="orange">nein</Tag> },
|
||||
{ title: t('cs.machine.version'), dataIndex: 'version', key: 'version' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: Machine) => (
|
||||
<Popconfirm
|
||||
title={t('cs.machine.confirmDelete', { id: row.machineId })}
|
||||
onConfirm={() => deleteMachine.mutate(row.machineId)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('cs.machine.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="machineId"
|
||||
loading={isLoading}
|
||||
dataSource={machines ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Collections tab -------------------------------------------------
|
||||
|
||||
function CollectionsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: collections, isLoading } = useQuery({
|
||||
queryKey: ['crowdsec', 'collections'],
|
||||
queryFn: fetchCollections,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const installCollection = useMutation({
|
||||
mutationFn: (name: string) => apiClient.post(`/crowdsec/collections/${encodeURIComponent(name)}/install`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const removeCollection = useMutation({
|
||||
mutationFn: (name: string) => apiClient.delete(`/crowdsec/collections/${encodeURIComponent(name)}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
|
||||
})
|
||||
|
||||
const isEnabled = (status: string) =>
|
||||
status === 'enabled' || status === 'downloaded'
|
||||
|
||||
const columns = [
|
||||
{ title: t('cs.collection.name'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('cs.collection.status'), dataIndex: 'status', key: 'status',
|
||||
render: (v: string) => (
|
||||
<Tag color={isEnabled(v) ? 'green' : 'default'}>
|
||||
{isEnabled(v) ? t('cs.collection.enabled') : t('cs.collection.disabled')}
|
||||
</Tag>
|
||||
) },
|
||||
{ title: t('cs.collection.version'), dataIndex: 'local_version', key: 'local_version' },
|
||||
{ title: t('cs.collection.author'), dataIndex: 'author', key: 'author' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: HubItem) => (
|
||||
<Space size={4}>
|
||||
{!isEnabled(row.status) ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={installCollection.isPending}
|
||||
onClick={() => installCollection.mutate(row.name)}
|
||||
>
|
||||
{t('cs.collection.install')}
|
||||
</Button>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={t('cs.collection.confirmRemove', { name: row.name })}
|
||||
onConfirm={() => removeCollection.mutate(row.name)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('cs.collection.remove')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="name"
|
||||
loading={isLoading}
|
||||
dataSource={collections ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 50 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Page ------------------------------------------------------------
|
||||
|
||||
export default function CrowdSecPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['crowdsec', 'status'],
|
||||
queryFn: fetchStatus,
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ key: 'decisions', label: t('cs.tabs.decisions'), children: <DecisionsTab /> },
|
||||
{ key: 'alerts', label: t('cs.tabs.alerts'), children: <AlertsTab /> },
|
||||
{ key: 'bouncers', label: t('cs.tabs.bouncers'), children: <BouncersTab /> },
|
||||
{ key: 'machines', label: t('cs.tabs.machines'), children: <MachinesTab /> },
|
||||
{ key: 'collections', label: t('cs.tabs.collections'), children: <CollectionsTab /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<RadarChartOutlined />}
|
||||
title={t('cs.title')}
|
||||
subtitle={t('cs.intro')}
|
||||
/>
|
||||
{status && !status.installed && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('cs.notInstalled')}
|
||||
className="mb-2"
|
||||
/>
|
||||
)}
|
||||
<StatusStrip status={status} />
|
||||
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
management-ui/src/pages/CrowdSec/types.ts
Normal file
79
management-ui/src/pages/CrowdSec/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface Decision {
|
||||
id: number
|
||||
origin: string
|
||||
type: string
|
||||
scope: string
|
||||
value: string
|
||||
duration: string
|
||||
reason: string
|
||||
country?: string
|
||||
as?: string
|
||||
}
|
||||
|
||||
export interface AlertSource {
|
||||
ip: string
|
||||
cn?: string
|
||||
as_name?: string
|
||||
range?: string
|
||||
scope?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: number
|
||||
scenario: string
|
||||
events_count: number
|
||||
source: AlertSource
|
||||
start_at: string
|
||||
stop_at: string
|
||||
decisions?: Decision[]
|
||||
}
|
||||
|
||||
export interface Bouncer {
|
||||
name: string
|
||||
ip_address?: string
|
||||
revoked: boolean
|
||||
last_pull?: string
|
||||
type?: string
|
||||
version?: string
|
||||
created_at: string
|
||||
auth_type?: string
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
machineId: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_push?: string
|
||||
isValidated: boolean
|
||||
version?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface HubItem {
|
||||
name: string
|
||||
description?: string
|
||||
status: string
|
||||
local_version?: string
|
||||
local_path?: string
|
||||
author?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface CrowdSecStatus {
|
||||
installed: boolean
|
||||
agent_running: boolean
|
||||
bouncer_running: boolean
|
||||
version?: string
|
||||
decision_count: number
|
||||
alert_count: number
|
||||
bouncer_count: number
|
||||
machine_count: number
|
||||
}
|
||||
|
||||
export interface AddDecisionBody {
|
||||
ip: string
|
||||
duration: string
|
||||
reason: string
|
||||
type: string
|
||||
}
|
||||
Reference in New Issue
Block a user