- 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>
568 lines
18 KiB
TypeScript
568 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|