From 72f793552eb72b51c45b55639f92d5730f40a6be Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 2 Jun 2026 15:28:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(waf):=20Phase=201=20=E2=80=94=20Migration?= =?UTF-8?q?=20+=20Service=20+=20Handler=20=E2=80=94=20v1.2.66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 0037: waf_configs-Tabelle (domain_id FK, enabled=false default, mode/paranoia_level/rule_exclusions/trusted_proxies/custom_rules) - models/waf.go: WafConfig-Model - services/waf/waf.go: Repo (List, GetByDomain, Upsert, ListEnabled) - handlers/waf.go: GET /waf/configs, GET /waf/configs/:id, PUT /waf/configs/:id — GET liefert Default-Config (disabled) wenn noch kein Row existiert - main.go: WafHandler registriert Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 + internal/database/migrations/0037_waf.sql | 18 +++ internal/handlers/waf.go | 137 ++++++++++++++++++++++ internal/models/waf.go | 19 +++ internal/services/waf/waf.go | 114 ++++++++++++++++++ 6 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 internal/database/migrations/0037_waf.sql create mode 100644 internal/handlers/waf.go create mode 100644 internal/models/waf.go create mode 100644 internal/services/waf/waf.go diff --git a/VERSION b/VERSION index 5313c2e..793f558 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.65 +1.2.66 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 011c90f..d0a907b 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -59,6 +59,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" + wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" ) var version = "1.2.35" @@ -383,6 +384,7 @@ func main() { } handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed) handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed) + handlers.NewWafHandler(wafsvc.New(pool), auditRepo, nodeID).Register(authed) // withFW wraps a service-reloader so that AFTER the service is // reloaded, the firewall is also re-rendered. Necessary for diff --git a/internal/database/migrations/0037_waf.sql b/internal/database/migrations/0037_waf.sql new file mode 100644 index 0000000..e08796a --- /dev/null +++ b/internal/database/migrations/0037_waf.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS waf_configs ( + id SERIAL PRIMARY KEY, + domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode TEXT NOT NULL DEFAULT 'detection' + CHECK (mode IN ('detection','blocking')), + paranoia_level INT NOT NULL DEFAULT 1 + CHECK (paranoia_level BETWEEN 1 AND 4), + rule_exclusions TEXT[] NOT NULL DEFAULT '{}', + trusted_proxies TEXT[] NOT NULL DEFAULT '{}', + custom_rules TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT waf_configs_domain_unique UNIQUE (domain_id) +); + +-- +goose Down +DROP TABLE IF EXISTS waf_configs; diff --git a/internal/handlers/waf.go b/internal/handlers/waf.go new file mode 100644 index 0000000..8cdb5b2 --- /dev/null +++ b/internal/handlers/waf.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "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" + wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" +) + +// WafHandler exposes the per-domain WAF configuration REST API: +// +// GET /waf/configs — list all configs (one per domain) +// GET /waf/configs/:domain_id — get config for a domain +// PUT /waf/configs/:domain_id — upsert config for a domain +type WafHandler struct { + Repo *wafsvc.Repo + Audit *audit.Repo + NodeID string +} + +func NewWafHandler(repo *wafsvc.Repo, a *audit.Repo, nodeID string) *WafHandler { + return &WafHandler{Repo: repo, Audit: a, NodeID: nodeID} +} + +func (h *WafHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/waf") + g.GET("/configs", h.List) + g.GET("/configs/:domain_id", h.Get) + g.PUT("/configs/:domain_id", h.Upsert) +} + +// List returns all WAF configs. +func (h *WafHandler) List(c *gin.Context) { + configs, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"configs": configs}) +} + +// Get returns the WAF config for a single domain. +// Returns a default (disabled) config when none exists yet. +func (h *WafHandler) Get(c *gin.Context) { + domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid domain_id")) + return + } + cfg, err := h.Repo.GetByDomain(c.Request.Context(), domainID) + if err != nil { + if errors.Is(err, wafsvc.ErrNotFound) { + // Return a default config so the UI always gets a usable object. + response.OK(c, gin.H{"config": defaultConfig(domainID)}) + return + } + response.Internal(c, err) + return + } + response.OK(c, gin.H{"config": cfg}) +} + +// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id. +type upsertBody struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + ParanoiaLevel int `json:"paranoia_level"` + RuleExclusions []string `json:"rule_exclusions"` + TrustedProxies []string `json:"trusted_proxies"` + CustomRules string `json:"custom_rules"` +} + +// Upsert creates or updates the WAF config for a domain. +func (h *WafHandler) Upsert(c *gin.Context) { + domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid domain_id")) + return + } + var body upsertBody + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, err) + return + } + if body.Mode == "" { + body.Mode = "detection" + } + if body.ParanoiaLevel < 1 || body.ParanoiaLevel > 4 { + body.ParanoiaLevel = 1 + } + if body.RuleExclusions == nil { + body.RuleExclusions = []string{} + } + if body.TrustedProxies == nil { + body.TrustedProxies = []string{} + } + + cfg := models.WafConfig{ + DomainID: domainID, + Enabled: body.Enabled, + Mode: body.Mode, + ParanoiaLevel: body.ParanoiaLevel, + RuleExclusions: body.RuleExclusions, + TrustedProxies: body.TrustedProxies, + CustomRules: body.CustomRules, + } + result, err := h.Repo.Upsert(c.Request.Context(), cfg) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert", + strconv.FormatInt(domainID, 10), + gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel}, + h.NodeID) + c.JSON(http.StatusOK, gin.H{"config": result}) +} + +// defaultConfig returns a sensible disabled default for a domain +// that has no WAF config row yet. +func defaultConfig(domainID int64) models.WafConfig { + return models.WafConfig{ + DomainID: domainID, + Enabled: false, + Mode: "detection", + ParanoiaLevel: 1, + RuleExclusions: []string{}, + TrustedProxies: []string{}, + CustomRules: "", + } +} diff --git a/internal/models/waf.go b/internal/models/waf.go new file mode 100644 index 0000000..467340b --- /dev/null +++ b/internal/models/waf.go @@ -0,0 +1,19 @@ +package models + +import "time" + +// WafConfig holds the per-domain WAF policy. +// Default on creation: enabled=false, mode=detection, paranoia_level=1. +type WafConfig struct { + ID int64 `gorm:"primaryKey" json:"id"` + DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"` + Enabled bool `gorm:"column:enabled" json:"enabled"` + Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking" + ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 1–4 + RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"` + TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"` + CustomRules string `gorm:"column:custom_rules" json:"custom_rules"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (WafConfig) TableName() string { return "waf_configs" } diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go new file mode 100644 index 0000000..5bf7b87 --- /dev/null +++ b/internal/services/waf/waf.go @@ -0,0 +1,114 @@ +// Package waf implements CRUD for per-domain WAF policies (waf_configs). +package waf + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("waf config not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, domain_id, enabled, mode, paranoia_level, + rule_exclusions, trusted_proxies, custom_rules, updated_at +FROM waf_configs +` + +func scan(row pgx.Row) (*models.WafConfig, error) { + var c models.WafConfig + err := row.Scan( + &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, + &c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &c, nil +} + +// List returns all WAF configs ordered by domain_id. +func (r *Repo) List(ctx context.Context) ([]models.WafConfig, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain_id ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WafConfig, 0, 16) + for rows.Next() { + c, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +// GetByDomain returns the WAF config for a domain, or ErrNotFound. +func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConfig, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain_id = $1", domainID) + c, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return c, nil +} + +// Upsert inserts or updates the WAF config for a domain. +// Returns the resulting row. +func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) { + c.UpdatedAt = time.Now() + row := r.Pool.QueryRow(ctx, ` + INSERT INTO waf_configs + (domain_id, enabled, mode, paranoia_level, + rule_exclusions, trusted_proxies, custom_rules, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (domain_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + mode = EXCLUDED.mode, + paranoia_level = EXCLUDED.paranoia_level, + rule_exclusions = EXCLUDED.rule_exclusions, + trusted_proxies = EXCLUDED.trusted_proxies, + custom_rules = EXCLUDED.custom_rules, + updated_at = EXCLUDED.updated_at + RETURNING id, domain_id, enabled, mode, paranoia_level, + rule_exclusions, trusted_proxies, custom_rules, updated_at + `, + c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel, + c.RuleExclusions, c.TrustedProxies, c.CustomRules, c.UpdatedAt, + ) + return scan(row) +} + +// ListEnabled returns only configs with enabled=true (used by the WAF agent). +func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" WHERE enabled = true ORDER BY domain_id ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WafConfig, 0, 8) + for rows.Next() { + c, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +}