From c9dd0b4cb1c25e156d72f98987ba6bb47b384d1a Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 10 May 2026 11:38:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(fw):=20/api/v1/firewall/*=20CRUD-Handler?= =?UTF-8?q?=20f=C3=BCr=20alle=206=20Entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/handlers/firewall.go: ein FirewallHandler-Struct hält alle 6 Repos + Audit-Ref. Register(authed) mountet 30 Endpoints unter /api/v1/firewall/{address-objects,address-groups,services, service-groups,rules,nat-rules}. Validation: * Address-Objects: kind=host → ParseIP, network → ParseCIDR, range → "IP-IP", fqdn → looksLikeFQDN. * Rules: src/dst max one of (object_id|group_id|cidr); 0 = "any". service max one of (object|group). CIDR-Werte werden geparsed. * NAT: kind-spezifische Pflichtfelder. dnat braucht target_addr + match_dport_start. snat braucht target_addr. masquerade verbietet target_addr (Iface-IP gewinnt). * Services: builtin-Rows können nicht editiert/gelöscht werden (Repo-Layer enforced). Audit-Log pro Mutation. NoContent für DELETE. Wiring in cmd/edgeguard-api/main.go: 6 Repos + ein NewFirewallHandler(...).Register(authed). Renderer (nft aus allen Joins) + Frontend folgen in den nächsten Commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/edgeguard-api/main.go | 8 + internal/handlers/firewall.go | 761 ++++++++++++++++++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 internal/handlers/firewall.go diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 1af4a4a..a0e4925 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -25,6 +25,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" @@ -125,6 +126,12 @@ func main() { ifsRepo := networkifs.New(pool) ipsRepo := ipaddresses.New(pool) tlsRepo := tlscerts.New(pool) + fwAddrObj := firewall.NewAddressObjectsRepo(pool) + fwAddrGrp := firewall.NewAddressGroupsRepo(pool) + fwSvc := firewall.NewServicesRepo(pool) + fwSvcGrp := firewall.NewServiceGroupsRepo(pool) + fwRules := firewall.NewRulesRepo(pool) + fwNAT := firewall.NewNATRulesRepo(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an @@ -143,6 +150,7 @@ func main() { handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) + handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID).Register(authed) } mountUI(r) diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go new file mode 100644 index 0000000..f60e48a --- /dev/null +++ b/internal/handlers/firewall.go @@ -0,0 +1,761 @@ +package handlers + +import ( + "errors" + "fmt" + "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 +} + +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, +) *FirewallHandler { + return &FirewallHandler{ + AddrObjects: ao, AddrGroups: ag, + Services: sv, ServiceGroups: sg, + Rules: rl, NATRules: nat, + Audit: a, NodeID: nodeID, + } +} + +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) +} + +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) +} + +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) +} + +// ── 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) +} + +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) +} + +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) +} + +// ── 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) +} + +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) +} + +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) +} + +// ── 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) +} + +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) +} + +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) +} + +// ── 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) +} + +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) +} + +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) +} + +// ── 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) +} + +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) +} + +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) +} + +// ── 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 }