- 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>
85 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|