feat(firewall): Auto-FW-Rule-Generator + UI-Anzeige
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 <port> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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@<iface>. Same pattern as the haproxy +
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.36"
|
||||
var version = "1.0.38"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <listen_port> 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 <listen_port> 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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.36",
|
||||
"version": "1.0.38",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<AutoRule[]> {
|
||||
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<string, string> = {
|
||||
|
||||
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<SystemRule> = [
|
||||
{ title: t('fw.sys.chain'), dataIndex: 'chain', key: 'chain', width: 80, render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: t('fw.sys.match'), dataIndex: 'match', key: 'match', render: (s: string) => <code>{s}</code> },
|
||||
@@ -63,7 +93,7 @@ export default function SystemRulesCard() {
|
||||
size="small"
|
||||
rowKey="key"
|
||||
columns={cols}
|
||||
dataSource={ROWS}
|
||||
dataSource={allRows}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user