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>
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from
|
||||
// the relational state in PG (firewall_rules + ha_nodes).
|
||||
// the v2 (Fortigate-style) firewall tables.
|
||||
//
|
||||
// The base ruleset is hard-coded in the template (default-deny input,
|
||||
// stateful baseline, SSH rate-limit, public :80 / :443, peer mTLS on
|
||||
// :8443 from cluster IPs). Operator-defined rows in firewall_rules
|
||||
// land at the bottom of input/forward/output.
|
||||
// Render flow:
|
||||
//
|
||||
// Reload uses `nft -f <path>` (atomic ruleset replace) — there is no
|
||||
// systemctl reload for nftables.
|
||||
// 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 (
|
||||
@@ -18,6 +25,8 @@ import (
|
||||
"net"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -28,7 +37,9 @@ import (
|
||||
//go:embed ruleset.nft.tpl
|
||||
var rulesTpl string
|
||||
|
||||
var tpl = template.Must(template.New("ruleset").Parse(rulesTpl))
|
||||
var tpl = template.Must(template.New("ruleset").Funcs(template.FuncMap{
|
||||
"join": strings.Join,
|
||||
}).Parse(rulesTpl))
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
@@ -60,42 +71,113 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
if err := exec.Command("nft", "-f", out).Run(); err != nil {
|
||||
return fmt.Errorf("nftables: nft -f %s: %w", out, err)
|
||||
// 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
|
||||
// Custom rules grouped by chain — the template iterates each
|
||||
// section independently so input/forward/output stay separate.
|
||||
CustomRulesInput []Rule
|
||||
CustomRulesForward []Rule
|
||||
CustomRulesOutput []Rule
|
||||
// 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
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
MatchExpr 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
|
||||
}
|
||||
|
||||
func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
view := &View{}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Peer IPs from ha_nodes — splits IPv4 vs IPv6 so the template
|
||||
// can populate the right named set without runtime branching.
|
||||
// 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)
|
||||
}
|
||||
defer peerRows.Close()
|
||||
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} {
|
||||
@@ -113,16 +195,325 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := peerRows.Err(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Migration 0010 hat firewall_rules komplett umgebaut (Fortigate-
|
||||
// Style mit address objects + service refs). Phase-2-Renderer
|
||||
// kannte das alte chain/match_expr-Schema. Bis Task #44 die
|
||||
// Render-Logik mit den neuen Joins ersetzt, geben wir hier
|
||||
// keine custom-Rules aus — Output ist nur baseline + cluster set.
|
||||
// Sicher, weil baseline default-deny ist; v2-Rules kommen mit
|
||||
// dem nächsten Renderer-Patch.
|
||||
// ── 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user