Files
edgeguard-native/internal/firewall/firewall.go
Debian 2556a93b34 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>
2026-05-11 06:47:38 +02:00

618 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from
// the v2 (Fortigate-style) firewall tables.
//
// Render flow:
//
// 1. loadView pulls everything: zone→iface mapping (from
// network_interfaces.role), address-objects + groups, services
// + groups, policy rules, nat rules, ha_nodes peer IPs.
// 2. Each rule and nat-rule is "resolved" — group references
// replaced with their primitive members, FQDNs left as comments
// (Phase-3 DNS-resolution sidecar will materialise them).
// 3. The template emits one nft file with: zone-iface sets, peer
// sets, default-deny baseline, forward + input chains carrying
// the resolved rules (priority-sorted), nat prerouting +
// postrouting chains.
// 4. Atomic write + `sudo nft -f` (sudoers-rule installed by
// postinst).
package firewall
import (
"bytes"
"context"
_ "embed"
"fmt"
"net"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
)
//go:embed ruleset.nft.tpl
var rulesTpl string
var tpl = template.Must(template.New("ruleset").Funcs(template.FuncMap{
"join": strings.Join,
}).Parse(rulesTpl))
type Generator struct {
Pool *pgxpool.Pool
OutputPath string
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator { return &Generator{Pool: pool} }
func (g *Generator) Name() string { return "nftables" }
func (g *Generator) Render(ctx context.Context) error {
view, err := g.loadView(ctx)
if err != nil {
return fmt.Errorf("nftables: load state: %w", err)
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
return fmt.Errorf("nftables: render template: %w", err)
}
out := g.OutputPath
if out == "" {
out = filepath.Join(configgen.EtcEdgeguard, "nftables.d", "ruleset.nft")
}
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("nftables: write: %w", err)
}
if g.SkipReload {
return nil
}
// nft -f via sudo — postinst installiert die NOPASSWD-Rule.
cmd := exec.Command("sudo", "-n", "/usr/sbin/nft", "-f", out)
combined, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nftables: nft -f %s: %w (output: %s)", out, err, strings.TrimSpace(string(combined)))
}
return nil
}
// View is what the template consumes — fully resolved.
type View struct {
ZoneIPv4 map[string][]string // zone name → iface names (ipv4-able)
PeerIPv4 []string
PeerIPv6 []string
// Legs is the cross-product of (rule × service). One nft line per
// leg, expanded server-side so the template stays free of dict /
// sub-template trickery. A rule with N services produces N legs;
// 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.
type RuleLeg struct {
RuleID int64
Action string
Log bool
Name string
Comment string
SrcIfaces []string
DstIfaces []string
SrcAddrs []string
DstAddrs []string
Service ResolvedService // Proto="" → no service match (any)
}
// ResolvedRule has all addresses + services already expanded so the
// template just emits one nft line per "leg" of the cross-product.
type ResolvedRule struct {
ID int64
Action string // accept | drop | reject
Log bool
Name string
Priority int
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
DstAddrs []string
Services []ResolvedService // empty = any
Comment string
}
// ResolvedNATRule is one nat-rule joined with iface-sets.
type ResolvedNATRule struct {
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
Comment string
}
// ResolvedService is one nft (proto, dport-spec) tuple.
type ResolvedService struct {
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
view := &View{
ZoneIPv4: map[string][]string{},
}
// ── Zone → Iface mapping aus network_interfaces.role ──
ifRows, err := g.Pool.Query(ctx,
`SELECT name, role FROM network_interfaces WHERE active = TRUE`)
if err != nil {
return nil, fmt.Errorf("query network_interfaces: %w", err)
}
for ifRows.Next() {
var name, role string
if err := ifRows.Scan(&name, &role); err != nil {
ifRows.Close()
return nil, err
}
view.ZoneIPv4[role] = append(view.ZoneIPv4[role], name)
}
ifRows.Close()
// ── Peer IPs aus ha_nodes (für mTLS-Peer-Set) ──
peerRows, err := g.Pool.Query(ctx,
`SELECT public_ip, internal_ip FROM ha_nodes`)
if err != nil {
return nil, fmt.Errorf("query ha_nodes: %w", err)
}
for peerRows.Next() {
var pub, internal *string
if err := peerRows.Scan(&pub, &internal); err != nil {
peerRows.Close()
return nil, err
}
for _, ip := range []*string{pub, internal} {
if ip == nil {
continue
}
parsed := net.ParseIP(*ip)
if parsed == nil {
continue
}
if parsed.To4() != nil {
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
} else {
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
}
}
}
peerRows.Close()
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
addrObjs, err := g.loadAddrObjects(ctx)
if err != nil {
return nil, err
}
addrGroups, err := g.loadAddrGroups(ctx, addrObjs)
if err != nil {
return nil, err
}
// ── Services + Groups ──
services, err := g.loadServices(ctx)
if err != nil {
return nil, err
}
serviceGroups, err := g.loadServiceGroups(ctx, services)
if err != nil {
return nil, err
}
// ── Rules ──
rules, err := g.loadRules(ctx, addrObjs, addrGroups, services, serviceGroups, view.ZoneIPv4)
if err != nil {
return nil, err
}
// Expand to one Leg per (rule × service); rules without a service
// produce one leg with empty Proto.
for _, r := range rules {
if len(r.Services) == 0 {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
})
continue
}
for _, svc := range r.Services {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
Service: svc,
})
}
}
// ── NAT-Rules ──
natRules, err := g.loadNATRules(ctx, view.ZoneIPv4)
if err != nil {
return nil, err
}
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
func (g *Generator) loadAddrObjects(ctx context.Context) (addrObjMap, error) {
out := addrObjMap{}
rows, err := g.Pool.Query(ctx,
`SELECT id, kind, value FROM firewall_address_objects`)
if err != nil {
return nil, fmt.Errorf("query address_objects: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var kind, value string
if err := rows.Scan(&id, &kind, &value); err != nil {
return nil, err
}
switch kind {
case "host", "network":
out[id] = value
case "range":
// "1.2.3.4-1.2.3.10" → nft format identical
out[id] = value
case "fqdn":
// FQDNs are skipped at render-time — template emits
// a comment, the actual rule won't filter on these
// addresses until a DNS-resolution sidecar lands.
out[id] = "" // signals "skip"
}
}
return out, rows.Err()
}
// addrGroupMap is keyed by group id; value is the list of resolved
// nft-expressions (one per primitive member).
type addrGroupMap map[int64][]string
func (g *Generator) loadAddrGroups(ctx context.Context, objs addrObjMap) (addrGroupMap, error) {
out := addrGroupMap{}
rows, err := g.Pool.Query(ctx,
`SELECT group_id, object_id FROM firewall_address_group_members`)
if err != nil {
return nil, fmt.Errorf("query address_group_members: %w", err)
}
defer rows.Close()
for rows.Next() {
var gid, oid int64
if err := rows.Scan(&gid, &oid); err != nil {
return nil, err
}
if expr, ok := objs[oid]; ok && expr != "" {
out[gid] = append(out[gid], expr)
}
}
return out, rows.Err()
}
type serviceMap map[int64]ResolvedService
func (g *Generator) loadServices(ctx context.Context) (serviceMap, error) {
out := serviceMap{}
rows, err := g.Pool.Query(ctx,
`SELECT id, proto, COALESCE(port_start, 0), COALESCE(port_end, 0) FROM firewall_services`)
if err != nil {
return nil, fmt.Errorf("query services: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var proto string
var ps, pe int
if err := rows.Scan(&id, &proto, &ps, &pe); err != nil {
return nil, err
}
out[id] = ResolvedService{Proto: proto, PortStart: ps, PortEnd: pe}
}
return out, rows.Err()
}
type serviceGroupMap map[int64][]ResolvedService
func (g *Generator) loadServiceGroups(ctx context.Context, services serviceMap) (serviceGroupMap, error) {
out := serviceGroupMap{}
rows, err := g.Pool.Query(ctx,
`SELECT group_id, service_id FROM firewall_service_group_members`)
if err != nil {
return nil, fmt.Errorf("query service_group_members: %w", err)
}
defer rows.Close()
for rows.Next() {
var gid, sid int64
if err := rows.Scan(&gid, &sid); err != nil {
return nil, err
}
if svc, ok := services[sid]; ok {
out[gid] = append(out[gid], svc)
}
}
return out, rows.Err()
}
func (g *Generator) loadRules(
ctx context.Context,
addrObjs addrObjMap,
addrGroups addrGroupMap,
services serviceMap,
serviceGroups serviceGroupMap,
zoneIfaces map[string][]string,
) ([]ResolvedRule, error) {
rows, err := g.Pool.Query(ctx, `
SELECT id, COALESCE(name, ''), priority, action, log, COALESCE(comment, ''),
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id
FROM firewall_rules
WHERE enabled
ORDER BY priority DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query rules: %w", err)
}
defer rows.Close()
resolveSide := func(objID, grpID *int64, cidr *string) []string {
if objID != nil {
if expr, ok := addrObjs[*objID]; ok && expr != "" {
return []string{expr}
}
}
if grpID != nil {
if exprs, ok := addrGroups[*grpID]; ok {
return exprs
}
}
if cidr != nil && *cidr != "" {
return []string{*cidr}
}
return nil // any
}
resolveService := func(objID, grpID *int64) []ResolvedService {
if objID != nil {
if svc, ok := services[*objID]; ok {
return []ResolvedService{svc}
}
}
if grpID != nil {
if svcs, ok := serviceGroups[*grpID]; ok {
return svcs
}
}
return nil
}
out := []ResolvedRule{}
for rows.Next() {
var (
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
)
if err := rows.Scan(
&id, &name, &pr, &action, &log, &com,
&srcZone, &srcObjID, &srcGrpID, &srcCIDR,
&dstZone, &dstObjID, &dstGrpID, &dstCIDR,
&svcObjID, &svcGrpID,
); err != nil {
return nil, err
}
r := ResolvedRule{
ID: id,
Action: action,
Log: log,
Name: name,
Priority: pr,
Comment: com,
SrcAddrs: resolveSide(srcObjID, srcGrpID, srcCIDR),
DstAddrs: resolveSide(dstObjID, dstGrpID, dstCIDR),
Services: resolveService(svcObjID, svcGrpID),
}
if srcZone != "any" {
r.SrcIfaces = zoneIfaces[srcZone]
}
if dstZone != "any" {
r.DstIfaces = zoneIfaces[dstZone]
}
out = append(out, r)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Priority != out[j].Priority {
return out[i].Priority > out[j].Priority
}
return out[i].ID < out[j].ID
})
return out, rows.Err()
}
func (g *Generator) loadNATRules(ctx context.Context, zoneIfaces map[string][]string) ([]ResolvedNATRule, error) {
rows, err := g.Pool.Query(ctx, `
SELECT id, priority, kind, COALESCE(comment, ''),
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr,
COALESCE(match_dport_start, 0), COALESCE(match_dport_end, 0),
COALESCE(target_addr, ''),
COALESCE(target_port_start, 0), COALESCE(target_port_end, 0)
FROM firewall_nat_rules
WHERE enabled
ORDER BY priority DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query nat_rules: %w", err)
}
defer rows.Close()
out := []ResolvedNATRule{}
for rows.Next() {
var (
id int64
pr int
kind, com string
inZone, outZone, proto, srcCIDR, dstCIDR *string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
)
if err := rows.Scan(
&id, &pr, &kind, &com,
&inZone, &outZone, &proto,
&srcCIDR, &dstCIDR,
&dpStart, &dpEnd,
&targetAddr, &tpStart, &tpEnd,
); err != nil {
return nil, err
}
r := ResolvedNATRule{
ID: id, Kind: kind, Priority: pr, Comment: com,
DPortStart: dpStart, DPortEnd: dpEnd,
TargetAddr: targetAddr,
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
}
if proto != nil {
r.Proto = *proto
}
if srcCIDR != nil {
r.SrcCIDR = *srcCIDR
}
if dstCIDR != nil {
r.DstCIDR = *dstCIDR
}
if inZone != nil {
r.InIfaces = zoneIfaces[*inZone]
}
if outZone != nil {
r.OutIfaces = zoneIfaces[*outZone]
}
out = append(out, r)
}
return out, rows.Err()
}