Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 55d403aa89 Take in an admin's edits of the state files while running (closes #68)
check / check (push) Successful in 3m28s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in an edit of
bans.json, clients.json or lookups.json as soon as it is saved, 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, and each write takes in an edit made since
first. An edit that does not parse is renamed to <name>.bad at the
file's next write, which writes the file again from memory and logs the
file and where the error is. README.md says how to add and lift a ban.

Judgement call: a broken edit is set aside at the file's next write, not
when seen, since an editor's file can be read half written.
Judgement call: a state file that cannot be read is not written over.

Model: opus-5-5
2026-10-06 07:44:48 +00:00
3 changed files with 20 additions and 76 deletions
+10 -23
View File
@@ -160,32 +160,20 @@ func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
continue
}
ban := activeBan(*bans, now)
if ban != nil {
ban.Notes.Requests++
ban.Notes.Refused++
// A ban is made only once the one before has ended, so only the
// last can be active.
last := &(*bans)[len(*bans)-1]
if last.ActiveAt(now) {
last.Notes.Requests++
last.Notes.Refused++
return *ban, true
return *last, true
}
}
return Ban{}, false
}
// activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the
// one that started last. Every ban is looked at, since a ban an admin adds
// to bans.json can start before the netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban {
for i := len(bans) - 1; i >= 0; i-- {
if bans[i].ActiveAt(now) {
return &bans[i]
}
}
return nil
}
// 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 last ban ended lasts
@@ -202,12 +190,11 @@ func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes)
bans, found := l.netblocks.Get(netblock)
if found {
active := activeBan(*bans, now)
if active != nil {
return *active
last = &(*bans)[len(*bans)-1]
if last.ActiveAt(now) {
return *last
}
last = &(*bans)[len(*bans)-1]
notes.EarlierBans = last.Notes.EarlierBans + 1
}
+6 -44
View File
@@ -130,38 +130,6 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
}
}
func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
t.Parallel()
// As when an admin adds a permanent ban to bans.json with a start
// before that of the netblock's ban that has ended.
netblock := netip.MustParsePrefix("203.0.113.0/24")
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
ended := bans.Ban{
Netblock: netblock,
Start: midnight(),
Expires: midnight().Add(time.Hour),
}
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{permanent, ended})
now := midnight().Add(2 * time.Hour)
ban, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), now)
if !banned || !ban.Permanent() {
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
banned, ban)
}
// A limit broken now makes no shorter ban over the permanent one.
ban = ledger.BanForLimit(netblock, now, bans.Notes{})
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
}
}
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
t.Parallel()
@@ -186,10 +154,8 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
func TestLoadReplacesTheBansHeld(t *testing.T) {
t.Parallel()
// Room for three bans, so that the second load, were it added to the
// two bans held, would drop none of them to make room.
rules := defaultRules()
rules.MaxBans = 3
rules.MaxBans = 2
ledger := bans.New(rules)
kept := bans.Ban{Netblock: netip.MustParsePrefix("2001:db8::/64"), Start: midnight()}
ledger.Load([]bans.Ban{
@@ -198,22 +164,18 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
})
// Loaded again without the first ban, as when an admin's edit of
// bans.json is taken in, that ban is lifted.
// bans.json is taken in: that ban is lifted, and the ledger, holding
// one ban, makes another without dropping any.
ledger.Load([]bans.Ban{kept})
made := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
bans.Notes{})
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
if banned {
t.Error("a ban left out of the second load still refuses")
}
// The ledger holds one ban, so it makes two more without dropping any.
first := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
bans.Notes{})
second := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
bans.Notes{})
want := []bans.Ban{first, second, kept}
if got := ledger.Snapshot(); !slices.Equal(got, want) {
if got, want := ledger.Snapshot(), []bans.Ban{made, kept}; !slices.Equal(got, want) {
t.Errorf("the ledger holds %+v, want %+v", got, want)
}
}
+4 -9
View File
@@ -378,11 +378,9 @@ func (f *Files) writeFile(name string) error {
return err
}
// The file holds data from here on, even if the directory sync fails,
// so that its next read does not take it for an admin's edit.
f.sums[name] = sha256.Sum256(data)
return syncDirectory(f.params.Dir)
return nil
}
// encode returns the state file name as smallwebwaf writes it, from a
@@ -622,7 +620,7 @@ func position(data []byte, err error) string {
// write writes data to the file name in dir so that a crash at any
// moment leaves either the old file or the new one, whole: data goes to a
// temporary file in the same directory, which is synced and renamed over
// name. syncDirectory must follow, so that the rename lasts.
// name, and then the directory is synced, so that the rename lasts.
func write(dir, name string, data []byte) error {
path := filepath.Join(dir, name)
temporary := path + ".tmp"
@@ -634,13 +632,10 @@ func write(dir, name string, data []byte) error {
if err != nil {
_ = os.Remove(temporary)
return err
}
return err
}
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
func syncDirectory(dir string) error {
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself
if err != nil {
return err