// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from // the relational state in PG (firewall_rules + ha_nodes). // // 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. // // Reload uses `nft -f ` (atomic ruleset replace) — there is no // systemctl reload for nftables. package firewall import ( "bytes" "context" _ "embed" "fmt" "net" "os/exec" "path/filepath" "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").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 } if err := exec.Command("nft", "-f", out).Run(); err != nil { return fmt.Errorf("nftables: nft -f %s: %w", out, err) } return nil } type View struct { 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 } type Rule struct { MatchExpr string Action string Comment string } func (g *Generator) loadView(ctx context.Context) (*View, error) { view := &View{} // Peer IPs from ha_nodes — splits IPv4 vs IPv6 so the template // can populate the right named set without runtime branching. 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 { 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()) } } } if err := peerRows.Err(); err != nil { return nil, err } // Custom firewall_rules — only active, ordered by priority. ruleRows, err := g.Pool.Query(ctx, ` SELECT chain, match_expr, action, COALESCE(comment, '') FROM firewall_rules WHERE active ORDER BY chain ASC, priority DESC, id ASC`) if err != nil { return nil, fmt.Errorf("query firewall_rules: %w", err) } defer ruleRows.Close() for ruleRows.Next() { var chain, match, action, comment string if err := ruleRows.Scan(&chain, &match, &action, &comment); err != nil { return nil, err } r := Rule{MatchExpr: match, Action: action, Comment: comment} switch chain { case "input": view.CustomRulesInput = append(view.CustomRulesInput, r) case "forward": view.CustomRulesForward = append(view.CustomRulesForward, r) case "output": view.CustomRulesOutput = append(view.CustomRulesOutput, r) } } return view, ruleRows.Err() }