feat(firewall): nftables hit-counter pro Regel + Live-Hits-Spalte in der UI

- ruleset.nft.tpl: operator-rules bekommen `counter <action> comment "egid:<id>"`
  → nft zählt Packets + Bytes per Regel ab dem letzten Ruleset-Apply
- handlers/firewall_counters.go: GET /firewall/counters parst
  `sudo nft list table inet edgeguard` per Regex, liefert [{rule_id,packets,bytes}]
- handlers/firewall_counters_test.go: unit-tests für parseNFTCounters
- Firewall/Rules.tsx: neue "Hits"-Spalte (Packet-Count, Tooltip mit Bytes),
  10s-Polling via TanStack Query ['fw','counters']
- i18n de+en: fw.rule.hits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-21 08:42:31 +02:00
parent 9a05e8bca8
commit f4d178cb38
8 changed files with 176 additions and 3 deletions

View File

@@ -1 +1 @@
1.1.48 1.1.49

View File

@@ -65,7 +65,7 @@ table inet edgeguard {
die Comment-Zeile angehängt — sonst frisst nft die rule die Comment-Zeile angehängt — sonst frisst nft die rule
als Teil des # Kommentars). */ -}} als Teil des # Kommentars). */ -}}
{{""}} {{""}}
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}{{.Action}} {{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
{{end}} {{end}}
# ── DEFAULT-DROP LOGGING ─────────────────────────────────────── # ── DEFAULT-DROP LOGGING ───────────────────────────────────────

View File

@@ -96,6 +96,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/firewall") g := rg.Group("/firewall")
g.GET("/auto-rules", h.AutoRules) g.GET("/auto-rules", h.AutoRules)
g.GET("/counters", h.Counters)
zn := g.Group("/zones") zn := g.Group("/zones")
zn.GET("", h.ListZone) zn.GET("", h.ListZone)

View File

@@ -0,0 +1,63 @@
package handlers
import (
"os/exec"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
)
// ruleCounter is one operator-defined firewall rule's live packet/byte
// counter. The nft ruleset template marks each rule with
// `counter … comment "egid:<id>"` so we can correlate the counter
// values back to DB rule IDs.
type ruleCounter struct {
RuleID int64 `json:"rule_id"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
}
var (
reEgid = regexp.MustCompile(`comment "egid:(\d+)"`)
reCounter = regexp.MustCompile(`\bcounter packets (\d+) bytes (\d+)\b`)
)
// Counters reads live hit-counters from the running nft ruleset.
// Runs `sudo -n /usr/sbin/nft list table inet edgeguard` (already
// allowed by the postinst sudoers rule) and parses each rule line
// for the `counter packets N bytes M … comment "egid:X"` pattern
// that the ruleset template emits.
func (h *FirewallHandler) Counters(c *gin.Context) {
out, err := exec.Command("sudo", "-n", "/usr/sbin/nft", "list", "table", "inet", "edgeguard").CombinedOutput()
if err != nil {
response.OK(c, gin.H{
"counters": []ruleCounter{},
"error": strings.TrimSpace(string(out)),
})
return
}
response.OK(c, gin.H{"counters": parseNFTCounters(string(out))})
}
func parseNFTCounters(output string) []ruleCounter {
var out []ruleCounter
for _, line := range strings.Split(output, "\n") {
mID := reEgid.FindStringSubmatch(line)
mCnt := reCounter.FindStringSubmatch(line)
if mID == nil || mCnt == nil {
continue
}
id, err1 := strconv.ParseInt(mID[1], 10, 64)
pkts, err2 := strconv.ParseInt(mCnt[1], 10, 64)
byts, err3 := strconv.ParseInt(mCnt[2], 10, 64)
if err1 != nil || err2 != nil || err3 != nil {
continue
}
out = append(out, ruleCounter{RuleID: id, Packets: pkts, Bytes: byts})
}
return out
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"testing"
)
func TestParseNFTCounters_ParsesCounterAndEgid(t *testing.T) {
// nft list output shows `counter packets N bytes M` with actual values
// (zero when unused). Template emits `counter <action> comment "egid:X"`;
// nft expands that to `counter packets N bytes M <action> comment "egid:X"`.
input := `
table inet edgeguard {
chain input {
type filter hook input priority 0; policy drop;
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH"
tcp dport 80 counter packets 0 bytes 0 accept comment "egid:1"
ip saddr 10.0.0.0/8 tcp dport 443 counter packets 1234 bytes 567890 accept comment "egid:2"
ip saddr 1.2.3.4 drop comment "egid:3"
tcp dport 8080 log prefix "edgeguard:4 " group 0 counter packets 7 bytes 420 reject comment "egid:4"
}
}
`
counters := parseNFTCounters(input)
if len(counters) != 3 {
t.Fatalf("expected 3 counters (egid:1,2,4), got %d: %+v", len(counters), counters)
}
byID := map[int64]ruleCounter{}
for _, c := range counters {
byID[c.RuleID] = c
}
// egid:1 — zero counters are valid
if c, ok := byID[1]; !ok {
t.Error("missing counter for egid:1")
} else if c.Packets != 0 || c.Bytes != 0 {
t.Errorf("egid:1 want packets=0 bytes=0, got packets=%d bytes=%d", c.Packets, c.Bytes)
}
// egid:2 — non-zero counters
if c, ok := byID[2]; !ok {
t.Error("missing counter for egid:2")
} else if c.Packets != 1234 || c.Bytes != 567890 {
t.Errorf("egid:2 want packets=1234 bytes=567890, got packets=%d bytes=%d", c.Packets, c.Bytes)
}
// egid:3 — line has no counter keyword → must not appear
if _, ok := byID[3]; ok {
t.Error("egid:3 has no counter statement and must not appear in output")
}
// egid:4 — counter + log + reject
if _, ok := byID[4]; !ok {
t.Error("missing counter for egid:4")
}
}
func TestParseNFTCounters_EmptyOutput(t *testing.T) {
if counters := parseNFTCounters(""); len(counters) != 0 {
t.Errorf("expected empty result, got %+v", counters)
}
}
func TestParseNFTCounters_NoRules(t *testing.T) {
input := "table inet edgeguard {\n chain input {\n }\n}\n"
if counters := parseNFTCounters(input); len(counters) != 0 {
t.Errorf("expected empty result for table with no operator rules, got %+v", counters)
}
}

View File

@@ -101,6 +101,7 @@
"object": "Adress-Objekt", "group": "Adress-Gruppe", "object": "Adress-Objekt", "group": "Adress-Gruppe",
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe", "serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe",
"comment": "Kommentar", "comment": "Kommentar",
"hits": "Hits",
"add": "Regel hinzufügen", "edit": "Regel bearbeiten", "add": "Regel hinzufügen", "edit": "Regel bearbeiten",
"deleteConfirm": "Diese Regel wirklich löschen?", "deleteConfirm": "Diese Regel wirklich löschen?",
"emptyTitle": "Noch keine eigenen Firewall-Regeln.", "emptyTitle": "Noch keine eigenen Firewall-Regeln.",

View File

@@ -101,6 +101,7 @@
"object": "Address object", "group": "Address group", "object": "Address object", "group": "Address group",
"serviceKind": "Service kind", "serviceGroup": "Service group", "serviceKind": "Service kind", "serviceGroup": "Service group",
"comment": "Comment", "comment": "Comment",
"hits": "Hits",
"add": "Add rule", "edit": "Edit rule", "add": "Add rule", "edit": "Edit rule",
"deleteConfirm": "Really delete this rule?", "deleteConfirm": "Really delete this rule?",
"emptyTitle": "No custom firewall rules yet.", "emptyTitle": "No custom firewall rules yet.",

View File

@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, message } from 'antd' import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FireOutlined } from '@ant-design/icons' import { FireOutlined } from '@ant-design/icons'
const { Text } = Typography
import DataTable from '../../components/DataTable' import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState' import EmptyState from '../../components/EmptyState'
@@ -40,6 +42,23 @@ const ACTION_COLORS: Record<FwRule['action'], string> = {
reject: 'orange', reject: 'orange',
} }
interface RuleCounter { rule_id: number; packets: number; bytes: number }
async function listCounters(): Promise<RuleCounter[]> {
try {
const r = await apiClient.get('/firewall/counters')
if (!isEnvelope(r.data)) return []
return (r.data.data as { counters?: RuleCounter[] }).counters ?? []
} catch { return [] }
}
function fmtBytes(n: number): string {
if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB'
if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB'
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
return n + ' B'
}
async function listRules(): Promise<FwRule[]> { async function listRules(): Promise<FwRule[]> {
const r = await apiClient.get('/firewall/rules') const r = await apiClient.get('/firewall/rules')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return []
@@ -99,6 +118,12 @@ export default function RulesTab() {
const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv }) const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv })
const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG }) const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG })
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones }) const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
const { data: counters } = useQuery({
queryKey: ['fw', 'counters'],
queryFn: listCounters,
refetchInterval: 10_000,
})
const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c]))
// Picker options: 'any' (special) + every zone the operator has // Picker options: 'any' (special) + every zone the operator has
// declared. Fallback to the seed list while the query is loading. // declared. Fallback to the seed list while the query is loading.
@@ -186,6 +211,18 @@ export default function RulesTab() {
}, },
{ title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' }, { title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' },
{ title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' }, { title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' },
{
title: t('fw.rule.hits'), key: 'hits', width: 90,
render: (_, r) => {
const c = counterByID.get(r.id)
if (!c) return <Text type="secondary" style={{ fontSize: 11 }}></Text>
return (
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
<Text style={{ fontSize: 11 }}>{c.packets.toLocaleString()}</Text>
</Tooltip>
)
},
},
{ {
title: t('common.edit'), key: 'actions', title: t('common.edit'), key: 'actions',
render: (_, row) => ( render: (_, row) => (