Files
edgeguard-native/internal/handlers/firewall.go
Debian c9dd0b4cb1 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>
2026-05-10 11:38:37 +02:00

762 lines
20 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 (
"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 }