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:
48
internal/database/migrations/0012_firewall_zones.sql
Normal file
48
internal/database/migrations/0012_firewall_zones.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- firewall_zones promotes zones from a hard-coded enum
|
||||
-- (wan/lan/dmz/mgmt/cluster) into a first-class entity. Operators
|
||||
-- can add their own (e.g. iot, guest, voip) without a schema
|
||||
-- change. Existing role/zone TEXT columns on network_interfaces,
|
||||
-- firewall_rules and firewall_nat_rules continue to store the
|
||||
-- zone NAME — referential integrity is enforced at the application
|
||||
-- layer (handler validates name exists in firewall_zones), not by
|
||||
-- a hard FK, so 'any' on rules and NULL on NAT keep working
|
||||
-- without special-casing.
|
||||
--
|
||||
-- builtin = TRUE marks the seed zones; the API rejects DELETE on
|
||||
-- those rows to prevent the operator from removing a zone the
|
||||
-- renderer still expects.
|
||||
CREATE TABLE IF NOT EXISTS firewall_zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT firewall_zones_name_check CHECK (name ~ '^[a-z][a-z0-9_-]{0,31}$')
|
||||
);
|
||||
|
||||
INSERT INTO firewall_zones (name, description, builtin) VALUES
|
||||
('wan', 'Public-facing internet uplink', TRUE),
|
||||
('lan', 'Internal trusted network', TRUE),
|
||||
('dmz', 'Quarantined service network', TRUE),
|
||||
('mgmt', 'Admin-only management network', TRUE),
|
||||
('cluster', 'Inter-node cluster traffic (KeyDB / mTLS API)', TRUE)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Drop the hard-coded CHECK constraints so the operator can declare
|
||||
-- new zones without the SQL layer rejecting them. App-side
|
||||
-- validation in the handlers takes over.
|
||||
ALTER TABLE network_interfaces DROP CONSTRAINT IF EXISTS network_interfaces_role_check;
|
||||
ALTER TABLE firewall_rules DROP CONSTRAINT IF EXISTS firewall_rules_src_zone_check;
|
||||
ALTER TABLE firewall_rules DROP CONSTRAINT IF EXISTS firewall_rules_dst_zone_check;
|
||||
ALTER TABLE firewall_nat_rules DROP CONSTRAINT IF EXISTS firewall_nat_rules_zone_check;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS firewall_zones;
|
||||
-- +goose StatementEnd
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -70,5 +70,5 @@ backend api_backend
|
||||
{{- range .Backends}}
|
||||
|
||||
backend eg_backend_{{.ID}}
|
||||
server {{.Name}} {{.Address}}:{{.Port}}{{if .HealthCheckPath}} check inter 5s{{end}}
|
||||
server {{.Name | safeID}} {{.Address}}:{{.Port}}{{if .HealthCheckPath}} check inter 5s{{end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -25,7 +26,33 @@ import (
|
||||
//go:embed haproxy.cfg.tpl
|
||||
var cfgTpl string
|
||||
|
||||
var tpl = template.Must(template.New("haproxy").Parse(cfgTpl))
|
||||
// safeID converts a free-form display name like "Control Master 1"
|
||||
// into a single token HAProxy accepts as a server-id (no spaces /
|
||||
// special chars). Anything outside [a-zA-Z0-9_-] becomes '_'.
|
||||
func safeID(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
out = "unnamed"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var tpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{
|
||||
"safeID": safeID,
|
||||
}).Parse(cfgTpl))
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
|
||||
14
internal/models/firewall_zone.go
Normal file
14
internal/models/firewall_zone.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type FirewallZone struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Description *string `gorm:"column:description" json:"description,omitempty"`
|
||||
Builtin bool `gorm:"column:builtin" json:"builtin"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (FirewallZone) TableName() string { return "firewall_zones" }
|
||||
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