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:
147
internal/services/firewall/zones.go
Normal file
147
internal/services/firewall/zones.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user