Files
edgeguard-native/internal/firewall/firewall.go
Debian 1b2c0d7411 feat(fw): Renderer-Rewrite + auto-apply + Anti-Lockout
internal/firewall/firewall.go komplett neu: joint zone-iface-mapping
(network_interfaces.role), address objects + groups (members
expandiert), services + groups, rules, nat-rules. Output: einheitliche
View mit Legs (rule × service cross-product) damit das Template kein
sub-template/dict braucht.

Template:
* Anti-Lockout-Block am input-chain-Top (SSH+443 immer erlaubt,
  KANN nicht von Custom-Rules overruled werden — User-Wunsch).
* Rules: pro Leg eine nft-Zeile mit iif/oif sets, ip saddr/daddr,
  proto+dport, optional log-prefix.
* prerouting_nat: iteriert dnat-Rules.
* postrouting_nat: snat + masquerade.

Auto-apply: FirewallHandler bekommt einen Reloader-Hook der nach
jedem POST/PUT/DELETE aufgerufen wird. main.go injected
firewall.New(pool).Render — schreibt + sudo nft -f.

Sudoers (/etc/sudoers.d/edgeguard): NOPASSWD für 'nft -f
/etc/edgeguard/nftables.d/ruleset.nft'. configgen.ReloadService
nutzt jetzt sudo (haproxy reload klappte vorher nicht aus dem
edgeguard-User).

Frontend (Sweep): style={{ marginBottom: 16 }} → className="mb-16"
in allen 7 Firewall-Tabs — User-Feedback "globales CSS statt inline".

Live auf 89.163.205.6: nft list table inet edgeguard zeigt
Anti-Lockout + Baseline + Cluster-Peer-Set + (jetzt noch leere)
Custom-Rules-Sektion. render-config postinst-mäßig sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:34:06 +02:00

520 lines
14 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
}
// 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
return view, nil
}
// 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()
}