Files
Debian 2556a93b34 feat(firewall): Auto-FW-Rule-Generator + UI-Anzeige
Renderer berechnet inbound-accept-Rules aus dem laufenden
Service-State — Operator legt keine FW-Rule mehr für DNS/Squid/WG-
Listen-Sockets manuell an.

internal/firewall:
* View.AutoRules + AutoFWRule struct (proto, port, optional dst-IP,
  comment).
* loadAutoRules quert cross-service:
  - DNS: dns_settings.listen_addresses ohne 127.x/::1 → udp+tcp 53
    pro IP (mit ip daddr X-match).
  - Squid: count(active forward_proxy_acls) > 0 → tcp 3128 (any IP,
    squid bindet 0.0.0.0).
  - WireGuard: server-mode + listen_port → udp <port> pro Iface.
* nft-Template emittiert eigene "Service-Auto-Rules"-Section vor
  Operator-Rules. Comment im nft-Output zeigt source-service.
* LoadAutoRules exportiert für Handler-Endpoint.

Handler:
* GET /api/v1/firewall/auto-rules — gibt die berechnete Liste
  zurück damit die UI sie anzeigen kann.
* FirewallHandler.Pool field + ctor-arg dazugekommen.

UI:
* SystemRulesCard fetcht /firewall/auto-rules + merged sie unter
  die statischen Anti-Lockout-Rows. 30s-Polling. Operator sieht
  jetzt im /firewall/Rules-Tab oben warum z.B. udp/53 offen ist
  (auto: DNS auf 10.10.20.1).

Cleanup: alte manuelle DNS+WG-Rules per SQL gelöscht — Auto-Rules
übernehmen.

Version 1.0.38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:47:38 +02:00

1007 lines
28 KiB
Go
Raw Permalink 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"
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"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"
"github.com/jackc/pgx/v5/pgxpool"
)
// 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
// Pool is needed to instantiate the renderer for the
// auto-rules endpoint (computes inbound rules derived from
// running service config — DNS/Squid/WG listen-sockets).
Pool *pgxpool.Pool
}
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,
pool *pgxpool.Pool,
) *FirewallHandler {
return &FirewallHandler{
Zones: zn,
AddrObjects: ao, AddrGroups: ag,
Services: sv, ServiceGroups: sg,
Rules: rl, NATRules: nat,
Audit: a, NodeID: nodeID,
Reloader: reloader,
Pool: pool,
}
}
// 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")
g.GET("/auto-rules", h.AutoRules)
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)
}
// AutoRules returns the inbound rules the firewall renderer derives
// from the running service config (DNS/Squid/WG listen-sockets).
// UI shows them in the SystemRulesCard so the operator understands
// why e.g. UDP/53 is open without any operator-rule.
func (h *FirewallHandler) AutoRules(c *gin.Context) {
rules := firewallrender.New(h.Pool).LoadAutoRules(c.Request.Context())
response.OK(c, gin.H{"rules": rules})
}
// ── 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 }