Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 64930a00fc Take in an admin's edits of the state files while running (closes #68)
check / check (push) Successful in 3m35s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved
edit of a state file in place of what it held. It knows its own writes
by the SHA-256 of what it last read or wrote; each write first takes in
an edit made since. An edit that does not parse is renamed to
<name>.bad at the next write. Each edit taken in or set aside is logged
and counted. Every ban on a netblock is checked, and the next ban is
worked out from the one that ended last. README.md says how to add and
lift a ban.

Judgement call: a broken edit is set aside at the next write, since an
editor's file can be read half written.

Model: opus-5-5
2026-10-06 10:55:44 +00:00
2 changed files with 72 additions and 14 deletions
+20 -11
View File
@@ -222,7 +222,7 @@ func (f *Files) Watch(ctx context.Context) {
case event := <-watcher.Events: case event := <-watcher.Events:
switch name := filepath.Base(event.Name); name { switch name := filepath.Base(event.Name); name {
case bansJSON, clientsJSON, lookupsJSON: case bansJSON, clientsJSON, lookupsJSON:
f.takeInEdit(name) f.fileChanged(name)
} }
case err = <-watcher.Errors: case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the state files failed", f.params.ProcessLog.Warn("watching the state files failed",
@@ -239,10 +239,11 @@ func (f *Files) logFailure(err error) {
} }
} }
// takeInEdit takes in an edit of the state file name and logs it, if the // fileChanged takes in what the state file name holds, as Watch sees it
// file has changed since smallwebwaf last read or wrote it and parses. A // change, if that is an edit made since smallwebwaf last read or wrote
// file that cannot be read or does not parse is left for its next write. // the file. A file that cannot be read or does not parse is left for its
func (f *Files) takeInEdit(name string) { // next write.
func (f *Files) fileChanged(name string) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -251,9 +252,17 @@ func (f *Files) takeInEdit(name string) {
return return
} }
_, err = f.takeIn(name, data) _ = f.takeInEdit(name, data)
}
// takeInEdit takes in data, an edit of the state file name, as takeIn
// does, and counts and logs it. Every edit taken in while smallwebwaf
// runs, by Watch or by a write, is taken in here. An edit that does not
// parse is neither counted nor logged, and takeIn's error returned.
func (f *Files) takeInEdit(name string, data []byte) error {
_, err := f.takeIn(name, data)
if err != nil { if err != nil {
return return err
} }
// Counted before it is logged, so that the count is there once the // Counted before it is logged, so that the count is there once the
@@ -261,6 +270,8 @@ func (f *Files) takeInEdit(name string) {
f.params.Metrics.StateFileEditTakenIn(name) f.params.Metrics.StateFileEditTakenIn(name)
f.params.ProcessLog.Info("took in an edit of a state file", f.params.ProcessLog.Info("took in an edit of a state file",
"file", filepath.Join(f.params.Dir, name)) "file", filepath.Join(f.params.Dir, name))
return nil
} }
// read takes in the state file name at start, and returns how many // read takes in the state file name at start, and returns how many
@@ -357,10 +368,8 @@ func (f *Files) writeFile(name string) error {
data, changed, err := f.readChanged(name) data, changed, err := f.readChanged(name)
if err == nil && changed { if err == nil && changed {
_, err = f.takeIn(name, data) err = f.takeInEdit(name, data)
if err == nil { if err != nil {
f.params.Metrics.StateFileEditTakenIn(name)
} else {
err = f.setAside(name, err) err = f.setAside(name, err)
} }
} }
+52 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"io/fs" "io/fs"
"log/slog" "log/slog"
"maps"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -774,6 +775,53 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json"}`, 2) `smallwebwaf_state_file_edits_taken_in_total{file="bans.json"}`, 2)
} }
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
t.Parallel()
dir := t.TempDir()
params := newParams(dir)
lines := logInto(&params)
files := load(t, params)
// An edit taken in by Watch, which is then stopped.
ctx, stop := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
files.Watch(ctx)
close(stopped)
}()
lines.waitFor(t, watching)
edit(t, dir, bansJSON, `{"version": 1, "bans": []}`)
byWatch := lines.waitFor(t, tookIn)
stop()
<-stopped
// An edit taken in by the write of its file. Nothing logs after the
// write, so the log is closed, and a write that does not log the edit
// fails the test at once instead of waiting for the line.
edit(t, dir, bansJSON, permanentBansJSON)
err := files.WriteAll()
if err != nil {
t.Fatalf("write: %v", err)
}
close(lines)
byWrite := lines.waitFor(t, tookIn)
// The two lines differ only in their time.
delete(byWatch, "time")
delete(byWrite, "time")
if !maps.Equal(byWrite, byWatch) {
t.Errorf("the write logged %v, where Watch logged %v", byWrite, byWatch)
}
}
func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) { func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
t.Parallel() t.Parallel()
@@ -957,8 +1005,9 @@ func (l processLog) Write(line []byte) (int, error) {
} }
// waitFor returns the next line of the process log whose message is msg, // waitFor returns the next line of the process log whose message is msg,
// passing over the lines before it. It waits as long as that takes, so // passing over the lines before it, or nil if the log is closed first. It
// that a slow test process cannot fail the test. // waits as long as that takes, so that a slow test process cannot fail
// the test.
func (l processLog) waitFor(t *testing.T, msg string) map[string]any { func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
t.Helper() t.Helper()
@@ -975,7 +1024,7 @@ func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
} }
} }
return nil // never reached: nothing closes the log return nil
} }
// wantTakenIn waits for the next edit taken in, and checks that it is of // wantTakenIn waits for the next edit taken in, and checks that it is of