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:
Debian
2026-05-11 06:47:38 +02:00
parent 8357d84c7b
commit 2556a93b34
10 changed files with 165 additions and 8 deletions

View File

@@ -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

View File

@@ -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}}