Files
edgeguard-native/internal/services/waf/waf.go
Debian 8a4fefee73 feat(waf): Ausnahmen mit Notiz + Ausnahmen-Liste im Drawer — v1.2.79
- Migration 0039: exclusion_notes JSONB in waf_configs (rule_id → note)
- Model/Service/Handler: exclusion_notes in Upsert + GET durchgereicht
- Alerts-Tab: "Als Ausnahme"-Button öffnet Modal mit Notiz-Textarea;
  Notiz wird in exclusion_notes gespeichert
- Config-Drawer: Ausnahmen als Liste (Rule-ID + Notiz + Entfernen-Button)
  statt rohem Textfeld; Ausnahmen nur noch via Alert-Tab hinzufügbar
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:31:49 +02:00

228 lines
6.5 KiB
Go

// 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, exclusion_notes, 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.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
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()
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_configs
(domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions,
exclusion_notes = EXCLUDED.exclusion_notes,
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, exclusion_notes, trusted_proxies, custom_rules, updated_at
`,
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
c.RuleExclusions, c.ExclusionNotes, 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()
}
// WafAlert mirrors the waf_alerts DB row.
type WafAlert struct {
ID int64 `json:"id"`
DomainID *int64 `json:"domain_id,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
URI string `json:"uri"`
RuleID int `json:"rule_id"`
RuleMsg string `json:"rule_msg"`
Severity string `json:"severity"`
Action string `json:"action"`
CreatedAt time.Time `json:"created_at"`
}
// ListAlerts returns recent WAF alerts, optionally filtered by domain_id.
func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]WafAlert, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error }
var err error
if domainID != nil {
rows2, e := r.Pool.Query(ctx, `
SELECT id, domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action, created_at
FROM waf_alerts
WHERE domain_id = $1
ORDER BY created_at DESC LIMIT $2
`, *domainID, limit)
rows, err = rows2, e
} else {
rows2, e := r.Pool.Query(ctx, `
SELECT id, domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action, created_at
FROM waf_alerts
ORDER BY created_at DESC LIMIT $1
`, limit)
rows, err = rows2, e
}
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]WafAlert, 0, limit)
for rows.Next() {
var a WafAlert
if err := rows.Scan(
&a.ID, &a.DomainID, &a.Hostname, &a.ClientIP, &a.Method, &a.URI,
&a.RuleID, &a.RuleMsg, &a.Severity, &a.Action, &a.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PurgeAlerts removes alerts older than the given number of days.
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
_, err := r.Pool.Exec(ctx,
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`,
olderThanDays,
)
return err
}
// DomainConfigPair combines a domain hostname with its WAF config.
type DomainConfigPair struct {
Hostname string
Config models.WafConfig
}
// ListAllWithDomain returns all WAF configs joined with their domain name.
// Used by the WAF agent to build the hostname→engine mapping.
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
rows, err := r.Pool.Query(ctx, `
SELECT d.name,
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at
FROM waf_configs w
JOIN domains d ON d.id = w.domain_id
WHERE d.active = true
ORDER BY d.name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]DomainConfigPair, 0, 16)
for rows.Next() {
var p DomainConfigPair
var c models.WafConfig
if err := rows.Scan(
&p.Hostname,
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
); err != nil {
return nil, err
}
p.Config = c
out = append(out, p)
}
return out, rows.Err()
}