Files
edgeguard-native/management-ui/src/pages/WAF/index.tsx
Debian 5c268425c1 feat(waf): benutzerdefinierte App-Profile + Fix: CRS-Plugins wurden im Agent nie geladen — v1.3.17
Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme-
Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar,
Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine
Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher).
- Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) +
  waf_configs.app_profiles.
- Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate).
- Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr
  updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei
  Profil-Edit.
- UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im
  Domain-Drawer.

FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents —
selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und
KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine
inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt
damit erstmals tatsächlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:51:39 +02:00

1019 lines
35 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import {
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import {
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
WarningOutlined,
} 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 DataTable from '../../components/DataTable'
import { useAuthStore } from '../../stores/auth'
import { CRS_RULES, getRuleDescription } from './crsRules'
const { Text } = Typography
// ---------- Types -----------------------------------------------------------
interface Domain {
id: number
name: string
active: boolean
}
interface WafConfig {
id?: number
domain_id: number
enabled: boolean
mode: 'detection' | 'blocking'
paranoia_level: number
rule_exclusions: string[]
crs_plugins: string[]
app_profiles: string[]
exclusion_notes: Record<string, string>
trusted_proxies: string[]
custom_rules: string
}
interface WafProfile {
id: number
name: string
description: string
rule_exclusions: string[]
builtin: boolean
created_at?: string
updated_at?: string
}
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
const CRS_PLUGIN_OPTIONS = [
{ value: 'nextcloud', label: 'Nextcloud' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'drupal', label: 'Drupal' },
]
// ---------- API helpers -----------------------------------------------------
async function fetchDomains(): Promise<Domain[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
return (r.data.data as { domains?: Domain[] }).domains ?? []
}
async function fetchWafConfigs(): Promise<WafConfig[]> {
const r = await apiClient.get('/waf/configs')
if (!isEnvelope(r.data)) return []
return (r.data.data as { configs?: WafConfig[] }).configs ?? []
}
async function fetchWafConfig(domainId: number): Promise<WafConfig> {
const r = await apiClient.get(`/waf/configs/${domainId}`)
if (isEnvelope(r.data)) return (r.data.data as { config: WafConfig }).config
return defaultConfig(domainId)
}
function defaultConfig(domainId: number): WafConfig {
return {
domain_id: domainId,
enabled: false,
mode: 'detection',
paranoia_level: 1,
rule_exclusions: [],
crs_plugins: [],
app_profiles: [],
exclusion_notes: {},
trusted_proxies: [],
custom_rules: '',
}
}
async function fetchProfiles(): Promise<WafProfile[]> {
const r = await apiClient.get('/waf/profiles')
if (!isEnvelope(r.data)) return []
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
}
interface WafFormValues {
enabled: boolean
mode: 'detection' | 'blocking'
paranoia_level: number
crs_plugins: string[]
app_profiles: string[]
trusted_proxies_str: string
custom_rules: string
}
// ---------- Config Drawer ---------------------------------------------------
interface ConfigDrawerProps {
domainName: string
domainId: number | null
onClose: () => void
}
function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [form] = Form.useForm<WafFormValues>()
const [addRuleId, setAddRuleId] = useState<string>('')
const [addNote, setAddNote] = useState<string>('')
// Durchsuchbare Optionen aus der CRS-Regel-Liste (ID — Beschreibung).
// filterOption unten matcht sowohl ID als auch Beschreibung.
const ruleOptions = useMemo(
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id}${desc}` })),
[],
)
const { data: cfg, isLoading } = useQuery({
queryKey: ['waf', 'config', domainId],
queryFn: () => fetchWafConfig(domainId!),
enabled: domainId !== null,
})
// Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen.
const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const profileOptions = useMemo(
() => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })),
[profiles],
)
const save = useMutation({
mutationFn: (values: WafConfig) =>
apiClient.put(`/waf/configs/${domainId}`, values),
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['waf'] })
onClose()
},
onError: () => message.error(t('waf.config.saveFailed')),
})
return (
<Drawer
title={<><SafetyCertificateOutlined style={{ color: '#0EA5E9', marginRight: 8 }} />{domainName}</>}
open={domainId !== null}
onClose={onClose}
width={520}
footer={
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
type="primary"
loading={save.isPending}
disabled={isViewer}
onClick={() => form.submit()}
>
{t('common.save')}
</Button>
</Space>
}
>
{!isLoading && cfg && (
<Form
form={form}
layout="vertical"
initialValues={{
...cfg,
app_profiles: cfg.app_profiles ?? [],
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
}}
onFinish={(vals) => {
const proxies = (vals.trusted_proxies_str ?? '')
.split(',').map((s: string) => s.trim()).filter(Boolean)
save.mutate({
domain_id: domainId!,
enabled: vals.enabled,
mode: vals.mode,
paranoia_level: vals.paranoia_level,
rule_exclusions: cfg?.rule_exclusions ?? [],
crs_plugins: vals.crs_plugins ?? [],
app_profiles: vals.app_profiles ?? [],
exclusion_notes: cfg?.exclusion_notes ?? {},
trusted_proxies: proxies,
custom_rules: vals.custom_rules ?? '',
})
}}
>
<Alert
type="info"
showIcon
className="mb-16"
message={t('waf.config.defaultHint')}
/>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('waf.config.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('waf.config.mode')} name="mode">
<Select disabled={isViewer}>
<Select.Option value="detection">
<Tag color="blue">{t('waf.mode.detection')}</Tag>
</Select.Option>
<Select.Option value="blocking">
<Tag color="red">{t('waf.mode.blocking')}</Tag>
</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label={t('waf.config.paranoia')} name="paranoia_level">
<Select disabled={isViewer}>
{[1, 2, 3, 4].map(pl => (
<Select.Option key={pl} value={pl}>
PL{pl} {t(`waf.pl.${pl}`)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t('waf.config.crsPlugins')}
name="crs_plugins"
help={t('waf.config.crsPluginsHint')}
>
<Select
mode="multiple"
allowClear
disabled={isViewer}
placeholder={t('waf.config.crsPluginsPlaceholder')}
options={CRS_PLUGIN_OPTIONS}
/>
</Form.Item>
<Form.Item
label={t('waf.config.appProfiles')}
name="app_profiles"
help={t('waf.config.appProfilesHint')}
>
<Select
mode="multiple"
allowClear
disabled={isViewer}
placeholder={t('waf.config.appProfilesPlaceholder')}
options={profileOptions}
notFoundContent={t('waf.profiles.empty')}
/>
</Form.Item>
{/* Exclusions list — shows existing exclusions with notes + remove button */}
<Form.Item label={t('waf.config.exclusions')}>
{(cfg?.rule_exclusions ?? []).length === 0 ? (
<Text type="secondary" style={{ fontSize: 12 }}>{t('waf.config.noExclusions')}</Text>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
{(cfg?.rule_exclusions ?? []).map(ruleId => (
<div key={ruleId} style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
padding: '6px 10px', background: '#F8FAFC',
border: '1px solid #E2E8F0', borderRadius: 6,
}}>
<Tooltip title={getRuleDescription(Number(ruleId))} placement="topLeft">
<Tag style={{ fontFamily: 'monospace', flexShrink: 0, cursor: 'help' }}>{ruleId}</Tag>
</Tooltip>
<Text style={{ fontSize: 12, color: '#475569', flex: 1 }}>
{cfg?.exclusion_notes?.[ruleId] || <em style={{ color: '#94A3B8' }}>{t('waf.config.noNote')}</em>}
</Text>
{!isViewer && (
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => {
const newExclusions = (cfg?.rule_exclusions ?? []).filter(r => r !== ruleId)
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
delete newNotes[ruleId]
save.mutate({
domain_id: domainId!,
enabled: cfg?.enabled ?? false,
mode: cfg?.mode ?? 'detection',
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: newExclusions,
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
})
}}
/>
)}
</div>
))}
</Space>
)}
{!isViewer && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Select
showSearch
value={addRuleId || undefined}
placeholder={t('waf.config.exclusionAddPlaceholder')}
style={{ flex: 1, minWidth: 0 }}
options={ruleOptions}
optionFilterProp="label"
notFoundContent={t('waf.config.exclusionAddNotFound')}
onChange={(v) => setAddRuleId(v)}
/>
<Input
placeholder={t('waf.config.exclusionAddNote')}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
style={{ width: 150 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
loading={save.isPending}
disabled={!addRuleId}
onClick={() => {
if (!addRuleId) return
const existing = cfg?.rule_exclusions ?? []
if (existing.includes(addRuleId)) { setAddRuleId(''); setAddNote(''); return }
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
if (addNote.trim()) newNotes[addRuleId] = addNote.trim()
save.mutate({
domain_id: domainId!,
enabled: cfg?.enabled ?? false,
mode: cfg?.mode ?? 'detection',
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: [...existing, addRuleId],
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
})
setAddRuleId(''); setAddNote('')
}}
>
{t('waf.config.exclusionAddBtn')}
</Button>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
{t('waf.config.exclusionsAddHint')}
</Text>
</div>
)}
</Form.Item>
<Form.Item
label={t('waf.config.trustedProxies')}
name="trusted_proxies_str"
help={t('waf.config.trustedProxiesHint')}
>
<Input
disabled={isViewer}
placeholder="10.0.0.1, 192.168.1.0/24"
/>
</Form.Item>
<Form.Item
label={t('waf.config.customRules')}
name="custom_rules"
help={t('waf.config.customRulesHint')}
>
<Input.TextArea
disabled={isViewer}
rows={5}
placeholder={'SecRule REQUEST_URI "@contains /api/" \\\n "id:9000001,phase:1,pass,nolog"'}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
</Form>
)}
</Drawer>
)
}
// ---------- Alerts tab ------------------------------------------------------
interface WafAlert {
id: number
domain_id?: number
hostname: string
client_ip: string
method: string
uri: string
rule_id: number
rule_msg: string
severity: string
action: 'detected' | 'blocked'
created_at: string
}
async function fetchAlerts(domainId?: number): Promise<WafAlert[]> {
const params = domainId ? `?domain_id=${domainId}&limit=500` : '?limit=500'
const r = await apiClient.get(`/waf/alerts${params}`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { alerts?: WafAlert[] }).alerts ?? []
}
function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map<number, WafConfig> }) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [exceptionModal, setExceptionModal] = useState<{ domainId: number; ruleId: number } | null>(null)
const [exceptionNote, setExceptionNote] = useState('')
const { data: alerts, isLoading } = useQuery({
queryKey: ['waf', 'alerts', domainId ?? 'all'],
queryFn: () => fetchAlerts(domainId),
refetchInterval: 15_000,
})
const purge = useMutation({
mutationFn: () => apiClient.delete('/waf/alerts?days=30'),
onSuccess: () => {
message.success(t('waf.alerts.purged'))
void qc.invalidateQueries({ queryKey: ['waf', 'alerts'] })
},
})
const addException = useMutation({
mutationFn: async ({ domainId, ruleId, note }: { domainId: number; ruleId: number; note: string }) => {
const r = await apiClient.get(`/waf/configs/${domainId}`)
const cfg: WafConfig = isEnvelope(r.data)
? (r.data.data as { config: WafConfig }).config
: defaultConfig(domainId)
const exclusions = [...(cfg.rule_exclusions ?? [])]
const notes = { ...(cfg.exclusion_notes ?? {}) }
const ruleStr = String(ruleId)
if (!exclusions.includes(ruleStr)) exclusions.push(ruleStr)
if (note.trim()) notes[ruleStr] = note.trim()
return apiClient.put(`/waf/configs/${domainId}`, { ...cfg, rule_exclusions: exclusions, exclusion_notes: notes })
},
onSuccess: () => {
message.success(t('waf.alerts.exceptionAdded'))
setExceptionModal(null)
setExceptionNote('')
void qc.invalidateQueries({ queryKey: ['waf'] })
},
onError: () => message.error(t('waf.alerts.exceptionFailed')),
})
const severityColor = (s: string) => {
switch (s?.toLowerCase()) {
case 'critical': return 'red'
case 'error': return 'red'
case 'warning': return 'orange'
case 'notice': return 'blue'
default: return 'default'
}
}
const columns = [
{
title: t('waf.alerts.col.time'),
dataIndex: 'created_at',
key: 'created_at',
width: 155,
render: (v: string) => (
<Text style={{ fontSize: 11, fontFamily: 'monospace' }}>
{new Date(v).toLocaleString()}
</Text>
),
},
{
title: t('waf.alerts.col.action'),
dataIndex: 'action',
key: 'action',
width: 100,
render: (v: string) => v === 'blocked'
? <Tag color="red"><CloseCircleOutlined /> {t('waf.alerts.blocked')}</Tag>
: <Tag color="blue"><WarningOutlined /> {t('waf.alerts.detected')}</Tag>,
},
{ title: t('waf.alerts.col.hostname'), dataIndex: 'hostname', key: 'hostname', width: 180,
render: (v: string) => <Text style={{ fontSize: 12 }}>{v}</Text> },
{ title: t('waf.alerts.col.clientIp'), dataIndex: 'client_ip', key: 'client_ip', width: 120,
render: (v: string) => <Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{v}</Text> },
{ title: t('waf.alerts.col.method'), dataIndex: 'method', key: 'method', width: 70 },
{ title: t('waf.alerts.col.uri'), dataIndex: 'uri', key: 'uri', ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{v}</Text> },
{ title: t('waf.alerts.col.ruleId'), dataIndex: 'rule_id', key: 'rule_id', width: 90,
render: (v: number) => (
<Tooltip title={getRuleDescription(v)} placement="topLeft">
<Tag style={{ fontFamily: 'monospace', cursor: 'help' }}>{v}</Tag>
</Tooltip>
) },
{ title: t('waf.alerts.col.severity'), dataIndex: 'severity', key: 'severity', width: 90,
render: (v: string) => <Tag color={severityColor(v)}>{v || '—'}</Tag> },
{ title: t('waf.alerts.col.msg'), dataIndex: 'rule_msg', key: 'rule_msg', ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11, color: '#64748B' }}>{v || '—'}</Text> },
{
title: '',
key: 'exception',
width: 130,
render: (_: unknown, row: WafAlert) => {
const isExcluded = !!row.domain_id &&
(configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id))
if (isExcluded) {
return (
<Tag color="success" icon={<CheckCircleOutlined />}>
{t('waf.alerts.alreadyExcluded')}
</Tag>
)
}
const canExclude = !!row.domain_id && row.rule_id > 0 && !isViewer
return (
<Tooltip title={!row.domain_id ? t('waf.alerts.noDomain') : undefined}>
<Button
size="small"
disabled={!canExclude}
onClick={() => {
if (!row.domain_id) return
setExceptionModal({ domainId: row.domain_id, ruleId: row.rule_id })
setExceptionNote('')
}}
>
{t('waf.alerts.addException')}
</Button>
</Tooltip>
)
},
},
]
return (
<div className="mt-2">
<div className="flex-between mb-12">
<Text type="secondary" style={{ fontSize: 12 }}>
{(alerts ?? []).length} {t('waf.alerts.total')}
</Text>
<Popconfirm
title={t('waf.alerts.purgeConfirm')}
onConfirm={() => purge.mutate()}
disabled={isViewer}
>
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} loading={purge.isPending}>
{t('waf.alerts.purge30d')}
</Button>
</Popconfirm>
</div>
{(alerts ?? []).length === 0 && !isLoading ? (
<Alert type="success" showIcon message={t('waf.alerts.empty')} />
) : (
<DataTable
rowKey="id"
loading={isLoading}
dataSource={alerts ?? []}
columns={columns}
rowClassName={(row: WafAlert) => {
const isExcluded = !!row.domain_id &&
(configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id))
if (isExcluded) return 'waf-alert-row--excluded'
if (row.action === 'blocked') return 'fw-rule-row--zero-hit'
return ''
}}
/>
)}
{/* Exception note modal */}
<Modal
title={t('waf.alerts.exceptionModalTitle', { rule: exceptionModal?.ruleId })}
open={exceptionModal !== null}
onCancel={() => { setExceptionModal(null); setExceptionNote('') }}
onOk={() => exceptionModal && addException.mutate({
domainId: exceptionModal.domainId,
ruleId: exceptionModal.ruleId,
note: exceptionNote,
})}
confirmLoading={addException.isPending}
okText={t('waf.alerts.addException')}
>
<p style={{ marginBottom: 12, color: '#475569', fontSize: 13 }}>
{t('waf.alerts.exceptionModalHint')}
</p>
<Input.TextArea
rows={3}
placeholder={t('waf.alerts.exceptionNotePlaceholder')}
value={exceptionNote}
onChange={e => setExceptionNote(e.target.value)}
autoFocus
/>
</Modal>
</div>
)
}
// ---------- Profiles tab ----------------------------------------------------
interface ProfileFormValues {
name: string
description: string
rule_exclusions: string[]
}
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [form] = Form.useForm<ProfileFormValues>()
const isCreate = profile !== null && profile.id === 0
const ruleOptions = useMemo(
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id}${desc}` })),
[],
)
// Formular bei jedem Öffnen/Wechsel neu befüllen.
useEffect(() => {
if (profile) {
form.setFieldsValue({
name: profile.name,
description: profile.description,
rule_exclusions: profile.rule_exclusions ?? [],
})
}
}, [profile, form])
const save = useMutation({
mutationFn: (vals: ProfileFormValues) =>
isCreate
? apiClient.post('/waf/profiles', vals)
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
onSuccess: () => {
message.success(t('waf.profiles.saved'))
void qc.invalidateQueries({ queryKey: ['waf'] })
onClose()
},
onError: () => message.error(t('waf.profiles.saveFailed')),
})
return (
<Drawer
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
open={profile !== null}
onClose={onClose}
width={520}
footer={
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
{t('common.save')}
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={(vals) => save.mutate({
name: vals.name,
description: vals.description ?? '',
rule_exclusions: vals.rule_exclusions ?? [],
})}
>
<Form.Item
label={t('waf.profiles.name')}
name="name"
rules={[{ required: true, max: 60 }]}
>
<Input placeholder={t('waf.profiles.namePlaceholder')} />
</Form.Item>
<Form.Item label={t('waf.profiles.description')} name="description">
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
</Form.Item>
<Form.Item
label={t('waf.profiles.rules')}
name="rule_exclusions"
help={t('waf.profiles.rulesHint')}
>
<Select
mode="multiple"
showSearch
allowClear
placeholder={t('waf.profiles.rulesPlaceholder')}
options={ruleOptions}
optionFilterProp="label"
/>
</Form.Item>
</Form>
</Drawer>
)
}
function ProfilesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [editing, setEditing] = useState<WafProfile | null>(null)
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const custom = (profiles ?? []).filter(p => !p.builtin)
const del = useMutation({
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
onSuccess: () => {
message.success(t('waf.profiles.deleted'))
void qc.invalidateQueries({ queryKey: ['waf'] })
},
onError: () => message.error(t('waf.profiles.deleteFailed')),
})
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
id: 0,
name: seed?.name ?? '',
description: seed?.description ?? '',
rule_exclusions: seed?.rule_exclusions ?? [],
builtin: false,
})
const columns = [
{
title: t('waf.profiles.col.name'),
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
},
{
title: t('waf.profiles.col.description'),
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
},
{
title: t('waf.profiles.col.rules'),
key: 'rules',
width: 90,
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
},
{
title: '',
key: 'actions',
width: 210,
render: (_: unknown, row: WafProfile) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
{t('waf.profiles.edit')}
</Button>
<Tooltip title={t('waf.profiles.clone')}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={isViewer}
onClick={() => openCreate({
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
description: row.description,
rule_exclusions: row.rule_exclusions,
})}
/>
</Tooltip>
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
</Popconfirm>
</Space>
),
},
]
return (
<div className="mt-2">
<Alert
type="info"
showIcon
className="mb-16"
message={t('waf.profiles.intro')}
description={
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{CRS_PLUGIN_OPTIONS.map(p => (
<Space key={p.value} size={2}>
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
{!isViewer && (
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
{t('waf.profiles.asTemplate')}
</Button>
)}
</Space>
))}
</div>
</div>
}
/>
<div className="flex-between mb-12">
<Text type="secondary" style={{ fontSize: 12 }}>
{custom.length} {t('waf.profiles.typeCustom')}
</Text>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
{t('waf.profiles.new')}
</Button>
</div>
{custom.length === 0 && !isLoading ? (
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
) : (
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
)}
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
</div>
)
}
// ---------- Page ------------------------------------------------------------
export default function WAFPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [drawerDomain, setDrawerDomain] = useState<{ id: number; name: string } | null>(null)
const { data: domains } = useQuery({
queryKey: ['domains'],
queryFn: fetchDomains,
})
const { data: wafConfigs, isLoading } = useQuery({
queryKey: ['waf', 'configs'],
queryFn: fetchWafConfigs,
refetchInterval: 30_000,
})
const quickToggle = useMutation({
mutationFn: async ({ domainId, enabled }: { domainId: number; enabled: boolean }) => {
const existing = wafConfigs?.find(c => c.domain_id === domainId) ?? defaultConfig(domainId)
return apiClient.put(`/waf/configs/${domainId}`, { ...existing, enabled })
},
onSuccess: () => void qc.invalidateQueries({ queryKey: ['waf'] }),
onError: () => message.error(t('waf.toggleFailed')),
})
const configMap = new Map<number, WafConfig>(
(wafConfigs ?? []).map(c => [c.domain_id, c])
)
const activeDomains = (domains ?? []).filter(d => d.active)
const enabledCount = (wafConfigs ?? []).filter(c => c.enabled).length
const columns = [
{
title: t('waf.col.domain'),
dataIndex: 'name',
key: 'name',
render: (name: string) => (
<Text strong style={{ fontSize: 13 }}>{name}</Text>
),
},
{
title: t('waf.col.status'),
key: 'status',
width: 80,
render: (_: unknown, row: Domain) => {
const cfg = configMap.get(row.id)
const enabled = cfg?.enabled ?? false
return (
<Switch
size="small"
checked={enabled}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.domainId === row.id}
onChange={(checked) => quickToggle.mutate({ domainId: row.id, enabled: checked })}
/>
)
},
},
{
title: t('waf.col.mode'),
key: 'mode',
width: 130,
render: (_: unknown, row: Domain) => {
const cfg = configMap.get(row.id)
if (!cfg?.enabled) return <Text type="secondary"></Text>
return cfg.mode === 'blocking'
? <Tag color="red"><CloseCircleOutlined /> {t('waf.mode.blocking')}</Tag>
: <Tag color="blue"><CheckCircleOutlined /> {t('waf.mode.detection')}</Tag>
},
},
{
title: t('waf.col.paranoia'),
key: 'paranoia',
width: 90,
render: (_: unknown, row: Domain) => {
const cfg = configMap.get(row.id)
if (!cfg?.enabled) return <Text type="secondary"></Text>
return <Tag>PL{cfg.paranoia_level}</Tag>
},
},
{
title: '',
key: 'actions',
width: 110,
render: (_: unknown, row: Domain) => (
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('waf.configure')}>
<Button
size="small"
icon={<SettingOutlined />}
disabled={isViewer}
onClick={() => setDrawerDomain({ id: row.id, name: row.name })}
>
{t('waf.configure')}
</Button>
</Tooltip>
),
},
]
return (
<div>
<PageHeader
icon={<SafetyCertificateOutlined />}
title={t('waf.title')}
subtitle={t('waf.intro')}
/>
{/* Status strip */}
<Row gutter={[12, 12]} className="mb-16">
<Col xs={12} sm={8} md={6}>
<Card size="small">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('waf.stat.protected')}
</Text>
<div style={{ fontSize: 22, fontWeight: 600, color: enabledCount > 0 ? '#10B981' : '#94A3B8' }}>
{enabledCount}
<span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {activeDomains.length}</span>
</div>
</Space>
</Card>
</Col>
<Col xs={12} sm={8} md={6}>
<Card size="small">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('waf.stat.blocking')}
</Text>
<div style={{ fontSize: 22, fontWeight: 600, color: '#EF4444' }}>
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'blocking').length}
</div>
</Space>
</Card>
</Col>
<Col xs={12} sm={8} md={6}>
<Card size="small">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('waf.stat.detection')}
</Text>
<div style={{ fontSize: 22, fontWeight: 600, color: '#0EA5E9' }}>
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'detection').length}
</div>
</Space>
</Card>
</Col>
</Row>
<Tabs
type="card"
items={[
{
key: 'domains',
label: t('waf.tabs.domains'),
children: (
<>
<Alert type="info" showIcon className="mb-16" message={t('waf.defaultOffHint')} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={activeDomains}
columns={columns}
rowClassName={(row: Domain) => {
const cfg = configMap.get(row.id)
return cfg?.enabled ? '' : 'fw-rule-row--disabled'
}}
/>
</>
),
},
{
key: 'profiles',
label: t('waf.tabs.profiles'),
children: <ProfilesTab />,
},
{
key: 'alerts',
label: t('waf.tabs.alerts'),
children: <AlertsTab configMap={configMap} />,
},
]}
/>
<ConfigDrawer
domainName={drawerDomain?.name ?? ''}
domainId={drawerDomain?.id ?? null}
onClose={() => setDrawerDomain(null)}
/>
</div>
)
}