Files
edgeguard-native/internal/handlers/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

786 lines
22 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 handlers
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
)
// FirewallHandler exposes everything under /api/v1/firewall/*:
//
// address-objects — primitive Adress-Definitionen (host/network/range/fqdn)
// address-groups — Gruppen von address-objects (mit /members ops)
// services — proto+port (Builtins lassen sich nicht editieren)
// service-groups — Gruppen von services
// rules — v2-Policy: zone × addr × service × action
// nat-rules — separate Tabelle: dnat / snat / masquerade
//
// Validation lebt im Handler (DB lässt mehr zu als die Anwendung erlauben
// will — exactly-one-of-Constraints sind in Postgres mühsam).
type FirewallHandler struct {
AddrObjects *firewall.AddressObjectsRepo
AddrGroups *firewall.AddressGroupsRepo
Services *firewall.ServicesRepo
ServiceGroups *firewall.ServiceGroupsRepo
Rules *firewall.RulesRepo
NATRules *firewall.NATRulesRepo
Audit *audit.Repo
NodeID string
// Reloader regenerates and applies the nft ruleset. Called after
// each mutation so the kernel ruleset is always in sync with the
// DB. Errors are logged but don't fail the API call (the row is
// already committed). Wire to internal/firewall.Generator.Render
// in main.go; pass nil during tests.
Reloader func(ctx context.Context) error
}
func NewFirewallHandler(
ao *firewall.AddressObjectsRepo,
ag *firewall.AddressGroupsRepo,
sv *firewall.ServicesRepo,
sg *firewall.ServiceGroupsRepo,
rl *firewall.RulesRepo,
nat *firewall.NATRulesRepo,
a *audit.Repo,
nodeID string,
reloader func(ctx context.Context) error,
) *FirewallHandler {
return &FirewallHandler{
AddrObjects: ao, AddrGroups: ag,
Services: sv, ServiceGroups: sg,
Rules: rl, NATRules: nat,
Audit: a, NodeID: nodeID,
Reloader: reloader,
}
}
// reload runs the configured Reloader (if any). Errors don't fail
// the surrounding API call — the DB row is already committed and
// the operator can re-trigger via `edgeguard-ctl render-config`.
func (h *FirewallHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("firewall: nft reload failed",
"op", op, "error", err)
}
}
func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/firewall")
ao := g.Group("/address-objects")
ao.GET("", h.ListAddrObj)
ao.POST("", h.CreateAddrObj)
ao.GET("/:id", h.GetAddrObj)
ao.PUT("/:id", h.UpdateAddrObj)
ao.DELETE("/:id", h.DeleteAddrObj)
ag := g.Group("/address-groups")
ag.GET("", h.ListAddrGrp)
ag.POST("", h.CreateAddrGrp)
ag.GET("/:id", h.GetAddrGrp)
ag.PUT("/:id", h.UpdateAddrGrp)
ag.DELETE("/:id", h.DeleteAddrGrp)
sv := g.Group("/services")
sv.GET("", h.ListService)
sv.POST("", h.CreateService)
sv.GET("/:id", h.GetService)
sv.PUT("/:id", h.UpdateService)
sv.DELETE("/:id", h.DeleteService)
sg := g.Group("/service-groups")
sg.GET("", h.ListSvcGrp)
sg.POST("", h.CreateSvcGrp)
sg.GET("/:id", h.GetSvcGrp)
sg.PUT("/:id", h.UpdateSvcGrp)
sg.DELETE("/:id", h.DeleteSvcGrp)
rl := g.Group("/rules")
rl.GET("", h.ListRule)
rl.POST("", h.CreateRule)
rl.GET("/:id", h.GetRule)
rl.PUT("/:id", h.UpdateRule)
rl.DELETE("/:id", h.DeleteRule)
nat := g.Group("/nat-rules")
nat.GET("", h.ListNAT)
nat.POST("", h.CreateNAT)
nat.GET("/:id", h.GetNAT)
nat.PUT("/:id", h.UpdateNAT)
nat.DELETE("/:id", h.DeleteNAT)
}
// ── Address Objects ────────────────────────────────────────────────────
func (h *FirewallHandler) ListAddrObj(c *gin.Context) {
out, err := h.AddrObjects.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"address_objects": out})
}
func (h *FirewallHandler) GetAddrObj(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.AddrObjects.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrAddressObjectNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
var req models.FirewallAddressObject
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateAddrObjValue(req.Kind, req.Value); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.AddrObjects.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallAddressObject
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateAddrObjValue(req.Kind, req.Value); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.AddrObjects.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrAddressObjectNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.AddrObjects.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrAddressObjectNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── Address Groups ─────────────────────────────────────────────────────
func (h *FirewallHandler) ListAddrGrp(c *gin.Context) {
out, err := h.AddrGroups.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"address_groups": out})
}
func (h *FirewallHandler) GetAddrGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.AddrGroups.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrAddressGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
var req models.FirewallAddressGroup
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.AddrGroups.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallAddressGroup
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.AddrGroups.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrAddressGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.AddrGroups.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrAddressGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── Services ───────────────────────────────────────────────────────────
func (h *FirewallHandler) ListService(c *gin.Context) {
out, err := h.Services.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"services": out})
}
func (h *FirewallHandler) GetService(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Services.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrServiceNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateService(c *gin.Context) {
var req models.FirewallService
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Services.Create(c.Request.Context(), req)
if err != nil {
response.BadRequest(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateService(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallService
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Services.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrServiceNotFound) {
response.NotFound(c, err)
return
}
response.BadRequest(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteService(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Services.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrServiceNotFound) {
response.NotFound(c, err)
return
}
response.BadRequest(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── Service Groups ─────────────────────────────────────────────────────
func (h *FirewallHandler) ListSvcGrp(c *gin.Context) {
out, err := h.ServiceGroups.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"service_groups": out})
}
func (h *FirewallHandler) GetSvcGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.ServiceGroups.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrServiceGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
var req models.FirewallServiceGroup
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.ServiceGroups.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallServiceGroup
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.ServiceGroups.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrServiceGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.ServiceGroups.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrServiceGroupNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── Rules ──────────────────────────────────────────────────────────────
func (h *FirewallHandler) ListRule(c *gin.Context) {
out, err := h.Rules.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"rules": out})
}
func (h *FirewallHandler) GetRule(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Rules.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateRule(c *gin.Context) {
var req models.FirewallRule
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateRule(req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Rules.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateRule(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallRule
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateRule(req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Rules.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteRule(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Rules.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── NAT Rules ──────────────────────────────────────────────────────────
func (h *FirewallHandler) ListNAT(c *gin.Context) {
out, err := h.NATRules.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"nat_rules": out})
}
func (h *FirewallHandler) GetNAT(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.NATRules.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateNAT(c *gin.Context) {
var req models.FirewallNATRule
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateNAT(req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.NATRules.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallNATRule
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateNAT(req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.NATRules.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.NATRules.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
// ── Validators ─────────────────────────────────────────────────────────
func validateAddrObjValue(kind, value string) error {
switch kind {
case "host":
if net.ParseIP(value) == nil {
return fmt.Errorf("host: %q is not a valid IPv4/IPv6 address", value)
}
case "network":
if _, _, err := net.ParseCIDR(value); err != nil {
return fmt.Errorf("network: %q is not a valid CIDR (%w)", value, err)
}
case "range":
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 ||
net.ParseIP(strings.TrimSpace(parts[0])) == nil ||
net.ParseIP(strings.TrimSpace(parts[1])) == nil {
return fmt.Errorf("range: %q must be 'IP-IP'", value)
}
case "fqdn":
if !looksLikeFQDN(value) {
return fmt.Errorf("fqdn: %q does not look like a hostname", value)
}
default:
return fmt.Errorf("unknown address-object kind %q", kind)
}
return nil
}
func looksLikeFQDN(s string) bool {
s = strings.TrimSuffix(strings.TrimSpace(s), ".")
if s == "" || len(s) > 253 || !strings.Contains(s, ".") {
return false
}
for _, label := range strings.Split(s, ".") {
if len(label) == 0 || len(label) > 63 {
return false
}
for _, r := range label {
ok := r == '-' || (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
if !ok {
return false
}
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
}
return true
}
// validateRule enforces "exactly one or zero" of the three address
// pointers per side, and "exactly one or zero" service references.
func validateRule(r models.FirewallRule) error {
if r.Action == "" {
return errors.New("action is required")
}
if got := countNonNil(r.SrcAddressObjectID, r.SrcAddressGroupID, optStr(r.SrcCIDR)); got > 1 {
return errors.New("source: at most one of src_address_object_id / src_address_group_id / src_cidr")
}
if got := countNonNil(r.DstAddressObjectID, r.DstAddressGroupID, optStr(r.DstCIDR)); got > 1 {
return errors.New("destination: at most one of dst_address_object_id / dst_address_group_id / dst_cidr")
}
if r.ServiceObjectID != nil && r.ServiceGroupID != nil {
return errors.New("service: at most one of service_object_id / service_group_id")
}
if r.SrcCIDR != nil {
if _, _, err := net.ParseCIDR(*r.SrcCIDR); err != nil {
return fmt.Errorf("src_cidr: %w", err)
}
}
if r.DstCIDR != nil {
if _, _, err := net.ParseCIDR(*r.DstCIDR); err != nil {
return fmt.Errorf("dst_cidr: %w", err)
}
}
return nil
}
func validateNAT(n models.FirewallNATRule) error {
switch n.Kind {
case "dnat":
if n.TargetAddr == nil || *n.TargetAddr == "" {
return errors.New("dnat: target_addr is required")
}
if n.MatchDPortStart == nil {
return errors.New("dnat: match_dport_start is required (which incoming port to forward)")
}
case "snat":
if n.TargetAddr == nil || *n.TargetAddr == "" {
return errors.New("snat: target_addr is required (the address to rewrite to)")
}
case "masquerade":
if n.OutZone == nil || *n.OutZone == "" {
return errors.New("masquerade: out_zone is required (which interface group's IP to use)")
}
if n.TargetAddr != nil && *n.TargetAddr != "" {
return errors.New("masquerade: target_addr must be empty (uses out-iface IP)")
}
default:
return fmt.Errorf("unknown NAT kind %q", n.Kind)
}
return nil
}
// countNonNil counts how many of the supplied pointers are non-nil
// (and, for *string, non-empty). Used by validateRule.
func countNonNil(args ...any) int {
n := 0
for _, a := range args {
switch v := a.(type) {
case *int64:
if v != nil {
n++
}
case *string:
if v != nil && *v != "" {
n++
}
case nil:
// skip
default:
n++
}
}
return n
}
func optStr(p *string) *string { return p }