feat(fw): /api/v1/firewall/* CRUD-Handler für alle 6 Entities

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 11:38:37 +02:00
parent 0307dc68bb
commit c9dd0b4cb1
2 changed files with 769 additions and 0 deletions

View File

@@ -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 }