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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user