Compare commits

1 Commits
Author SHA1 Message Date
clawbot 9097f30f95 Take in an admin's edits of the state files while running (closes #68)
check / check (push) Successful in 4m49s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved
edit of a state file in place of what it held. It tells its own writes
from an admin's 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 file's next write. Every ban on a netblock
is checked, and the next ban is worked out from the one that ended
last. Two metrics count the edits taken in and set aside. 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:17:29 +00:00
2 changed files with 14 additions and 72 deletions
+11 -20
View File
@@ -222,7 +222,7 @@ func (f *Files) Watch(ctx context.Context) {
case event := <-watcher.Events:
switch name := filepath.Base(event.Name); name {
case bansJSON, clientsJSON, lookupsJSON:
f.fileChanged(name)
f.takeInEdit(name)
}
case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the state files failed",
@@ -239,11 +239,10 @@ func (f *Files) logFailure(err error) {
}
}
// fileChanged takes in what the state file name holds, as Watch sees it
// change, if that is an edit made since smallwebwaf last read or wrote
// the file. A file that cannot be read or does not parse is left for its
// next write.
func (f *Files) fileChanged(name string) {
// takeInEdit takes in an edit of the state file name and logs it, if the
// file has changed since smallwebwaf last read or wrote it and parses. A
// file that cannot be read or does not parse is left for its next write.
func (f *Files) takeInEdit(name string) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -252,17 +251,9 @@ func (f *Files) fileChanged(name string) {
return
}
_ = 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)
_, err = f.takeIn(name, data)
if err != nil {
return err
return
}
// Counted before it is logged, so that the count is there once the
@@ -270,8 +261,6 @@ func (f *Files) takeInEdit(name string, data []byte) error {
f.params.Metrics.StateFileEditTakenIn(name)
f.params.ProcessLog.Info("took in an edit of a state file",
"file", filepath.Join(f.params.Dir, name))
return nil
}
// read takes in the state file name at start, and returns how many
@@ -368,8 +357,10 @@ func (f *Files) writeFile(name string) error {
data, changed, err := f.readChanged(name)
if err == nil && changed {
err = f.takeInEdit(name, data)
if err != nil {
_, err = f.takeIn(name, data)
if err == nil {
f.params.Metrics.StateFileEditTakenIn(name)
} else {
err = f.setAside(name, err)
}
}
+3 -52
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"io/fs"
"log/slog"
"maps"
"net"
"net/http"
"net/http/httptest"
@@ -775,53 +774,6 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
`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) {
t.Parallel()
@@ -1005,9 +957,8 @@ func (l processLog) Write(line []byte) (int, error) {
}
// waitFor returns the next line of the process log whose message is msg,
// passing over the lines before it, or nil if the log is closed first. It
// waits as long as that takes, so that a slow test process cannot fail
// the test.
// passing over the lines before it. It 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 {
t.Helper()
@@ -1024,7 +975,7 @@ func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
}
}
return nil
return nil // never reached: nothing closes the log
}
// wantTakenIn waits for the next edit taken in, and checks that it is of