diff --git a/VERSION b/VERSION index daba2a9..7468c0e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.70 +1.2.71 diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 7e2548f..5389d2c 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -39,6 +39,7 @@ 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 WAFPage = lazy(() => import('./pages/WAF')) const queryClient = new QueryClient({ defaultOptions: { @@ -145,6 +146,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 2bd776e..04793c0 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -78,6 +78,7 @@ const NAV: NavSection[] = [ { path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: }, { path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: }, { path: '/crowdsec', labelKey: 'nav.crowdsec', icon: }, + { path: '/waf', labelKey: 'nav.waf', icon: }, ], }, { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 08e7f09..4e83ed0 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -34,7 +34,8 @@ "network": "Netzwerk", "security": "Sicherheit", "system": "System" - } + }, + "waf": "WAF" }, "fw": { "title": "Firewall", @@ -1759,5 +1760,46 @@ "disabled": "Nicht installiert", "confirmRemove": "Collection {{name}} wirklich entfernen?" } + }, + "waf": { + "title": "Web Application Firewall", + "intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.", + "configure": "Konfigurieren", + "toggleFailed": "WAF-Status konnte nicht geändert werden.", + "defaultOffHint": "WAF ist standardmäßig für alle Domains deaktiviert. Unten pro Domain aktivieren und konfigurieren.", + "col": { + "domain": "Domain", + "status": "WAF", + "mode": "Modus", + "paranoia": "Paranoia" + }, + "stat": { + "protected": "Geschützt", + "blocking": "Blocking", + "detection": "Nur Erkennung" + }, + "mode": { + "detection": "Erkennung", + "blocking": "Blocking" + }, + "pl": { + "1": "Basis (empfohlen)", + "2": "Standard", + "3": "Erweitert", + "4": "Maximum (kann Traffic brechen)" + }, + "config": { + "enabled": "Aktiviert", + "mode": "Modus", + "paranoia": "Paranoia-Level", + "exclusions": "Regel-Ausnahmen", + "exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).", + "trustedProxies": "Vertrauenswürdige Proxys", + "trustedProxiesHint": "IPs/CIDRs die die WAF-Inspektion umgehen (z.B. interne Load Balancer).", + "customRules": "Eigene SecRules", + "customRulesHint": "Rohe SecRule-Direktiven die nach dem CRS eingefügt werden. Können CRS-Regeln überschreiben.", + "defaultHint": "Standard: Nur-Erkennung, Paranoia-Level 1. Erst auf Blocking wechseln, nachdem Alerts geprüft wurden.", + "saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden." + } } } \ No newline at end of file diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index dba6886..08ad49e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -34,7 +34,8 @@ "network": "Network", "security": "Security", "system": "System" - } + }, + "waf": "WAF" }, "fw": { "title": "Firewall", @@ -1759,5 +1760,46 @@ "disabled": "Not installed", "confirmRemove": "Really remove collection {{name}}?" } + }, + "waf": { + "title": "Web Application Firewall", + "intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.", + "configure": "Configure", + "toggleFailed": "Could not change WAF state.", + "defaultOffHint": "WAF is disabled by default for all domains. Enable and configure per domain below.", + "col": { + "domain": "Domain", + "status": "WAF", + "mode": "Mode", + "paranoia": "Paranoia" + }, + "stat": { + "protected": "Protected", + "blocking": "Blocking", + "detection": "Detection only" + }, + "mode": { + "detection": "Detection", + "blocking": "Blocking" + }, + "pl": { + "1": "Basic (recommended)", + "2": "Standard", + "3": "Advanced", + "4": "Maximum (may break traffic)" + }, + "config": { + "enabled": "Enabled", + "mode": "Mode", + "paranoia": "Paranoia Level", + "exclusions": "Rule Exclusions", + "exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).", + "trustedProxies": "Trusted Proxies", + "trustedProxiesHint": "IPs/CIDRs that bypass WAF inspection (e.g. internal load balancers).", + "customRules": "Custom SecRules", + "customRulesHint": "Raw SecRule directives appended after the CRS. Applied last, can override CRS rules.", + "defaultHint": "Default: Detection-Only, Paranoia Level 1. Switch to Blocking only after reviewing alerts.", + "saveFailed": "Failed to save WAF configuration." + } } } \ No newline at end of file diff --git a/management-ui/src/pages/WAF/index.tsx b/management-ui/src/pages/WAF/index.tsx new file mode 100644 index 0000000..63d2957 --- /dev/null +++ b/management-ui/src/pages/WAF/index.tsx @@ -0,0 +1,400 @@ +import { useState } from 'react' +import { + Alert, Badge, Button, Card, Col, Drawer, Form, Input, Row, + Select, Space, Switch, Tag, Tooltip, Typography, message, +} from 'antd' +import { + CheckCircleOutlined, CloseCircleOutlined, + SafetyCertificateOutlined, SettingOutlined, +} 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' + +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[] + trusted_proxies: string[] + custom_rules: string +} + +// ---------- API helpers ----------------------------------------------------- + +async function fetchDomains(): Promise { + const r = await apiClient.get('/domains') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { domains?: Domain[] }).domains ?? [] +} + +async function fetchWafConfigs(): Promise { + 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 { + const r = await apiClient.get(`/waf/configs/${domainId}`) + if (isEnvelope(r.data)) return r.data.data.config as WafConfig + return defaultConfig(domainId) +} + +function defaultConfig(domainId: number): WafConfig { + return { + domain_id: domainId, + enabled: false, + mode: 'detection', + paranoia_level: 1, + rule_exclusions: [], + trusted_proxies: [], + custom_rules: '', + } +} + +// ---------- 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() + + const { data: cfg, isLoading } = useQuery({ + queryKey: ['waf', 'config', domainId], + queryFn: () => fetchWafConfig(domainId!), + enabled: domainId !== null, + }) + + 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 ( + {domainName}} + open={domainId !== null} + onClose={onClose} + width={520} + footer={ + + + + + } + > + {!isLoading && cfg && ( +
{ + const exclusions = (vals.rule_exclusions_str as unknown as string ?? '') + .split(',').map((s: string) => s.trim()).filter(Boolean) + const proxies = (vals.trusted_proxies_str as unknown as string ?? '') + .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: exclusions, + trusted_proxies: proxies, + custom_rules: vals.custom_rules ?? '', + }) + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
+ ) +} + +// ---------- 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( + (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) => ( + {name} + ), + }, + { + 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 ( + 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 + return cfg.mode === 'blocking' + ? {t('waf.mode.blocking')} + : {t('waf.mode.detection')} + }, + }, + { + title: t('waf.col.paranoia'), + key: 'paranoia', + width: 90, + render: (_: unknown, row: Domain) => { + const cfg = configMap.get(row.id) + if (!cfg?.enabled) return + return PL{cfg.paranoia_level} + }, + }, + { + title: '', + key: 'actions', + width: 110, + render: (_: unknown, row: Domain) => ( + + + + ), + }, + ] + + return ( +
+ } + title={t('waf.title')} + subtitle={t('waf.intro')} + /> + + {/* Status strip */} + + + + + + {t('waf.stat.protected')} + +
0 ? '#10B981' : '#94A3B8' }}> + {enabledCount} + / {activeDomains.length} +
+
+
+ + + + + + {t('waf.stat.blocking')} + +
+ {(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'blocking').length} +
+
+
+ + + + + + {t('waf.stat.detection')} + +
+ {(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'detection').length} +
+
+
+ +
+ + + + { + const cfg = configMap.get(row.id) + return cfg?.enabled ? '' : 'fw-rule-row--disabled' + }} + /> + + setDrawerDomain(null)} + /> +
+ ) +}