Files
edgeguard-native/internal/handlers/firewall.go
Debian 51ea1fc802 feat: Zonen als first-class Entity + Domain↔Backend-Verknüpfung sichtbar
* Migration 0012: firewall_zones (id, name UNIQUE, description, builtin),
  Seed wan/lan/dmz/mgmt/cluster als builtin. CHECK-Constraints auf
  network_interfaces.role + firewall_rules.{src,dst}_zone +
  firewall_nat_rules.{in,out}_zone gedroppt — Validation lebt jetzt
  app-side (Handler prüft Existenz in firewall_zones).
* Backend: firewall.ZonesRepo (CRUD + Exists + References-Lookup),
  /api/v1/firewall/zones, builtin geschützt (Name nicht änderbar,
  Delete blockiert), Rename eines Custom-Zone aktuell ohne Cascade
  (Handler-Sorge bei Rules/NAT/Networks).
* Handler-Validation in CreateRule/UpdateRule/CreateNAT/UpdateNAT +
  NetworksHandler: Zone-Existence-Check pro Mutation, 400 bei Tippfehler.
* Frontend: Firewall-Tab "Zonen" (CRUD mit builtin-Schutz). Networks-
  Form lädt Rollen aus /firewall/zones (statt hardcoded Liste); Rules-
  und NAT-Forms ziehen die Zone-Auswahl ebenfalls aus der API.
* Domain-Form bekommt Primary-Backend-Picker (Field war im Modell,
  fehlte im UI). Backends-Tabelle zeigt umgekehrt welche Domains
  darauf zeigen — bidirektionale Sicht ohne Schemaänderung.
* HAProxy-Renderer: safeID-FuncMap escaped Server-Namen mit Whitespace
  ("Control Master 1" → "Control_Master_1"). Vorher ist haproxy beim
  Reload an Spaces im Backend-Namen kaputt gegangen.
* Version 1.0.3 → 1.0.6.

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

986 lines
27 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 {
Zones *firewall.ZonesRepo
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(
zn *firewall.ZonesRepo,
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{
Zones: zn,
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")
zn := g.Group("/zones")
zn.GET("", h.ListZone)
zn.POST("", h.CreateZone)
zn.GET("/:id", h.GetZone)
zn.PUT("/:id", h.UpdateZone)
zn.DELETE("/:id", h.DeleteZone)
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)
}
// ── Zones ──────────────────────────────────────────────────────────────
func (h *FirewallHandler) ListZone(c *gin.Context) {
out, err := h.Zones.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"zones": out})
}
func (h *FirewallHandler) GetZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Zones.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateZone(c *gin.Context) {
var req models.FirewallZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if !zoneNamePattern(req.Name) {
response.BadRequest(c, errors.New("zone name must match [a-z][a-z0-9_-]{0,31}"))
return
}
out, err := h.Zones.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.create", out.Name, out, h.NodeID)
response.Created(c, out)
}
func (h *FirewallHandler) UpdateZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Name != "" && !zoneNamePattern(req.Name) {
response.BadRequest(c, errors.New("zone name must match [a-z][a-z0-9_-]{0,31}"))
return
}
out, err := h.Zones.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.update", out.Name, out, h.NodeID)
response.OK(c, out)
}
func (h *FirewallHandler) DeleteZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cur, err := h.Zones.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
refs, err := h.Zones.References(c.Request.Context(), cur.Name)
if err != nil {
response.Internal(c, err)
return
}
if refs > 0 {
response.BadRequest(c, fmt.Errorf("zone %q is still in use by %d objects", cur.Name, refs))
return
}
if err := h.Zones.Delete(c.Request.Context(), id); err != nil {
response.BadRequest(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.delete", cur.Name, gin.H{"id": id}, h.NodeID)
response.NoContent(c)
}
// zoneNamePattern mirrors the SQL CHECK on firewall_zones.name —
// duplicated app-side so we can return a friendly message instead
// of leaking the constraint violation.
func zoneNamePattern(s string) bool {
if s == "" || len(s) > 32 {
return false
}
if !(s[0] >= 'a' && s[0] <= 'z') {
return false
}
for i := 1; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
default:
return false
}
}
return true
}
// resolveZone returns nil if name is empty / "any", or if the zone
// exists in firewall_zones. Used by rule + NAT validation so a typo
// in src_zone/dst_zone gets a 400 instead of silently never matching
// in the renderer.
func (h *FirewallHandler) resolveZone(ctx context.Context, name string, allowAny bool) error {
if name == "" {
return nil
}
if allowAny && name == "any" {
return nil
}
ok, err := h.Zones.Exists(ctx, name)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("zone %q does not exist", name)
}
return nil
}
// ── 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
}
if err := h.resolveZone(c.Request.Context(), req.SrcZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("src_zone: %w", err))
return
}
if err := h.resolveZone(c.Request.Context(), req.DstZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("dst_zone: %w", 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
}
if err := h.resolveZone(c.Request.Context(), req.SrcZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("src_zone: %w", err))
return
}
if err := h.resolveZone(c.Request.Context(), req.DstZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("dst_zone: %w", 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
}
if err := h.checkNATZones(c.Request.Context(), 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
}
if err := h.checkNATZones(c.Request.Context(), 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
}
// checkNATZones validates in_zone / out_zone (both nullable) point
// to existing zones — NAT zones don't accept "any" because the
// renderer needs a concrete iface group to attach the chain.
func (h *FirewallHandler) checkNATZones(ctx context.Context, n models.FirewallNATRule) error {
if n.InZone != nil {
if err := h.resolveZone(ctx, *n.InZone, false); err != nil {
return fmt.Errorf("in_zone: %w", err)
}
}
if n.OutZone != nil {
if err := h.resolveZone(ctx, *n.OutZone, false); err != nil {
return fmt.Errorf("out_zone: %w", err)
}
}
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 }