refactor(fwlog): Live-Log als Firewall-Tab, default-aus, Start-Button

UI-Restruktur nach User-Feedback:
- Sidebar-Eintrag „Firewall-Live" entfernt — gehört thematisch unter
  Firewall, kein Top-Level-Item. Standalone-Page /firewall-live raus.
- Neuer Firewall-Tab „Live-Log" zwischen NAT und Zonen.
- Default = AUS: zeigt Empty-State mit Start-Button. WebSocket
  verbindet erst nach Klick. Stop-Button schließt explizit.
- Filter-Inputs (src/dst/rule_id) jetzt 300ms debounced — vorher
  triggerte jeder Tastendruck einen WS-Reconnect.

Server-Pipeline „wirklich live" gepinnt:
- ulogd.conf NFLOG-Plugin bekommt qthreshold=1 + qtimeout=1. Default
  des Kernels batched Pakete bis 1024 oder 1s; mit 1/1 fließt jedes
  Paket sofort. Critical für die Wahrnehmung „live" statt „bursty".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 21:50:52 +02:00
parent 827c364335
commit 9642a6adfe
12 changed files with 182 additions and 142 deletions

View File

@@ -1 +1 @@
1.0.62
1.0.63

View File

@@ -50,7 +50,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
var version = "1.0.62"
var version = "1.0.63"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os"
)
var version = "1.0.62"
var version = "1.0.63"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -24,7 +24,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.0.62"
var version = "1.0.63"
const (
// renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -25,7 +25,6 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
const DNSPage = lazy(() => import('./pages/DNS'))
const NTPPage = lazy(() => import('./pages/NTP'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
const LogsPage = lazy(() => import('./pages/Logs'))
const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings'))
@@ -111,7 +110,6 @@ export default function App() {
<Route path="/dns" element={<DNSPage />} />
<Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/firewall-live" element={<FirewallLivePage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/license" element={<LicensePage />} />
<Route path="/settings" element={<SettingsPage />} />

View File

@@ -16,7 +16,6 @@ const PAGE_TITLES: Record<string, string> = {
'/routing-rules': 'nav.routing',
'/networks': 'nav.networks',
'/ip-addresses': 'nav.ipAddresses',
'/firewall-live': 'nav.firewallLive',
'/cluster': 'nav.cluster',
'/logs': 'nav.logs',
'/license': 'nav.license',

View File

@@ -7,7 +7,6 @@ import {
ClusterOutlined,
CrownOutlined,
DashboardOutlined,
EyeOutlined,
FileSearchOutlined,
DatabaseOutlined,
FireOutlined,
@@ -63,7 +62,6 @@ const NAV: NavSection[] = [
labelKey: 'nav.section.security',
items: [
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
{ path: '/firewall-live', labelKey: 'nav.firewallLive', icon: <EyeOutlined /> },
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
],
@@ -79,7 +77,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.62'
const VERSION = '1.0.63'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -36,6 +36,7 @@
"tabs": {
"rules": "Regeln",
"nat": "NAT",
"live": "Live-Log",
"zones": "Zonen",
"addrObj": "Adress-Objekte",
"addrGrp": "Adress-Gruppen",
@@ -648,8 +649,12 @@
"fwlog": {
"title": "Firewall-Log (Live)",
"intro": "Pakete, die in nft-Regeln mit aktivem Log-Flag matchen, fließen via NFLOG → ulogd2 → JSONL hierher. WebSocket-Stream zeigt Live-Events; Ring-Buffer (1000) hält die letzten Treffer auch nach Reconnect.",
"start": "Live-Log starten",
"stop": "Stop",
"notStartedTitle": "Live-Log ist aus",
"notStartedDesc": "Standardmäßig pausiert — Klick zum Verbinden lässt Events live einfließen.",
"live": "Live",
"disconnected": "getrennt",
"disconnected": "verbinde …",
"pause": "Pause",
"resume": "Fortsetzen",
"queued": "wartend",

View File

@@ -36,6 +36,7 @@
"tabs": {
"rules": "Rules",
"nat": "NAT",
"live": "Live log",
"zones": "Zones",
"addrObj": "Address objects",
"addrGrp": "Address groups",
@@ -648,8 +649,12 @@
"fwlog": {
"title": "Firewall log (live)",
"intro": "Packets matching nft rules with the log flag enabled flow via NFLOG → ulogd2 → JSONL into this view. WebSocket stream shows live events; ring buffer (1000) keeps recent hits across reconnects.",
"start": "Start live log",
"stop": "Stop",
"notStartedTitle": "Live log is off",
"notStartedDesc": "Paused by default — click to connect and see events flowing in.",
"live": "Live",
"disconnected": "disconnected",
"disconnected": "connecting …",
"pause": "Pause",
"resume": "Resume",
"queued": "queued",

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert, Button, Card, Input, Select, Space, Table, Tag, Tooltip, Typography, message,
Alert, Button, Card, Empty, Input, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
AlertOutlined,
ClearOutlined,
DownloadOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
ClearOutlined,
FireOutlined,
PoweroffOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
// Entry-Shape kommt vom Backend (internal/services/firewalllog/reader.go).
// Bewusst dünn — alles was die Linie aus ulogd2-JSON lesen kann.
interface Entry {
timestamp: string
rule_id?: string
@@ -34,13 +30,8 @@ interface Entry {
action?: string
}
// Ring-Buffer im UI — auch wir cappen auf 1000 damit die Tabelle
// nicht das DOM in die Knie zwingt.
const UI_RING = 1000
// wsURL baut wss://<host>/api/v1/firewall/log/live aus dem aktuellen
// Document. Beim Dev-Vite-Server läuft's über das proxy-passthrough auf
// edgeguard-api (Vite-Config hat /api → 127.0.0.1:9443).
function wsURL(query: string): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const base = `${proto}//${window.location.host}/api/v1/firewall/log/live`
@@ -57,20 +48,16 @@ interface Filters {
function buildQuery(f: Filters): string {
const p = new URLSearchParams()
if (f.action) p.set('action', f.action)
if (f.proto) p.set('proto', f.proto)
if (f.src) p.set('src', f.src)
if (f.dst) p.set('dst', f.dst)
if (f.action) p.set('action', f.action)
if (f.proto) p.set('proto', f.proto)
if (f.src) p.set('src', f.src)
if (f.dst) p.set('dst', f.dst)
if (f.rule_id) p.set('rule_id', f.rule_id)
p.set('limit', '1000')
return p.toString()
}
function actionTag(action: string | undefined, prefix: string | undefined) {
// ulogd setzt "action" pauschal auf "blocked" — der Operator
// möchte aber wissen ob's accept/drop/reject war. Da nft das
// im Prefix nicht direkt mitschickt (nur die Rule-ID), zeigen
// wir den prefix-Text als Sekundär-Info.
const a = (action || '').toLowerCase()
let color: string = 'default'
let label = action || '—'
@@ -90,8 +77,8 @@ function protoTag(p?: string) {
if (!p) return <Tag></Tag>
const lower = p.toLowerCase()
const color =
lower === 'tcp' ? 'geekblue' :
lower === 'udp' ? 'cyan' :
lower === 'tcp' ? 'geekblue' :
lower === 'udp' ? 'cyan' :
lower.startsWith('icmp') ? 'purple' : 'default'
return <Tag color={color}>{lower}</Tag>
}
@@ -114,34 +101,55 @@ function toCSV(rows: Entry[]): string {
return lines.join('\n')
}
export default function FirewallLivePage() {
// LiveLog Tab — sitzt im Firewall-Page-Tab-Stack. Standardmäßig
// DISCONNECTED — der WebSocket wird erst beim Klick auf „Start"
// aufgebaut. Stop schließt explizit, Filter-Änderungen reconnecten
// nur wenn aktiv.
export default function LiveLogTab() {
const { t } = useTranslation()
const [entries, setEntries] = useState<Entry[]>([])
const [active, setActive] = useState(false) // Start/Stop master switch
const [paused, setPaused] = useState(false)
const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const [entries, setEntries] = useState<Entry[]>([])
const [filters, setFilters] = useState<Filters>({
action: '', proto: '', src: '', dst: '', rule_id: '',
})
// Debounced Filter-Mirror — Text-Inputs schreiben in `filters`,
// ein useEffect spiegelt sie 300ms später in `appliedFilters`, was
// den WS-Reconnect triggert. So spammt nicht jeder Tastendruck
// einen Reconnect.
const [appliedFilters, setAppliedFilters] = useState<Filters>(filters)
// useRef'd damit der WS-Reconnect-Effekt nicht bei jeder Pause-
// Änderung re-runt.
const pausedRef = useRef(paused)
pausedRef.current = paused
const pendingDuringPauseRef = useRef<Entry[]>([])
const wsRef = useRef<WebSocket | null>(null)
const query = useMemo(() => buildQuery(filters), [filters])
// WS-Lifecycle — reconnect bei query-Änderung oder nach Drop.
useEffect(() => {
const t = setTimeout(() => setAppliedFilters(filters), 300)
return () => clearTimeout(t)
}, [filters])
const query = useMemo(() => buildQuery(appliedFilters), [appliedFilters])
// WS-Lifecycle — nur connecten wenn `active`. Filter-/query-
// Änderung reconnected only when active.
useEffect(() => {
if (!active) {
// Sicherheits-Cleanup wenn Tab inaktiv wird.
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
setConnected(false)
return
}
let cancelled = false
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const connect = () => {
if (cancelled) return
if (cancelled || !active) return
setError(null)
let ws: WebSocket
try {
ws = new WebSocket(wsURL(query))
} catch (e) {
@@ -149,33 +157,31 @@ export default function FirewallLivePage() {
scheduleReconnect()
return
}
wsRef.current = ws
ws.onopen = () => setConnected(true)
ws.onmessage = (ev) => {
try {
const e: Entry = JSON.parse(ev.data as string)
if (pausedRef.current) {
// während Pause: nicht ins UI rendern, aber buffern damit
// beim Resume die zwischenzeitlichen Events da sind.
pendingDuringPauseRef.current.push(e)
if (pendingDuringPauseRef.current.length > UI_RING) {
pendingDuringPauseRef.current = pendingDuringPauseRef.current.slice(-UI_RING)
pendingDuringPauseRef.current =
pendingDuringPauseRef.current.slice(-UI_RING)
}
return
}
setEntries((prev) => {
const next = prev.length >= UI_RING
return prev.length >= UI_RING
? [...prev.slice(prev.length - UI_RING + 1), e]
: [...prev, e]
return next
})
} catch {
// Parse-Fehler einer Zeilewir verlieren den Event aber
// brechen die Pipeline nicht ab.
// line parse fail — schluck, broken nicht die Pipeline
}
}
ws.onclose = () => {
setConnected(false)
if (!cancelled) scheduleReconnect()
if (!cancelled && active) scheduleReconnect()
}
ws.onerror = () => {
setError(t('fwlog.connError'))
@@ -187,18 +193,17 @@ export default function FirewallLivePage() {
reconnectTimer = setTimeout(connect, 2000)
}
setEntries([]) // bei filter-change Tabelle leeren (server schickt fresh snapshot)
setEntries([]) // bei active/query-change frischer Slate
connect()
return () => {
cancelled = true
if (reconnectTimer) clearTimeout(reconnectTimer)
if (ws) ws.close()
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
}
}, [query, t])
}, [active, query, t])
// Bei Resume die während-pause gebufferten Events in die Tabelle
// schieben.
// Resume: gebufferte Events in die Tabelle mergen.
useEffect(() => {
if (!paused && pendingDuringPauseRef.current.length > 0) {
const flushed = pendingDuringPauseRef.current
@@ -282,98 +287,121 @@ export default function FirewallLivePage() {
return (
<div>
<PageHeader
icon={<FireOutlined />}
title={t('fwlog.title')}
subtitle={t('fwlog.intro')}
extra={
<Space>
<Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}>
{connected ? t('fwlog.live') : t('fwlog.disconnected')}
</Tag>
{!active ? (
<Card style={{ textAlign: 'center', padding: '32px 16px' }}>
<Empty
image={<PoweroffOutlined style={{ fontSize: 48, color: '#94A3B8' }} />}
description={
<Space direction="vertical" size={4}>
<Text strong>{t('fwlog.notStartedTitle')}</Text>
<Text type="secondary">{t('fwlog.notStartedDesc')}</Text>
</Space>
}
>
<Button
icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={() => setPaused((p) => !p)}
type="primary"
size="large"
icon={<PlayCircleOutlined />}
onClick={() => setActive(true)}
style={{ marginTop: 16 }}
>
{paused ? t('fwlog.resume') : t('fwlog.pause')}
{t('fwlog.start')}
</Button>
<Tooltip title={t('fwlog.clearTooltip')}>
<Button icon={<ClearOutlined />} onClick={clear}>{t('fwlog.clear')}</Button>
</Tooltip>
<Tooltip title={t('fwlog.exportTooltip')}>
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button>
</Tooltip>
</Space>
}
/>
</Empty>
</Card>
) : (
<>
{error && <Alert type="warning" showIcon message={error} className="mb-16" closable />}
{error && <Alert type="warning" showIcon message={error} className="mb-16" closable />}
<Card size="small" className="mb-16">
<Space wrap>
<Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}>
{connected ? t('fwlog.live') : t('fwlog.disconnected')}
</Tag>
<Button
danger
icon={<PoweroffOutlined />}
onClick={() => setActive(false)}
>
{t('fwlog.stop')}
</Button>
<Button
icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={() => setPaused((p) => !p)}
>
{paused ? t('fwlog.resume') : t('fwlog.pause')}
</Button>
<Tooltip title={t('fwlog.clearTooltip')}>
<Button icon={<ClearOutlined />} onClick={clear}>{t('fwlog.clear')}</Button>
</Tooltip>
<Tooltip title={t('fwlog.exportTooltip')}>
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button>
</Tooltip>
<Select
placeholder={t('fwlog.filter.action')}
allowClear
style={{ width: 140 }}
value={filters.action || undefined}
onChange={(v) => setFilters((f) => ({ ...f, action: v || '' }))}
options={[
{ value: 'accept', label: 'accept' },
{ value: 'drop', label: 'drop' },
{ value: 'reject', label: 'reject' },
{ value: 'blocked', label: 'blocked' },
]}
/>
<Select
placeholder={t('fwlog.filter.proto')}
allowClear
style={{ width: 120 }}
value={filters.proto || undefined}
onChange={(v) => setFilters((f) => ({ ...f, proto: v || '' }))}
options={[
{ value: 'tcp', label: 'tcp' },
{ value: 'udp', label: 'udp' },
{ value: 'icmp', label: 'icmp' },
{ value: 'icmpv6', label: 'icmpv6' },
]}
/>
<Input
placeholder={t('fwlog.filter.src')}
allowClear
style={{ width: 160 }}
value={filters.src}
onChange={(e) => setFilters((f) => ({ ...f, src: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.dst')}
allowClear
style={{ width: 160 }}
value={filters.dst}
onChange={(e) => setFilters((f) => ({ ...f, dst: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.rule')}
allowClear
style={{ width: 100 }}
value={filters.rule_id}
onChange={(e) => setFilters((f) => ({ ...f, rule_id: e.target.value }))}
/>
<Text type="secondary">
{entries.length}{paused && pendingDuringPauseRef.current.length > 0
? ` (+${pendingDuringPauseRef.current.length} ${t('fwlog.queued')})`
: ''}
</Text>
</Space>
</Card>
<Card size="small" className="mb-16">
<Space wrap>
<Select
placeholder={t('fwlog.filter.action')}
allowClear
style={{ width: 140 }}
value={filters.action || undefined}
onChange={(v) => setFilters((f) => ({ ...f, action: v || '' }))}
options={[
{ value: 'accept', label: 'accept' },
{ value: 'drop', label: 'drop' },
{ value: 'reject', label: 'reject' },
{ value: 'blocked', label: 'blocked' },
]}
<Table
rowKey={(r) => `${r.timestamp}-${r.src_ip}-${r.dst_ip}-${r.src_port}-${r.dst_port}-${r.pkt_len}`}
size="small"
dataSource={[...entries].reverse()}
columns={columns}
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
/>
<Select
placeholder={t('fwlog.filter.proto')}
allowClear
style={{ width: 120 }}
value={filters.proto || undefined}
onChange={(v) => setFilters((f) => ({ ...f, proto: v || '' }))}
options={[
{ value: 'tcp', label: 'tcp' },
{ value: 'udp', label: 'udp' },
{ value: 'icmp', label: 'icmp' },
{ value: 'icmpv6', label: 'icmpv6' },
]}
/>
<Input
placeholder={t('fwlog.filter.src')}
allowClear
style={{ width: 160 }}
value={filters.src}
onChange={(e) => setFilters((f) => ({ ...f, src: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.dst')}
allowClear
style={{ width: 160 }}
value={filters.dst}
onChange={(e) => setFilters((f) => ({ ...f, dst: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.rule')}
allowClear
style={{ width: 100 }}
value={filters.rule_id}
onChange={(e) => setFilters((f) => ({ ...f, rule_id: e.target.value }))}
/>
<Text type="secondary">
{entries.length}{paused && pendingDuringPauseRef.current.length > 0
? ` (+${pendingDuringPauseRef.current.length} ${t('fwlog.queued')})`
: ''}
</Text>
</Space>
</Card>
<Table
rowKey={(r) => `${r.timestamp}-${r.src_ip}-${r.dst_ip}-${r.src_port}-${r.dst_port}-${r.pkt_len}`}
size="small"
dataSource={[...entries].reverse()}
columns={columns}
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
/>
</>
)}
</div>
)
}

View File

@@ -10,6 +10,7 @@ import ServiceGroupsTab from './ServiceGroups'
import RulesTab from './Rules'
import NATRulesTab from './NATRules'
import ZonesTab from './Zones'
import LiveLogTab from './LiveLog'
export default function FirewallPage() {
const { t } = useTranslation()
@@ -17,6 +18,7 @@ export default function FirewallPage() {
const tabs = [
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
{ key: 'live', label: t('fw.tabs.live'), children: <LiveLogTab /> },
{ key: 'zones', label: t('fw.tabs.zones'), children: <ZonesTab /> },
{ key: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },

View File

@@ -243,6 +243,11 @@ stack=fw1:NFLOG,base1:BASE,ifi1:IFINDEX,ip2str1:IP2STR,mac2str1:HWHDR,json1:JSON
[fw1]
group=0
# qthreshold=1 + qtimeout=1: Kernel batched sonst Pakete bis 1024 oder
# bis 1s. Mit 1/1 → jedes Paket SOFORT raus, kein Sammeln. Wichtig
# damit die Live-Log-UI in real-time fließt statt in Bursts.
qthreshold=1
qtimeout=1
[json1]
sync=1