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>
This commit is contained in:
Debian
2026-05-10 18:05:27 +02:00
parent aa14b6b2be
commit 51ea1fc802
23 changed files with 782 additions and 37 deletions

View File

@@ -29,6 +29,7 @@ import (
// 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
@@ -47,6 +48,7 @@ type FirewallHandler struct {
}
func NewFirewallHandler(
zn *firewall.ZonesRepo,
ao *firewall.AddressObjectsRepo,
ag *firewall.AddressGroupsRepo,
sv *firewall.ServicesRepo,
@@ -58,6 +60,7 @@ func NewFirewallHandler(
reloader func(ctx context.Context) error,
) *FirewallHandler {
return &FirewallHandler{
Zones: zn,
AddrObjects: ao, AddrGroups: ag,
Services: sv, ServiceGroups: sg,
Rules: rl, NATRules: nat,
@@ -82,6 +85,13 @@ func (h *FirewallHandler) reload(ctx context.Context, op string) {
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)
@@ -125,6 +135,155 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
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) {
@@ -507,6 +666,14 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
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)
@@ -530,6 +697,14 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
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) {
@@ -599,6 +774,10 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
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)
@@ -622,6 +801,10 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
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) {
@@ -759,6 +942,23 @@ func validateNAT(n models.FirewallNATRule) error {
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 {

View File

@@ -9,19 +9,24 @@ import (
"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"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
)
type NetworksHandler struct {
Repo *networkifs.Repo
IPs *ipaddresses.Repo
Audit *audit.Repo
NodeID string
Repo *networkifs.Repo
IPs *ipaddresses.Repo
Zones *firewall.ZonesRepo
Audit *audit.Repo
NodeID string
}
func NewNetworksHandler(repo *networkifs.Repo, ips *ipaddresses.Repo, a *audit.Repo, nodeID string) *NetworksHandler {
return &NetworksHandler{Repo: repo, IPs: ips, Audit: a, NodeID: nodeID}
func NewNetworksHandler(
repo *networkifs.Repo, ips *ipaddresses.Repo,
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
) *NetworksHandler {
return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID}
}
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
@@ -70,6 +75,13 @@ func (h *NetworksHandler) Create(c *gin.Context) {
response.BadRequest(c, err)
return
}
if ok, err := h.Zones.Exists(c.Request.Context(), req.Role); err != nil {
response.Internal(c, err)
return
} else if !ok {
response.BadRequest(c, errors.New("role: zone "+req.Role+" does not exist"))
return
}
out, err := h.Repo.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -93,6 +105,13 @@ func (h *NetworksHandler) Update(c *gin.Context) {
response.BadRequest(c, err)
return
}
if ok, err := h.Zones.Exists(c.Request.Context(), req.Role); err != nil {
response.Internal(c, err)
return
} else if !ok {
response.BadRequest(c, errors.New("role: zone "+req.Role+" does not exist"))
return
}
out, err := h.Repo.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, networkifs.ErrNotFound) {