Files
edgeguard-native/internal/waf/alerts.go
Debian 220d9d7050 feat(waf): Alerts — Regelübereinstimmungen in DB + UI — v1.2.77
- Migration 0038: waf_alerts-Tabelle
- AlertWriter (Buffered-Channel → async DB-Write)
- SPOE: MatchedRules → sendAlert() nach ProcessRequestHeaders()
- API: GET /waf/alerts + DELETE /waf/alerts
- WAF-Page: Tabs Domains | Alarme; Alarme-Tabelle mit Rule-ID,
  Severity, Aktion (Detected/Blocked), URI, Client-IP + Purge-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:40:43 +02:00

85 lines
2.2 KiB
Go

package waf
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Alert represents a single WAF rule match that was logged.
type Alert 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"` // "detected" | "blocked"
CreatedAt time.Time `json:"created_at"`
}
// AlertWriter accepts Alert values via a buffered channel and writes
// them to PostgreSQL asynchronously so SPOE handling stays low-latency.
type AlertWriter struct {
pool *pgxpool.Pool
ch chan Alert
}
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
// bufSize is the number of unwritten alerts that can queue before drops.
func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
aw := &AlertWriter{
pool: pool,
ch: make(chan Alert, bufSize),
}
go aw.run()
return aw
}
// Send enqueues an alert. Drops silently if the channel is full to
// avoid slowing down SPOE request handling.
func (aw *AlertWriter) Send(a Alert) {
select {
case aw.ch <- a:
default:
slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID)
}
}
func (aw *AlertWriter) run() {
for a := range aw.ch {
aw.write(a)
}
}
func (aw *AlertWriter) write(a Alert) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Resolve domain_id from hostname (best-effort).
var domainID *int64
var id int64
if err := aw.pool.QueryRow(ctx,
`SELECT id FROM domains WHERE name = $1 AND active = true LIMIT 1`,
a.Hostname,
).Scan(&id); err == nil {
domainID = &id
}
if _, err := aw.pool.Exec(ctx, `
INSERT INTO waf_alerts
(domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
`, domainID, a.Hostname, a.ClientIP, a.Method, a.URI,
a.RuleID, a.RuleMsg, a.Severity, a.Action,
); err != nil {
slog.Warn("waf: write alert to db failed", "error", err)
}
}