Alerts to a JSON webhook, with a cooldown and an hourly summary (closes #26)
check / check (push) Waiting to run
check / check (push) Waiting to run
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, in observe mode too, marked mode observe and worked out only when the alert would be sent; source_failure for GeoJS; file_error for a rule or state file with an error. SWWAF_ALERT_EVENTS chooses; SWWAF_ALERT_COOLDOWN holds back repeats by netblock, file or source; past SWWAF_ALERT_MAX_PER_HOUR the hour ends in one summary. A bounded queue, retried with backoff, holds up no request; a 4xx other than 408 and 429 gives the alert up. alerts.json keeps the queue, the cooldowns and the hour. Nothing shows the URL's path or query. Judgement call: the summary's event is summary, which SPEC.md omits. Judgement call: an admin's ban raises no alert. Model: opus-5-5
This commit was merged in pull request #93.
This commit is contained in:
+108
-20
@@ -1,7 +1,8 @@
|
||||
// Package state keeps smallwebwaf's state in JSON files in
|
||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
||||
// bans.json holds the bans, clients.json each client's counters and
|
||||
// history, and lookups.json GeoJS's answers. Load reads them at start,
|
||||
// history, lookups.json GeoJS's answers, and alerts.json the cooldowns,
|
||||
// the hour under way and the alerts waiting. Load reads them at start,
|
||||
// Watch takes in an admin's edit of one while smallwebwaf runs, and Run
|
||||
// and WriteAll write them. The disk is read and written outside the
|
||||
// parts' locks, which are held only to take a snapshot or to put in what
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
@@ -42,6 +44,7 @@ const (
|
||||
bansJSON = "bans.json"
|
||||
clientsJSON = "clients.json"
|
||||
lookupsJSON = "lookups.json"
|
||||
alertsJSON = "alerts.json"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -60,10 +63,13 @@ type Params struct {
|
||||
// is (SWWAF_STATE_COUNTER_INTERVAL).
|
||||
WriteDelay time.Duration
|
||||
CounterInterval time.Duration
|
||||
// Ledger, Limiter and GeoJS hold the state.
|
||||
// Ledger, Limiter, GeoJS and Alerts hold the state. Alerts also
|
||||
// receive a file_error alert for an edit set aside, and for a write
|
||||
// that fails while smallwebwaf runs.
|
||||
Ledger *bans.Ledger
|
||||
Limiter *ratelimit.Limiter
|
||||
GeoJS *lookup.GeoJS
|
||||
Alerts *alerts.Queue
|
||||
// Now tells the time by which the counters' buckets run out, normally
|
||||
// time.Now in UTC.
|
||||
Now func() time.Time
|
||||
@@ -121,6 +127,14 @@ type lookupsFile struct {
|
||||
Lookups []lookup.Answer `json:"lookups"`
|
||||
}
|
||||
|
||||
// alertsFile is alerts.json, indented for an admin to read and edit.
|
||||
type alertsFile struct {
|
||||
Version int `json:"version"`
|
||||
Cooldowns []alerts.Cooldown `json:"cooldowns"`
|
||||
Hour alerts.Hour `json:"hour"`
|
||||
Waiting []alerts.Alert `json:"waiting"`
|
||||
}
|
||||
|
||||
// stateFile is the struct of a state file. Once the file is decoded, its
|
||||
// check refuses the first entry without a field it needs, which would
|
||||
// otherwise be read as something the entry does not say. data is the
|
||||
@@ -147,23 +161,25 @@ func Load(params Params) (*Files, error) {
|
||||
bansRead, bansErr := f.read(bansJSON)
|
||||
clientsRead, clientsErr := f.read(clientsJSON)
|
||||
lookupsRead, lookupsErr := f.read(lookupsJSON)
|
||||
alertsRead, alertsErr := f.read(alertsJSON)
|
||||
|
||||
err = errors.Join(bansErr, clientsErr, lookupsErr)
|
||||
err = errors.Join(bansErr, clientsErr, lookupsErr, alertsErr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
||||
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead)
|
||||
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead,
|
||||
"alerts_waiting", alertsRead)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
||||
// made in between, and every file every CounterInterval, until ctx is
|
||||
// done. A write that fails is logged, and the file is written again at
|
||||
// its next write. Each write takes in an admin's edit of its file first,
|
||||
// as writeFile describes.
|
||||
// done. A write that fails is logged, raised as a file_error alert, and
|
||||
// the file is written again at its next write. Each write takes in an
|
||||
// admin's edit of its file first, as writeFile describes.
|
||||
func (f *Files) Run(ctx context.Context) {
|
||||
interval := time.NewTicker(f.params.CounterInterval)
|
||||
defer interval.Stop()
|
||||
@@ -181,9 +197,11 @@ func (f *Files) Run(ctx context.Context) {
|
||||
case <-bansDue:
|
||||
bansDue = nil
|
||||
|
||||
f.logFailure(f.writeFile(bansJSON))
|
||||
f.logFailure(bansJSON, f.writeFile(bansJSON))
|
||||
case <-interval.C:
|
||||
f.logFailure(f.WriteAll())
|
||||
for _, name := range []string{bansJSON, clientsJSON, lookupsJSON, alertsJSON} {
|
||||
f.logFailure(name, f.writeFile(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +210,7 @@ func (f *Files) Run(ctx context.Context) {
|
||||
// fails does not keep the others from being written.
|
||||
func (f *Files) WriteAll() error {
|
||||
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||
f.writeFile(lookupsJSON))
|
||||
f.writeFile(lookupsJSON), f.writeFile(alertsJSON))
|
||||
}
|
||||
|
||||
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||
@@ -227,7 +245,7 @@ func (f *Files) Watch(ctx context.Context) {
|
||||
return
|
||||
case event := <-watcher.Events:
|
||||
switch name := filepath.Base(event.Name); name {
|
||||
case bansJSON, clientsJSON, lookupsJSON:
|
||||
case bansJSON, clientsJSON, lookupsJSON, alertsJSON:
|
||||
f.fileChanged(name)
|
||||
}
|
||||
case err = <-watcher.Errors:
|
||||
@@ -237,11 +255,22 @@ func (f *Files) Watch(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// logFailure logs a write that failed.
|
||||
func (f *Files) logFailure(err error) {
|
||||
// logFailure logs a write of the state file name that failed, and raises
|
||||
// a file_error alert for it.
|
||||
func (f *Files) logFailure(name string, err error) {
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("writing the state files failed",
|
||||
"error", err.Error())
|
||||
const failed = "writing the state files failed"
|
||||
|
||||
// Raised before it is logged, so that the alert is there once the
|
||||
// log line is.
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
Reason: failed,
|
||||
Detail: map[string]any{
|
||||
"file": filepath.Join(f.params.Dir, name), "error": err.Error(),
|
||||
},
|
||||
})
|
||||
f.params.ProcessLog.Error(failed, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +391,18 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
||||
|
||||
f.params.GeoJS.Load(file.Lookups)
|
||||
entries = len(file.Lookups)
|
||||
case alertsJSON:
|
||||
var file alertsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f.params.Alerts.Load(alerts.State{
|
||||
Cooldowns: file.Cooldowns, Hour: file.Hour, Waiting: file.Waiting,
|
||||
})
|
||||
entries = len(file.Waiting)
|
||||
}
|
||||
|
||||
f.sums[name] = sha256.Sum256(data)
|
||||
@@ -413,8 +454,9 @@ func (f *Files) writeFile(name string) error {
|
||||
|
||||
// setAside renames the state file name, an edit that does not parse with
|
||||
// parseErr, to name.bad, for the admin to mend, and logs it with where in
|
||||
// the file the error is. If the rename fails, the edit is left as it is,
|
||||
// and the error returned is parseErr joined with the rename's.
|
||||
// the file the error is, and raises a file_error alert for it. If the
|
||||
// rename fails, the edit is left as it is, and the error returned is
|
||||
// parseErr joined with the rename's.
|
||||
func (f *Files) setAside(name string, parseErr error) error {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
@@ -423,8 +465,16 @@ func (f *Files) setAside(name string, parseErr error) error {
|
||||
return errors.Join(parseErr, err)
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Error("set aside an edit of a state file that does not parse",
|
||||
"file", path+".bad", "error", parseErr.Error())
|
||||
const setAside = "set aside an edit of a state file that does not parse"
|
||||
|
||||
// Raised before it is logged, so that the alert is there once the log
|
||||
// line is.
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
Reason: setAside,
|
||||
Detail: map[string]any{"file": path + ".bad", "error": parseErr.Error()},
|
||||
})
|
||||
f.params.ProcessLog.Error(setAside, "file", path+".bad", "error", parseErr.Error())
|
||||
f.params.Metrics.StateFileEditSetAside(name)
|
||||
|
||||
return nil
|
||||
@@ -445,8 +495,21 @@ func (f *Files) encode(name string) ([]byte, error) {
|
||||
return append(data, '\n'), nil
|
||||
case clientsJSON:
|
||||
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||
default: // lookups.json
|
||||
case lookupsJSON:
|
||||
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||
default: // alerts.json
|
||||
held := f.params.Alerts.Snapshot()
|
||||
file := alertsFile{
|
||||
Version: version, Cooldowns: held.Cooldowns, Hour: held.Hour,
|
||||
Waiting: held.Waiting,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +644,31 @@ func (f *lookupsFile) check(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check refuses a cooldown without its event or when its alert was sent,
|
||||
// which would hold back no repeat, and an alert waiting without its event
|
||||
// or its time.
|
||||
func (f *alertsFile) check([]byte) error {
|
||||
for i, cooldown := range f.Cooldowns {
|
||||
switch {
|
||||
case cooldown.Event == "":
|
||||
return fmt.Errorf("cooldowns %w", missing(i, "event"))
|
||||
case cooldown.Sent.IsZero():
|
||||
return fmt.Errorf("cooldowns %w", missing(i, "sent"))
|
||||
}
|
||||
}
|
||||
|
||||
for i, alert := range f.Waiting {
|
||||
switch {
|
||||
case alert.Event == "":
|
||||
return fmt.Errorf("waiting %w", missing(i, "event"))
|
||||
case alert.Time.IsZero():
|
||||
return fmt.Errorf("waiting %w", missing(i, "time"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countsWithoutStart reports whether b holds requests but no start, which
|
||||
// places them in time.
|
||||
func countsWithoutStart(b ratelimit.Buckets) bool {
|
||||
|
||||
+289
-19
@@ -10,8 +10,10 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
@@ -31,6 +34,7 @@ const (
|
||||
bansJSON = "bans.json"
|
||||
clientsJSON = "clients.json"
|
||||
lookupsJSON = "lookups.json"
|
||||
alertsJSON = "alerts.json"
|
||||
// What the process log says once Watch watches the directory, and as
|
||||
// it takes in an edit.
|
||||
watching = "watching the state files for edits"
|
||||
@@ -85,6 +89,67 @@ const liftedBansJSON = `{"version": 1, "bans": [{"netblock": "203.0.113.9/32", `
|
||||
`"start": "2026-10-06T00:00:00Z", "expires": "2026-10-06T01:00:00Z", ` +
|
||||
`"cause": "limit", "lifted": "2026-10-06T00:10:00Z"}]}`
|
||||
|
||||
// filledAlertsJSON is alerts.json holding the alerts of fill.
|
||||
const filledAlertsJSON = `{
|
||||
"version": 1,
|
||||
"cooldowns": [
|
||||
{
|
||||
"event": "file_error",
|
||||
"netblock": "",
|
||||
"file": "/var/lib/smallwebwaf/bans.json",
|
||||
"sent": "2026-10-06T00:00:00Z",
|
||||
"suppressed_repeats": 0
|
||||
},
|
||||
{
|
||||
"event": "ban",
|
||||
"netblock": "203.0.113.9/32",
|
||||
"sent": "2026-10-06T00:00:00Z",
|
||||
"suppressed_repeats": 1
|
||||
}
|
||||
],
|
||||
"hour": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"sent": 2,
|
||||
"held_back": {
|
||||
"source_failure": 1
|
||||
}
|
||||
},
|
||||
"waiting": [
|
||||
{
|
||||
"instance": "fsn1app1/gitea",
|
||||
"time": "2026-10-06T00:00:00Z",
|
||||
"event": "ban",
|
||||
"client": "203.0.113.9",
|
||||
"netblock": "203.0.113.9/32",
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "DE",
|
||||
"reason": "requests per minute over the limit of 1",
|
||||
"detail": {
|
||||
"cause": "limit"
|
||||
},
|
||||
"suppressed_repeats": 0
|
||||
},
|
||||
{
|
||||
"instance": "fsn1app1/gitea",
|
||||
"time": "2026-10-06T00:00:00Z",
|
||||
"event": "file_error",
|
||||
"client": "",
|
||||
"netblock": "",
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"reason": "writing the state files failed",
|
||||
"detail": {
|
||||
"error": "no space left on device",
|
||||
"file": "/var/lib/smallwebwaf/bans.json"
|
||||
},
|
||||
"suppressed_repeats": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
func TestFilesWrittenAndReadBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -111,13 +176,69 @@ func TestFilesWrittenAndReadBack(t *testing.T) {
|
||||
wantEqual(t, clientsJSON, after.Limiter.Snapshot(), before.Limiter.Snapshot())
|
||||
wantEqual(t, lookupsJSON, after.GeoJS.Snapshot(), before.GeoJS.Snapshot())
|
||||
|
||||
if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual(
|
||||
got, want) {
|
||||
t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want)
|
||||
}
|
||||
|
||||
// Each one-per-line file lists its entries by client, and nothing
|
||||
// but the three files is left in the directory.
|
||||
// but the four files is left in the directory.
|
||||
wantEntries(t, filepath.Join(dir, clientsJSON), "clients",
|
||||
"192.0.2.1/32", "203.0.113.9/32", "2001:db8::/64")
|
||||
wantEntries(t, filepath.Join(dir, lookupsJSON), "lookups",
|
||||
"192.0.2.1/32", "203.0.113.9/32")
|
||||
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON)
|
||||
}
|
||||
|
||||
func TestAlertsJSONIsIndentedWithTheCooldownsTheHourAndTheAlertsWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
fill(params)
|
||||
|
||||
files := load(t, params)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got := readFile(t, filepath.Join(dir, alertsJSON))
|
||||
if got != filledAlertsJSON {
|
||||
t.Errorf("alerts.json\n%s\nwant\n%s", got, filledAlertsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceFailureCooldownKeptInAlertsJSONAcrossARestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
before := newParams(dir)
|
||||
files := load(t, before)
|
||||
|
||||
failure := alerts.Alert{
|
||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||
Detail: map[string]any{"source": "geojs"},
|
||||
}
|
||||
before.Alerts.Raise(failure)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// After the restart, the cooldown read back holds back a repeat for the
|
||||
// same source.
|
||||
after := newParams(dir)
|
||||
load(t, after)
|
||||
after.Alerts.Raise(failure)
|
||||
|
||||
waiting := after.Alerts.Snapshot().Waiting
|
||||
if len(waiting) != 1 || after.Alerts.Suppressed() != 1 {
|
||||
t.Errorf("%d alerts wait and %d are held back, want the one read back and 1",
|
||||
len(waiting), after.Alerts.Suppressed())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansJSONIsIndentedWithNullForAPermanentBan(t *testing.T) {
|
||||
@@ -146,8 +267,10 @@ func TestMissingFilesAreEmptyState(t *testing.T) {
|
||||
params := newParams(t.TempDir())
|
||||
load(t, params)
|
||||
|
||||
held := params.Alerts.Snapshot()
|
||||
if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 ||
|
||||
len(params.GeoJS.Snapshot()) != 0 {
|
||||
len(params.GeoJS.Snapshot()) != 0 || len(held.Cooldowns) != 0 ||
|
||||
len(held.Waiting) != 0 || held.Hour.Sent != 0 {
|
||||
t.Error("state from no files")
|
||||
}
|
||||
}
|
||||
@@ -189,6 +312,11 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
`{"version": 1, "bans": [{"netblock": "203.0.113.300/32"}]}`,
|
||||
`: netip.ParsePrefix("203.0.113.300/32")`,
|
||||
},
|
||||
{
|
||||
"an unknown field of an alert waiting", alertsJSON,
|
||||
`{"version": 1, "waiting": [{"event": "ban", "evnet": "ban"}]}`,
|
||||
`: json: unknown field "evnet"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -278,6 +406,43 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, content string
|
||||
// want is what the error says after the file's path.
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"a cooldown without its event",
|
||||
`{"version": 1, "cooldowns": [{"sent": "2026-10-06T00:00:00Z"}]}`,
|
||||
`: cooldowns entry 1 has no "event"`,
|
||||
},
|
||||
{
|
||||
"a cooldown without when it was sent",
|
||||
`{"version": 1, "cooldowns": [{"event": "ban"}]}`,
|
||||
`: cooldowns entry 1 has no "sent"`,
|
||||
},
|
||||
{
|
||||
"an alert waiting without its event",
|
||||
`{"version": 1, "waiting": [{"time": "2026-10-06T00:00:00Z"}]}`,
|
||||
`: waiting entry 1 has no "event"`,
|
||||
},
|
||||
{
|
||||
"an alert waiting without its time",
|
||||
`{"version": 1, "waiting": [{"event": "ban"}]}`,
|
||||
`: waiting entry 1 has no "time"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRefused(t, alertsJSON, tc.content, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -294,7 +459,7 @@ func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
||||
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, file := range []string{bansJSON, clientsJSON, lookupsJSON} {
|
||||
for _, file := range []string{bansJSON, clientsJSON, lookupsJSON, alertsJSON} {
|
||||
for _, content := range []string{`{"version": 2}`, `{}`} {
|
||||
t.Run(file+" "+content, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -344,12 +509,12 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
||||
|
||||
// A second ban, made while the first waits to be written, puts the
|
||||
// write off no further, and is written with it.
|
||||
first := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
||||
first, _ := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
||||
midnight(), bans.Notes{})
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
second := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
||||
second, _ := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
||||
midnight(), bans.Notes{})
|
||||
|
||||
time.Sleep(5*time.Second - time.Nanosecond)
|
||||
@@ -396,8 +561,8 @@ func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
removeFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON)
|
||||
removeFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -440,6 +605,56 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.CounterInterval = time.Minute
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A directory in the way of clients.json's temporary file fails each
|
||||
// of its writes, while the other files are written. It holds a file,
|
||||
// so that the write cannot remove it.
|
||||
err := os.Mkdir(filepath.Join(dir, clientsJSON+".tmp"), 0o700)
|
||||
if err == nil {
|
||||
err = os.WriteFile(filepath.Join(dir, clientsJSON+".tmp", "kept"), nil, 0o600)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("put a directory in the way: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Minute)
|
||||
synctest.Wait()
|
||||
|
||||
waiting := params.Alerts.Snapshot().Waiting
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("%d alerts wait, want 1", len(waiting))
|
||||
}
|
||||
|
||||
message, _ := waiting[0].Detail["error"].(string)
|
||||
|
||||
if waiting[0].Event != alerts.EventFileError ||
|
||||
waiting[0].Reason != "writing the state files failed" ||
|
||||
waiting[0].Detail["file"] != filepath.Join(dir, clientsJSON) ||
|
||||
!strings.Contains(message, clientsJSON+".tmp") {
|
||||
t.Fatalf("alerts waiting %+v, want a file_error alert for clients.json, "+
|
||||
"naming its temporary file", waiting)
|
||||
}
|
||||
|
||||
// The next write fails too, within the cooldown, which holds it back.
|
||||
time.Sleep(time.Minute)
|
||||
synctest.Wait()
|
||||
|
||||
if len(params.Alerts.Snapshot().Waiting) != 1 || params.Alerts.Suppressed() != 1 {
|
||||
t.Errorf("%d alerts wait and %d are held back, want 1 and 1",
|
||||
len(params.Alerts.Snapshot().Waiting), params.Alerts.Suppressed())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -565,7 +780,7 @@ func TestFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
||||
t.Errorf("bans.json is now %v (%v), want the socket", info, err)
|
||||
}
|
||||
|
||||
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantWriteFailed(t, params, bansJSON)
|
||||
}
|
||||
|
||||
@@ -637,6 +852,25 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
wantTakenIn(t, lines, dir, lookupsJSON)
|
||||
wantEqual(t, lookupsJSON, params.GeoJS.Snapshot(),
|
||||
[]lookup.Answer{{Client: client, Country: "FR", Answered: midnight()}})
|
||||
|
||||
// A netblock with bits past its length is read as the netblock it is
|
||||
// in.
|
||||
edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+
|
||||
`"netblock": "198.51.100.9/24", "sent": "2026-10-06T00:00:00Z"}], `+
|
||||
`"waiting": [{"event": "file_error", "time": "2026-10-06T00:00:00Z"}]}`)
|
||||
wantTakenIn(t, lines, dir, alertsJSON)
|
||||
|
||||
want := alerts.State{
|
||||
Cooldowns: []alerts.Cooldown{{
|
||||
Event: alerts.EventBan, Netblock: netip.MustParsePrefix("198.51.100.0/24"),
|
||||
Sent: midnight(),
|
||||
}},
|
||||
Hour: alerts.Hour{HeldBack: map[string]int{}},
|
||||
Waiting: []alerts.Alert{{Event: alerts.EventFileError, Time: midnight()}},
|
||||
}
|
||||
if got := params.Alerts.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s taken in as\n%+v\nwant\n%+v", alertsJSON, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnWritesAreNotTakenIn(t *testing.T) {
|
||||
@@ -708,7 +942,7 @@ func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) {
|
||||
`"start": "2026-10-06T00:00:00Z", "expires": null}]}`)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
|
||||
_, banned := params.Ledger.Check(client, midnight())
|
||||
_, banned, _ := params.Ledger.Check(client, midnight())
|
||||
if !banned {
|
||||
t.Error("the ban added to bans.json does not refuse")
|
||||
}
|
||||
@@ -717,7 +951,7 @@ func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) {
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": []}`)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
|
||||
_, banned = params.Ledger.Check(client, midnight())
|
||||
_, banned, _ = params.Ledger.Check(client, midnight())
|
||||
if banned {
|
||||
t.Error("the ban removed from bans.json still refuses")
|
||||
}
|
||||
@@ -807,7 +1041,7 @@ func TestBanLiftedByAnEditWhileRunning(t *testing.T) {
|
||||
netblock := netip.MustParsePrefix(liftedClient + "/32")
|
||||
params.Ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
|
||||
_, banned := params.Ledger.Find(netblock.Addr(), afterLifting())
|
||||
_, banned, _ := params.Ledger.Find(netblock.Addr(), afterLifting())
|
||||
if !banned {
|
||||
t.Fatal("the ban does not refuse before it is lifted")
|
||||
}
|
||||
@@ -844,7 +1078,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
|
||||
edit(t, dir, bansJSON, broken)
|
||||
edit(t, dir, clientsJSON, `{"version": 1, "clients": []}`)
|
||||
wantTakenIn(t, lines, dir, clientsJSON)
|
||||
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON)
|
||||
|
||||
// The next write sets it aside, logged with where the error is, and
|
||||
// writes bans.json again from what smallwebwaf still holds.
|
||||
@@ -861,7 +1095,14 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
|
||||
t.Errorf("set aside with %v", line)
|
||||
}
|
||||
|
||||
wantFiles(t, dir, bansJSON, bansJSON+".bad", clientsJSON, lookupsJSON)
|
||||
// It is raised as a file_error alert, with the same file and error.
|
||||
waiting := params.Alerts.Snapshot().Waiting
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError ||
|
||||
waiting[0].Detail["file"] != path+".bad" || waiting[0].Detail["error"] != message {
|
||||
t.Errorf("alerts waiting %+v, want a file_error alert for %s", waiting, path+".bad")
|
||||
}
|
||||
|
||||
wantFiles(t, dir, alertsJSON, bansJSON, bansJSON+".bad", clientsJSON, lookupsJSON)
|
||||
|
||||
if got := readFile(t, path+".bad"); got != broken {
|
||||
t.Errorf("bans.json.bad holds\n%s\nwant the edit", got)
|
||||
@@ -990,7 +1231,8 @@ func midnight() time.Time {
|
||||
}
|
||||
|
||||
// newParams returns Params for the state files in dir, with parts that
|
||||
// hold nothing yet. GeoJS is never asked.
|
||||
// hold nothing yet. GeoJS is never asked, and the alerts, at most two an
|
||||
// hour, are never sent.
|
||||
func newParams(dir string) state.Params {
|
||||
discard := slog.New(slog.DiscardHandler)
|
||||
m := metrics.New(1)
|
||||
@@ -1010,6 +1252,14 @@ func newParams(dir string) state.Params {
|
||||
GeoJS: lookup.New(lookup.Params{
|
||||
Now: midnight, ProcessLog: discard, Metrics: m,
|
||||
}),
|
||||
Alerts: alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
MaxPerHour: 2,
|
||||
Instance: "fsn1app1/gitea",
|
||||
Now: midnight,
|
||||
}),
|
||||
Now: midnight,
|
||||
ProcessLog: discard,
|
||||
Metrics: m,
|
||||
@@ -1017,8 +1267,9 @@ func newParams(dir string) state.Params {
|
||||
}
|
||||
|
||||
// fill puts a permanent ban an admin made, a ban for a broken limit and
|
||||
// one for a clear sign of attack, clients with counts and histories, and
|
||||
// GeoJS answers into the parts of params.
|
||||
// one for a clear sign of attack, clients with counts and histories,
|
||||
// GeoJS answers, and alerts, as filledAlertsJSON holds them, into the
|
||||
// parts of params.
|
||||
func fill(params state.Params) {
|
||||
now := midnight()
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
@@ -1043,6 +1294,25 @@ func fill(params state.Params) {
|
||||
Answered: now.Add(-time.Hour), Used: now.Add(-time.Minute),
|
||||
},
|
||||
})
|
||||
|
||||
// An alert waiting, a repeat of it the cooldown holds back, another
|
||||
// alert waiting, and one past the two an hour, for the hour's summary.
|
||||
ban := alerts.Alert{
|
||||
Event: alerts.EventBan, Client: client.Addr(), Netblock: client, Country: "DE",
|
||||
Reason: "requests per minute over the limit of 1",
|
||||
Detail: map[string]any{"cause": "limit"},
|
||||
}
|
||||
params.Alerts.Raise(ban)
|
||||
params.Alerts.Raise(ban)
|
||||
params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError, Reason: "writing the state files failed",
|
||||
Detail: map[string]any{
|
||||
"file": "/var/lib/smallwebwaf/bans.json", "error": "no space left on device",
|
||||
},
|
||||
})
|
||||
params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||
})
|
||||
}
|
||||
|
||||
// permanentBan is the ban permanentBansJSON holds.
|
||||
@@ -1098,13 +1368,13 @@ func wantLiftedBanKept(
|
||||
|
||||
netblock := netip.MustParsePrefix(liftedClient + "/32")
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), afterLifting())
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), afterLifting())
|
||||
if banned {
|
||||
t.Error("the lifted ban refuses")
|
||||
}
|
||||
|
||||
// Were the lifted ban counted, the next would last three hours.
|
||||
ban := ledger.BanForLimit(netblock, afterLifting(), bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, afterLifting(), bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != time.Hour {
|
||||
t.Errorf("the next ban lasts %s, want 1h", ban.Expires.Sub(ban.Start))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user