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

@@ -0,0 +1,147 @@
package firewall
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrZoneNotFound = errors.New("zone not found")
type ZonesRepo struct {
Pool *pgxpool.Pool
}
func NewZonesRepo(pool *pgxpool.Pool) *ZonesRepo { return &ZonesRepo{Pool: pool} }
const zoneBaseSelect = `
SELECT id, name, description, builtin, created_at, updated_at
FROM firewall_zones
`
func (r *ZonesRepo) List(ctx context.Context) ([]models.FirewallZone, error) {
rows, err := r.Pool.Query(ctx, zoneBaseSelect+" ORDER BY name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.FirewallZone, 0, 8)
for rows.Next() {
z, err := scanZone(rows)
if err != nil {
return nil, err
}
out = append(out, *z)
}
return out, rows.Err()
}
func (r *ZonesRepo) Get(ctx context.Context, id int64) (*models.FirewallZone, error) {
row := r.Pool.QueryRow(ctx, zoneBaseSelect+" WHERE id = $1", id)
z, err := scanZone(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrZoneNotFound
}
return nil, err
}
return z, nil
}
// Exists is used by the rules / nat / iface handlers to validate
// that the zone name they got from the operator references a real
// zone (or the special 'any' which the rule layer handles itself).
func (r *ZonesRepo) Exists(ctx context.Context, name string) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `SELECT 1 FROM firewall_zones WHERE name = $1`, name).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (r *ZonesRepo) Create(ctx context.Context, z models.FirewallZone) (*models.FirewallZone, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO firewall_zones (name, description, builtin)
VALUES ($1, $2, FALSE)
RETURNING id, name, description, builtin, created_at, updated_at`,
z.Name, z.Description)
return scanZone(row)
}
// Update — builtin zones may have their description tweaked but
// not their name (the renderer + iface rows reference zones by
// name and a rename would silently dangle them). Custom zones can
// be renamed; the handler is responsible for cascading the new
// name into network_interfaces.role / firewall_rules.src_zone /
// dst_zone / firewall_nat_rules.in_zone / out_zone if needed.
func (r *ZonesRepo) Update(ctx context.Context, id int64, z models.FirewallZone) (*models.FirewallZone, error) {
cur, err := r.Get(ctx, id)
if err != nil {
return nil, err
}
name := z.Name
if cur.Builtin {
name = cur.Name
}
row := r.Pool.QueryRow(ctx, `
UPDATE firewall_zones SET
name = $1, description = $2, updated_at = NOW()
WHERE id = $3
RETURNING id, name, description, builtin, created_at, updated_at`,
name, z.Description, id)
return scanZone(row)
}
// Delete — builtin zones are non-deletable; for custom zones the
// caller must check for references in network_interfaces /
// firewall_rules / firewall_nat_rules first (handler concern).
func (r *ZonesRepo) Delete(ctx context.Context, id int64) error {
cur, err := r.Get(ctx, id)
if err != nil {
return err
}
if cur.Builtin {
return errors.New("builtin zone cannot be deleted")
}
tag, err := r.Pool.Exec(ctx, `DELETE FROM firewall_zones WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrZoneNotFound
}
return nil
}
// References returns the count of foreign uses of this zone (by
// name) so the handler can surface a "zone is still in use" error
// instead of letting a cascade go silent.
func (r *ZonesRepo) References(ctx context.Context, name string) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM network_interfaces WHERE role = $1) +
(SELECT COUNT(*) FROM firewall_rules WHERE src_zone = $1 OR dst_zone = $1) +
(SELECT COUNT(*) FROM firewall_nat_rules WHERE in_zone = $1 OR out_zone = $1)`,
name).Scan(&n)
return n, err
}
func scanZone(row interface{ Scan(...any) error }) (*models.FirewallZone, error) {
var z models.FirewallZone
if err := row.Scan(
&z.ID, &z.Name, &z.Description, &z.Builtin,
&z.CreatedAt, &z.UpdatedAt,
); err != nil {
return nil, err
}
return &z, nil
}