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:
Debian
2026-06-01 20:25:48 +02:00
parent e999eb68c2
commit 1740cb7ae2
15 changed files with 1561 additions and 8 deletions

View 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>
)
}

View 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
}