feat(waf): Phase 1 — Migration + Service + Handler — v1.2.66
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
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"
|
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.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
|
||||||
handlers.NewCrowdSecHandler(auditRepo, nodeID).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
|
// withFW wraps a service-reloader so that AFTER the service is
|
||||||
// reloaded, the firewall is also re-rendered. Necessary for
|
// reloaded, the firewall is also re-rendered. Necessary for
|
||||||
|
|||||||
18
internal/database/migrations/0037_waf.sql
Normal file
18
internal/database/migrations/0037_waf.sql
Normal file
@@ -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;
|
||||||
137
internal/handlers/waf.go
Normal file
137
internal/handlers/waf.go
Normal file
@@ -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: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/models/waf.go
Normal file
19
internal/models/waf.go
Normal file
@@ -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" }
|
||||||
114
internal/services/waf/waf.go
Normal file
114
internal/services/waf/waf.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user