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>
This commit is contained in:
84
internal/waf/alerts.go
Normal file
84
internal/waf/alerts.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user