Files
Debian 053b38e46c fix: AlertWriter graceful flush (#15) + Rolling-Update Robustheit (#19) — v1.2.95
#15 waf/alerts.go: AlertWriter.Close() flusht gepufferte Alerts + stoppt die Goroutine (stop/done-Channels, sync.Once, atomic closed; Kanal wird NIE geschlossen → Send racet ohne Panic). Wiring in cmd/edgeguard-waf nach ListenAndServe (graceful shutdown). -race-Test alerts_test.go.
#19 handlers/cluster_rollingupdate.go: (a) RollingUpdateStatus mutiert State nicht mehr beim GET — terminale Zustände altern in readRollingUpdateState nach 10 min aus (kein verlorenes 'done' bei parallelen Pollern). (b) State-File via sync.Mutex + configgen.AtomicWrite (kein partieller Read / Race zwischen Handler & Goroutine). (c) Version-Flip wird gegen die VORHER erfasste Secondary-Baseline geprüft statt gegen die Primary-Version (verhindert sofort-/nie-Flip).
Bewusst belassen: geteilter upgrade.sh-Pfad ist deterministischer Inhalt + an exakte sudoers-Zeile gebunden → Überschreib-Race benign; MST-Timestamp-Parse locale (Server laufen C-Locale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:21:50 +02:00

121 lines
3.0 KiB
Go

package waf
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"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
stop chan struct{}
done chan struct{}
closeOnce sync.Once
closed atomic.Bool
}
// 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),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go aw.run()
return aw
}
// Send enqueues an alert. Drops silently if the channel is full (or the
// writer is closing) to avoid slowing down / panicking SPOE handling.
func (aw *AlertWriter) Send(a Alert) {
if aw.closed.Load() {
return
}
select {
case aw.ch <- a:
default:
slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID)
}
}
// Close stops the writer and flushes buffered alerts (best-effort).
// Safe to call multiple times. The channel is never closed → Send never
// panics even if it races with Close.
func (aw *AlertWriter) Close() {
aw.closeOnce.Do(func() {
aw.closed.Store(true)
close(aw.stop)
})
<-aw.done
}
func (aw *AlertWriter) run() {
defer close(aw.done)
for {
select {
case a := <-aw.ch:
aw.write(a)
case <-aw.stop:
// Restliche gepufferte Alerts noch wegschreiben, dann Ende.
for {
select {
case a := <-aw.ch:
aw.write(a)
default:
return
}
}
}
}
}
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)
}
}