Alerts to a JSON webhook, with a cooldown and an hourly summary (closes #26)
check / check (push) Canceled after 0s

SWWAF_ALERT_WEBHOOK_URL gets one JSON POST per alert, in SPEC.md's
schema, with SWWAF_ALERT_WEBHOOK_HEADERS: ban and permanent_ban, with
the ban's notes; source_failure for GeoJS; file_error for a rule or
state file edit that does not parse and a failed state write.
SWWAF_ALERT_EVENTS chooses, SWWAF_ALERT_COOLDOWN holds back repeats,
and past SWWAF_ALERT_MAX_PER_HOUR the hour ends in one summary. A
bounded queue, retried with backoff, holds up no request; alerts.json
keeps it, the cooldowns and the hour. The ledger now reports whether it
made a ban, or made one permanent.

Judgement call: the summary's event is summary, which SPEC.md omits.
Judgement call: admin bans and observe mode raise no alert.

Model: opus-5-5
This commit is contained in:
2026-10-07 00:12:21 +00:00
parent 5d6f6ffaf9
commit a8e86c18db
24 changed files with 2768 additions and 209 deletions
+29 -21
View File
@@ -194,25 +194,27 @@ func (l *Ledger) Changed() <-chan struct{} {
// a ban on a netblock client is in is active, and returns that ban, with
// the request counted among those it refused. A ban for a clear sign of
// attack is made permanent by the request: the netblock is malicious.
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
// The last result reports whether the request made the ban permanent.
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) {
l.mu.Lock()
defer l.mu.Unlock()
ban := l.active(client, now)
if ban == nil {
return Ban{}, false
return Ban{}, false, false
}
ban.Notes.Requests++
ban.Notes.Refused++
if ban.Cause == CauseAttack && !ban.Permanent() {
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
if madePermanent {
ban.Expires = time.Time{}
l.markChanged()
}
return *ban, true
return *ban, true, madePermanent
}
// Find is Check without counting the request among those the ban
@@ -244,16 +246,18 @@ func activeBan(bans []Ban, now time.Time) *Ban {
}
// BanForLimit bans netblock at now for a broken limit, with notes, and
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
// LimitBanRepeatWindow after the netblock's ban that ended last, other
// than one for a clear sign of attack or a lifted one, lasts repeatFactor
// times as long as that one. A ban that would be longer than
// MaxBanDuration is permanent instead. If a ban on netblock is still
// returns the ban, and true. A first ban lasts LimitBanDuration. A ban
// made within LimitBanRepeatWindow after the netblock's ban that ended
// last, other than one for a clear sign of attack or a lifted one, lasts
// repeatFactor times as long as that one. A ban that would be longer
// than MaxBanDuration is permanent instead. If a ban on netblock is still
// active, as when two of its requests break a limit at once, that ban is
// returned and no other is made. The ledger fills in the notes' Refused
// and EarlierBans itself, and gives the ban the reason "requests per
// <Window> over the limit of <Limit>", from the notes.
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
// returned with false, and no other is made. The ledger fills in the
// notes' Refused and EarlierBans itself, and gives the ban the reason
// "requests per <Window> over the limit of <Limit>", from the notes.
func (l *Ledger) BanForLimit(
netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) {
reason := fmt.Sprintf("requests per %s over the limit of %d",
notes.Window, notes.Limit)
@@ -261,10 +265,13 @@ func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes)
}
// BanForAttack bans netblock at now for a clear sign of attack, with
// notes, and returns the ban, as BanForLimit does. A first ban lasts
// AttackBanDuration; once the netblock has had one that was not lifted,
// the next is permanent. Its reason is "matched the rule <RuleID>".
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
// notes, and returns the ban, and whether it made it, as BanForLimit
// does. A first ban lasts AttackBanDuration; once the netblock has had
// one that was not lifted, the next is permanent. Its reason is "matched
// the rule <RuleID>".
func (l *Ledger) BanForAttack(
netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) {
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
}
@@ -483,10 +490,11 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
}
// ban bans netblock at now for cause, with reason and notes, as
// BanForLimit and BanForAttack describe, and returns the ban.
// BanForLimit and BanForAttack describe, and returns the ban, and whether
// it made it.
func (l *Ledger) ban(
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes,
) Ban {
) (Ban, bool) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -497,7 +505,7 @@ func (l *Ledger) ban(
if found {
active := activeBan(*bans, now)
if active != nil {
return *active
return *active, false
}
held = *bans
@@ -517,7 +525,7 @@ func (l *Ledger) ban(
l.made[cause]++
l.markChanged()
return ban
return ban, true
}
// earlierBans returns how many bans a netblock with the bans held, oldest