From 2556a93b340c70c906c0a5b405a54723accd9389 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 06:47:38 +0200 Subject: [PATCH] feat(firewall): Auto-FW-Rule-Generator + UI-Anzeige MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer berechnet inbound-accept-Rules aus dem laufenden Service-State — Operator legt keine FW-Rule mehr für DNS/Squid/WG- Listen-Sockets manuell an. internal/firewall: * View.AutoRules + AutoFWRule struct (proto, port, optional dst-IP, comment). * loadAutoRules quert cross-service: - DNS: dns_settings.listen_addresses ohne 127.x/::1 → udp+tcp 53 pro IP (mit ip daddr X-match). - Squid: count(active forward_proxy_acls) > 0 → tcp 3128 (any IP, squid bindet 0.0.0.0). - WireGuard: server-mode + listen_port → udp pro Iface. * nft-Template emittiert eigene "Service-Auto-Rules"-Section vor Operator-Rules. Comment im nft-Output zeigt source-service. * LoadAutoRules exportiert für Handler-Endpoint. Handler: * GET /api/v1/firewall/auto-rules — gibt die berechnete Liste zurück damit die UI sie anzeigen kann. * FirewallHandler.Pool field + ctor-arg dazugekommen. UI: * SystemRulesCard fetcht /firewall/auto-rules + merged sie unter die statischen Anti-Lockout-Rows. 30s-Polling. Operator sieht jetzt im /firewall/Rules-Tab oben warum z.B. udp/53 offen ist (auto: DNS auf 10.10.20.1). Cleanup: alte manuelle DNS+WG-Rules per SQL gelöscht — Auto-Rules übernehmen. Version 1.0.38. Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 4 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/firewall/firewall.go | 98 +++++++++++++++++++ internal/firewall/ruleset.nft.tpl | 8 ++ internal/handlers/firewall.go | 21 ++++ management-ui/package.json | 2 +- .../src/components/Layout/Sidebar.tsx | 2 +- .../src/pages/Firewall/SystemRules.tsx | 32 +++++- 10 files changed, 165 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 2e9116b..9600efd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.36 +1.0.38 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index aedc6bf..afb03ca 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -43,7 +43,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.36" +var version = "1.0.38" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -179,7 +179,7 @@ func main() { fwReloader := func(ctx context.Context) error { return firewallrender.New(pool).Render(ctx) } - handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed) + handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed) // WireGuard reload: re-render /etc/edgeguard/wireguard/*.conf // + restart wg-quick@. Same pattern as the haproxy + diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 38bff6d..134246c 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.36" +var version = "1.0.38" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 7a1f570..d3390df 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -21,7 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.36" +var version = "1.0.38" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 4734b4d..0bc3dce 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -91,6 +91,29 @@ type View struct { // a rule with no service produces one leg with Service.Proto = "". Legs []RuleLeg NATRules []ResolvedNATRule + + // AutoRules are inbound-accept rules the firewall renderer + // derives from the running service config: + // - DNS (Unbound): if listen_addresses lists a non-loopback IP, + // emit udp/tcp 53 to that IP. + // - Squid: if forward_proxy_acls has any active entry, emit + // tcp 3128 (operator can lock down via address-objects later). + // - WireGuard server-mode: emit udp per active + // server iface. + // Operator never edits these — they belong to the service. If + // the service is removed/disabled, the rule is gone next render. + AutoRules []AutoFWRule +} + +// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or +// "udp"; Port is the listen port; DstIP is the local IP the service +// binds to (empty = any local IP). Comment is what the SystemRules +// card shows in the UI. +type AutoFWRule struct { + Proto string + Port int + DstIP string + Comment string } // RuleLeg is one materialised nft policy line. @@ -252,9 +275,84 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { } view.NATRules = natRules + // ── Auto-Rules aus laufender Service-Config ── + view.AutoRules = g.loadAutoRules(ctx) + return view, nil } +// LoadAutoRules ist die exportierte Variante — der UI-Handler ruft +// sie auf um die Liste an die SystemRulesCard zu liefern. +func (g *Generator) LoadAutoRules(ctx context.Context) []AutoFWRule { + return g.loadAutoRules(ctx) +} + +// loadAutoRules berechnet inbound-accept-Rules aus dem aktuellen +// State der anderen Services. Best-effort — Fehler beim Lesen einer +// einzelnen Service-DB-Tabelle führen zu warning + skip, nicht zum +// Abort der gesamten Render. Anti-Lockout-Rules (SSH/443/3443/80) +// stehen weiterhin als statische Block im Template. +func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule { + out := []AutoFWRule{} + + // DNS (Unbound): listen_addresses ohne 127.x/::1 → udp+tcp 53. + var listenAddrs string + if err := g.Pool.QueryRow(ctx, `SELECT listen_addresses FROM dns_settings WHERE id=1`).Scan(&listenAddrs); err == nil { + for _, ip := range splitCSV(listenAddrs) { + if isLoopback(ip) || ip == "0.0.0.0" || ip == "::" { + continue + } + out = append(out, + AutoFWRule{Proto: "udp", Port: 53, DstIP: ip, Comment: "DNS (Unbound) auf " + ip}, + AutoFWRule{Proto: "tcp", Port: 53, DstIP: ip, Comment: "DNS-TCP (Unbound) auf " + ip}, + ) + } + } + + // Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound + // (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter). + var aclCount int + if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 { + out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"}) + } + + // WireGuard server-mode: udp pro aktive iface. + rows, err := g.Pool.Query(ctx, `SELECT name, listen_port FROM wireguard_interfaces WHERE active AND mode='server' AND listen_port IS NOT NULL`) + if err == nil { + defer rows.Close() + for rows.Next() { + var name string + var port int + if err := rows.Scan(&name, &port); err == nil { + out = append(out, AutoFWRule{Proto: "udp", Port: port, Comment: "WireGuard " + name}) + } + } + } + + return out +} + +// splitCSV — wie in den Service-renderern. +func splitCSV(s string) []string { + out := []string{} + for _, p := range strings.Split(s, ",") { + t := strings.TrimSpace(p) + if t != "" { + out = append(out, t) + } + } + return out +} + +// isLoopback erkennt 127.x.x.x, ::1, oder localhost-namen — die +// brauchen keine FW-Rule (nft erlaubt iif lo per Anti-Lockout). +func isLoopback(ip string) bool { + if ip == "::1" || ip == "localhost" { + return true + } + return strings.HasPrefix(ip, "127.") +} + // addrObjMap is keyed by id; value is the nft expression for that // object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10"). type addrObjMap map[int64]string diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 67c7f80..e7da25e 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -47,6 +47,14 @@ table inet edgeguard { tcp dport 8443 ip saddr @peer_ipv4 accept tcp dport 8443 ip6 saddr @peer_ipv6 accept + # ── Service-Auto-Rules (DNS/Squid/WG/...) ── + # Aus dem laufenden Service-State abgeleitet — Operator + # editiert diese nicht. Wenn der Service entfernt/disabled + # wird, ist die Rule beim nächsten Render weg. +{{range .AutoRules}} + {{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}" +{{end}} + # ── Operator-defined rules ── {{range .Legs}} # rule {{.RuleID}}{{if .Name}} ({{.Name}}){{end}}{{if .Comment}} — {{.Comment}}{{end}} diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go index 4c100f6..937c45f 100644 --- a/internal/handlers/firewall.go +++ b/internal/handlers/firewall.go @@ -11,10 +11,13 @@ import ( "github.com/gin-gonic/gin" + firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" + + "github.com/jackc/pgx/v5/pgxpool" ) // FirewallHandler exposes everything under /api/v1/firewall/*: @@ -45,6 +48,11 @@ type FirewallHandler struct { // already committed). Wire to internal/firewall.Generator.Render // in main.go; pass nil during tests. Reloader func(ctx context.Context) error + + // Pool is needed to instantiate the renderer for the + // auto-rules endpoint (computes inbound rules derived from + // running service config — DNS/Squid/WG listen-sockets). + Pool *pgxpool.Pool } func NewFirewallHandler( @@ -58,6 +66,7 @@ func NewFirewallHandler( a *audit.Repo, nodeID string, reloader func(ctx context.Context) error, + pool *pgxpool.Pool, ) *FirewallHandler { return &FirewallHandler{ Zones: zn, @@ -66,6 +75,7 @@ func NewFirewallHandler( Rules: rl, NATRules: nat, Audit: a, NodeID: nodeID, Reloader: reloader, + Pool: pool, } } @@ -85,6 +95,8 @@ func (h *FirewallHandler) reload(ctx context.Context, op string) { func (h *FirewallHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/firewall") + g.GET("/auto-rules", h.AutoRules) + zn := g.Group("/zones") zn.GET("", h.ListZone) zn.POST("", h.CreateZone) @@ -135,6 +147,15 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) { nat.DELETE("/:id", h.DeleteNAT) } +// AutoRules returns the inbound rules the firewall renderer derives +// from the running service config (DNS/Squid/WG listen-sockets). +// UI shows them in the SystemRulesCard so the operator understands +// why e.g. UDP/53 is open without any operator-rule. +func (h *FirewallHandler) AutoRules(c *gin.Context) { + rules := firewallrender.New(h.Pool).LoadAutoRules(c.Request.Context()) + response.OK(c, gin.H{"rules": rules}) +} + // ── Zones ────────────────────────────────────────────────────────────── func (h *FirewallHandler) ListZone(c *gin.Context) { diff --git a/management-ui/package.json b/management-ui/package.json index d7f22a5..0f98405 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.36", + "version": "1.0.38", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index d2797fd..abaaa58 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -73,7 +73,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.36' +const VERSION = '1.0.38' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/pages/Firewall/SystemRules.tsx b/management-ui/src/pages/Firewall/SystemRules.tsx index a85e1b7..2acdf20 100644 --- a/management-ui/src/pages/Firewall/SystemRules.tsx +++ b/management-ui/src/pages/Firewall/SystemRules.tsx @@ -1,7 +1,25 @@ import { Alert, Card, Space, Table, Tag, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import apiClient, { isEnvelope } from '../../api/client' + +interface AutoRule { + Proto: string + Port: number + DstIP?: string + Comment: string +} + +async function listAutoRules(): Promise { + try { + const r = await apiClient.get('/firewall/auto-rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { rules?: AutoRule[] }).rules ?? [] + } catch { return [] } +} + // SystemRulesCard documents the baseline nftables ruleset that // EdgeGuard installs unconditionally — anti-lockout, stateful // session handling, public ingress, cluster mTLS. These rules sit @@ -36,6 +54,18 @@ const ACTION_COLORS: Record = { export default function SystemRulesCard() { const { t } = useTranslation() + const { data: autoRules } = useQuery({ queryKey: ['fw', 'auto-rules'], queryFn: listAutoRules, refetchInterval: 30_000 }) + + // Static system rules + dynamic auto-rules zu einer Liste mergen. + const dynamic: SystemRule[] = (autoRules ?? []).map((r, i) => ({ + key: `auto-${i}`, + chain: 'input', + match: `${r.DstIP ? `ip daddr ${r.DstIP} ` : ''}${r.Proto} dport ${r.Port}`, + action: 'accept', + note: `auto: ${r.Comment}`, + })) + const allRows = [...ROWS, ...dynamic] + const cols: ColumnsType = [ { title: t('fw.sys.chain'), dataIndex: 'chain', key: 'chain', width: 80, render: (s: string) => {s} }, { title: t('fw.sys.match'), dataIndex: 'match', key: 'match', render: (s: string) => {s} }, @@ -63,7 +93,7 @@ export default function SystemRulesCard() { size="small" rowKey="key" columns={cols} - dataSource={ROWS} + dataSource={allRows} pagination={false} />