Files
edgeguard-native/management-ui/src/pages/CrowdSec/index.tsx
Debian 414dad6b3b feat(crowdsec): IDS ein-/ausschalten via Switch — v1.2.63
- system.go: ServiceToggle-Endpoint (POST /system/service-toggle)
  start/stop + enable/disable für crowdsec + crowdsec-firewall-bouncer
- system.go: crowdsec + crowdsec-firewall-bouncer in servicesToCheck
- postinst: sudoers-Einträge für systemctl start/stop/enable/disable
  beider CrowdSec-Units
- UI: Switch im StatusStrip für Agent + Bouncer, getrennt schaltbar,
  disabled wenn CrowdSec nicht installiert oder Viewer-Rolle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:17:22 +02:00

568 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
Alert,
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Switch,
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 { useAuthStore } from '../../stores/auth'
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 ?? []
}
async function toggleService(service: string, enabled: boolean): Promise<void> {
await apiClient.post('/system/service-toggle', { service, enabled })
}
// ---------- Status strip ----------------------------------------------------
function StatusStrip({ status, onToggle }: { status: CrowdSecStatus | undefined; onToggle: () => void }) {
const { t } = useTranslation()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const toggleAgent = useMutation({
mutationFn: (enabled: boolean) => toggleService('crowdsec', enabled),
onSuccess: onToggle,
})
const toggleBouncer = useMutation({
mutationFn: (enabled: boolean) => toggleService('crowdsec-firewall-bouncer', enabled),
onSuccess: onToggle,
})
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">
<Space size={8}>
<Tag
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
color={status?.agent_running ? 'green' : 'red'}
>
{status?.agent_running ? t('cs.status.running') : t('cs.status.stopped')}
</Tag>
<Switch
size="small"
checked={status?.agent_running ?? false}
disabled={isViewer || !status?.installed}
loading={toggleAgent.isPending}
onChange={(checked) => toggleAgent.mutate(checked)}
/>
</Space>
</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">
<Space size={8}>
<Tag
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
color={status?.bouncer_running ? 'green' : 'red'}
>
{status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped')}
</Tag>
<Switch
size="small"
checked={status?.bouncer_running ?? false}
disabled={isViewer || !status?.installed}
loading={toggleBouncer.isPending}
onChange={(checked) => toggleBouncer.mutate(checked)}
/>
</Space>
</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 queryClient = useQueryClient()
const { data: status } = useQuery({
queryKey: ['crowdsec', 'status'],
queryFn: fetchStatus,
refetchInterval: 10_000,
})
const invalidateStatus = () => {
void queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] })
}
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} onToggle={invalidateStatus} />
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
</div>
)
}