diff --git a/VERSION b/VERSION index a8646df..8ac3ef6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.48 +1.1.49 diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index dbcb3a4..941d2be 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -65,7 +65,7 @@ table inet edgeguard { die Comment-Zeile angehängt — sonst frisst nft die rule 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}} # ── DEFAULT-DROP LOGGING ─────────────────────────────────────── diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go index 937c45f..43afa6d 100644 --- a/internal/handlers/firewall.go +++ b/internal/handlers/firewall.go @@ -96,6 +96,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/firewall") g.GET("/auto-rules", h.AutoRules) + g.GET("/counters", h.Counters) zn := g.Group("/zones") zn.GET("", h.ListZone) diff --git a/internal/handlers/firewall_counters.go b/internal/handlers/firewall_counters.go new file mode 100644 index 0000000..0e84dfd --- /dev/null +++ b/internal/handlers/firewall_counters.go @@ -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:"` 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 +} diff --git a/internal/handlers/firewall_counters_test.go b/internal/handlers/firewall_counters_test.go new file mode 100644 index 0000000..2c55ed9 --- /dev/null +++ b/internal/handlers/firewall_counters_test.go @@ -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 comment "egid:X"`; + // nft expands that to `counter packets N bytes M 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) + } +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index c53fb14..73d44f3 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -101,6 +101,7 @@ "object": "Adress-Objekt", "group": "Adress-Gruppe", "serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe", "comment": "Kommentar", + "hits": "Hits", "add": "Regel hinzufügen", "edit": "Regel bearbeiten", "deleteConfirm": "Diese Regel wirklich löschen?", "emptyTitle": "Noch keine eigenen Firewall-Regeln.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 414c540..03f19a5 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -101,6 +101,7 @@ "object": "Address object", "group": "Address group", "serviceKind": "Service kind", "serviceGroup": "Service group", "comment": "Comment", + "hits": "Hits", "add": "Add rule", "edit": "Edit rule", "deleteConfirm": "Really delete this rule?", "emptyTitle": "No custom firewall rules yet.", diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx index e724d04..b426dd1 100644 --- a/management-ui/src/pages/Firewall/Rules.tsx +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -1,10 +1,12 @@ 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { FireOutlined } from '@ant-design/icons' +const { Text } = Typography + import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' @@ -40,6 +42,23 @@ const ACTION_COLORS: Record = { reject: 'orange', } +interface RuleCounter { rule_id: number; packets: number; bytes: number } + +async function listCounters(): Promise { + 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 { const r = await apiClient.get('/firewall/rules') if (!isEnvelope(r.data)) return [] @@ -99,6 +118,12 @@ export default function RulesTab() { const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv }) const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG }) 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 // 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.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 + return ( + + {c.packets.toLocaleString()} + + ) + }, + }, { title: t('common.edit'), key: 'actions', render: (_, row) => (