// 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() }