Take in an admin's edits of the state files while running (closes #68)
check / check (push) Successful in 4m49s
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
This commit is contained in:
+259
-74
@@ -1,14 +1,17 @@
|
||||
// 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, and
|
||||
// Run and WriteAll write them, each from a snapshot its part takes under
|
||||
// its own lock, so that no request waits on the disk.
|
||||
// history, and lookups.json GeoJS's answers. 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
|
||||
// a file holds, so that no request waits on the disk.
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,8 +20,10 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
@@ -61,15 +66,26 @@ type Params struct {
|
||||
// Now tells the time by which the counters' buckets run out, normally
|
||||
// time.Now in UTC.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives what was read, and the writes that fail.
|
||||
// ProcessLog receives what was read and taken in, the edits set aside,
|
||||
// and the writes that fail.
|
||||
ProcessLog *slog.Logger
|
||||
// Metrics count each file's writes.
|
||||
// Metrics count each file's writes, and the edits taken in and set
|
||||
// aside.
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// Files are the state files of a running smallwebwaf.
|
||||
type Files struct {
|
||||
params Params
|
||||
|
||||
// mu is held while a file is read for an edit, and while it is
|
||||
// written, so that Watch and the writes take turns. No request takes
|
||||
// it.
|
||||
mu sync.Mutex
|
||||
// sums are the SHA-256 sums of what each file held, by name, when
|
||||
// smallwebwaf last read or wrote it. A file that holds anything else
|
||||
// has been edited since.
|
||||
sums map[string][sha256.Size]byte
|
||||
}
|
||||
|
||||
// bansFile is bans.json, indented for an admin to read and edit.
|
||||
@@ -120,41 +136,28 @@ func Load(params Params) (*Files, error) {
|
||||
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
bansIn bansFile
|
||||
clientsIn clientsFile
|
||||
lookupsIn lookupsFile
|
||||
)
|
||||
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||
|
||||
err = errors.Join(
|
||||
read(params.Dir, bansJSON, &bansIn),
|
||||
read(params.Dir, clientsJSON, &clientsIn),
|
||||
read(params.Dir, lookupsJSON, &lookupsIn),
|
||||
)
|
||||
bansRead, bansErr := f.read(bansJSON)
|
||||
clientsRead, clientsErr := f.read(clientsJSON)
|
||||
lookupsRead, lookupsErr := f.read(lookupsJSON)
|
||||
|
||||
err = errors.Join(bansErr, clientsErr, lookupsErr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
held := make([]bans.Ban, 0, len(bansIn.Bans))
|
||||
for _, entry := range bansIn.Bans {
|
||||
held = append(held, entry.ban())
|
||||
}
|
||||
|
||||
params.Ledger.Load(held)
|
||||
params.Limiter.Load(clientsIn.Clients, params.Now())
|
||||
params.GeoJS.Load(lookupsIn.Lookups)
|
||||
|
||||
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
||||
"bans", len(bansIn.Bans), "clients", len(clientsIn.Clients),
|
||||
"lookups", len(lookupsIn.Lookups))
|
||||
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead)
|
||||
|
||||
return &Files{params: params}, nil
|
||||
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.
|
||||
// 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()
|
||||
@@ -172,7 +175,7 @@ func (f *Files) Run(ctx context.Context) {
|
||||
case <-bansDue:
|
||||
bansDue = nil
|
||||
|
||||
f.logFailure(f.writeBans())
|
||||
f.logFailure(f.writeFile(bansJSON))
|
||||
case <-interval.C:
|
||||
f.logFailure(f.WriteAll())
|
||||
}
|
||||
@@ -182,7 +185,50 @@ func (f *Files) Run(ctx context.Context) {
|
||||
// WriteAll writes every state file, as smallwebwaf stops. A file that
|
||||
// fails does not keep the others from being written.
|
||||
func (f *Files) WriteAll() error {
|
||||
return errors.Join(f.writeBans(), f.writeClients(), f.writeLookups())
|
||||
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||
f.writeFile(lookupsJSON))
|
||||
}
|
||||
|
||||
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||
// state file as soon as it is saved: what the file holds replaces what
|
||||
// smallwebwaf held for it. An edit that does not parse is left for the
|
||||
// file's next write, which sets it aside, since a file can be read while
|
||||
// an editor is still writing it. If Dir cannot be watched, that is
|
||||
// logged, and an edit is taken in only before its file is written.
|
||||
func (f *Files) Watch(ctx context.Context) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
_ = watcher.Close()
|
||||
}()
|
||||
|
||||
err = watcher.Add(f.params.Dir)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("cannot watch the state files for edits",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Info("watching the state files for edits",
|
||||
"directory", f.params.Dir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event := <-watcher.Events:
|
||||
switch name := filepath.Base(event.Name); name {
|
||||
case bansJSON, clientsJSON, lookupsJSON:
|
||||
f.takeInEdit(name)
|
||||
}
|
||||
case err = <-watcher.Errors:
|
||||
f.params.ProcessLog.Warn("watching the state files failed",
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logFailure logs a write that failed.
|
||||
@@ -193,52 +239,200 @@ func (f *Files) logFailure(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeBans writes bans.json.
|
||||
func (f *Files) writeBans() error {
|
||||
held := f.params.Ledger.Snapshot()
|
||||
// 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()
|
||||
|
||||
file := bansFile{Version: version, Bans: make([]banEntry, 0, len(held))}
|
||||
for _, ban := range held {
|
||||
file.Bans = append(file.Bans, newBanEntry(ban))
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err != nil || !changed {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
_, err = f.takeIn(name, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", bansJSON, err)
|
||||
return
|
||||
}
|
||||
|
||||
return f.writeCounted(bansJSON, append(data, '\n'))
|
||||
// Counted before it is logged, so that the count is there once the
|
||||
// log line is.
|
||||
f.params.Metrics.StateFileEditTakenIn(name)
|
||||
f.params.ProcessLog.Info("took in an edit of a state file",
|
||||
"file", filepath.Join(f.params.Dir, name))
|
||||
}
|
||||
|
||||
// writeClients writes clients.json.
|
||||
func (f *Files) writeClients() error {
|
||||
data, err := encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", clientsJSON, err)
|
||||
// read takes in the state file name at start, and returns how many
|
||||
// entries it holds. A missing file holds none.
|
||||
func (f *Files) read(name string) (int, error) {
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err != nil || !changed {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return f.writeCounted(clientsJSON, data)
|
||||
return f.takeIn(name, data)
|
||||
}
|
||||
|
||||
// writeLookups writes lookups.json.
|
||||
func (f *Files) writeLookups() error {
|
||||
data, err := encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", lookupsJSON, err)
|
||||
// readChanged returns what the state file name holds, and whether that
|
||||
// has changed since smallwebwaf last read or wrote the file, as it has
|
||||
// for a file smallwebwaf never read or wrote. A missing file has not
|
||||
// changed: it is written again at its next write.
|
||||
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return f.writeCounted(lookupsJSON, data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return data, sha256.Sum256(data) != f.sums[name], nil
|
||||
}
|
||||
|
||||
// writeCounted writes data to the state file name, as write does, and
|
||||
// counts the write in the metrics.
|
||||
func (f *Files) writeCounted(name string, data []byte) error {
|
||||
err := write(f.params.Dir, name, data)
|
||||
// takeIn parses data, what the state file name holds, puts it into the
|
||||
// part that keeps that state, in place of what the part held, and returns
|
||||
// how many entries the file holds. An error names the file and, where the
|
||||
// JSON decoder tells it, the line and column, or else the entry.
|
||||
func (f *Files) takeIn(name string, data []byte) (int, error) {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
var entries int
|
||||
|
||||
switch name {
|
||||
case bansJSON:
|
||||
var file bansFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
held := make([]bans.Ban, 0, len(file.Bans))
|
||||
for _, entry := range file.Bans {
|
||||
held = append(held, entry.ban())
|
||||
}
|
||||
|
||||
f.params.Ledger.Load(held)
|
||||
entries = len(held)
|
||||
case clientsJSON:
|
||||
var file clientsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f.params.Limiter.Load(file.Clients, f.params.Now())
|
||||
entries = len(file.Clients)
|
||||
case lookupsJSON:
|
||||
var file lookupsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f.params.GeoJS.Load(file.Lookups)
|
||||
entries = len(file.Lookups)
|
||||
}
|
||||
|
||||
f.sums[name] = sha256.Sum256(data)
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// writeFile writes the state file name from what smallwebwaf holds. An
|
||||
// edit made since smallwebwaf last read or wrote the file is taken in
|
||||
// first, so that it is not overwritten, or set aside if it does not
|
||||
// parse. A file that cannot be read, or an edit that cannot be set
|
||||
// aside, is left as it is, and the write given up. Every write is counted
|
||||
// in the metrics, and one that fails or is given up as a failure.
|
||||
func (f *Files) writeFile(name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err == nil && changed {
|
||||
_, err = f.takeIn(name, data)
|
||||
if err == nil {
|
||||
f.params.Metrics.StateFileEditTakenIn(name)
|
||||
} else {
|
||||
err = f.setAside(name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
data, err = f.encode(name)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("encode %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = write(f.params.Dir, name, data)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// 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)
|
||||
err = syncDirectory(f.params.Dir)
|
||||
}
|
||||
|
||||
f.params.Metrics.StateFileWritten(name, len(data), err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (f *Files) setAside(name string, parseErr error) error {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
err := os.Rename(path, path+".bad")
|
||||
if err != nil {
|
||||
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())
|
||||
f.params.Metrics.StateFileEditSetAside(name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encode returns the state file name as smallwebwaf writes it, from a
|
||||
// snapshot of the part that keeps that state.
|
||||
func (f *Files) encode(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case bansJSON:
|
||||
held := f.params.Ledger.Snapshot()
|
||||
|
||||
file := bansFile{Version: version, Bans: make([]banEntry, 0, len(held))}
|
||||
for _, ban := range held {
|
||||
file.Bans = append(file.Bans, newBanEntry(ban))
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(data, '\n'), nil
|
||||
case clientsJSON:
|
||||
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||
default: // lookups.json
|
||||
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// newBanEntry returns ban as bans.json holds it.
|
||||
func newBanEntry(ban bans.Ban) banEntry {
|
||||
entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes}
|
||||
@@ -389,28 +583,16 @@ func checkWritable(dir string) error {
|
||||
return errors.Join(file.Close(), os.Remove(file.Name()))
|
||||
}
|
||||
|
||||
// read reads the state file name in dir into file, a pointer to that
|
||||
// file's struct, and checks its entries. A missing file leaves file as it
|
||||
// is.
|
||||
func read(dir, name string, file stateFile) error {
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse reads data, what the state file at path holds, into file, a
|
||||
// pointer to that file's struct, and checks its entries.
|
||||
func parse(path string, data []byte, file stateFile) error {
|
||||
// The version is read first, so that a file of another version is
|
||||
// refused for that, and not for an entry this version cannot read.
|
||||
var header struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &header)
|
||||
err := json.Unmarshal(data, &header)
|
||||
if err == nil && header.Version != version {
|
||||
err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d",
|
||||
errVersion, header.Version, version)
|
||||
@@ -463,7 +645,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, and then the directory is synced, so that the rename lasts.
|
||||
// name. syncDirectory must follow, so that the rename lasts.
|
||||
func write(dir, name string, data []byte) error {
|
||||
path := filepath.Join(dir, name)
|
||||
temporary := path + ".tmp"
|
||||
@@ -475,10 +657,13 @@ 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
|
||||
|
||||
+432
-11
@@ -3,7 +3,9 @@ package state_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -28,6 +30,13 @@ const (
|
||||
bansJSON = "bans.json"
|
||||
clientsJSON = "clients.json"
|
||||
lookupsJSON = "lookups.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"
|
||||
tookIn = "took in an edit of a state file"
|
||||
// maxLogLines is how many lines of the process log wait for a test to
|
||||
// read them.
|
||||
maxLogLines = 64
|
||||
)
|
||||
|
||||
// permanentBansJSON is bans.json holding permanentBan.
|
||||
@@ -290,7 +299,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The two tests below run Run in a synctest bubble, where time is a clock
|
||||
// The three tests below run Run in a synctest bubble, where time is a clock
|
||||
// of the test's own: time.Sleep moves it on at once, and synctest.Wait
|
||||
// returns once Run waits for its next write, so that every write due by
|
||||
// then is on disk.
|
||||
@@ -302,7 +311,7 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.WriteDelay = 10 * time.Second
|
||||
run(t, load(t, params))
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A second ban, made while the first waits to be written, puts the
|
||||
// write off no further, and is written with it.
|
||||
@@ -347,7 +356,7 @@ func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.CounterInterval = time.Minute
|
||||
run(t, load(t, params))
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// The files are removed once written, so that each interval shows
|
||||
// them written again.
|
||||
@@ -364,6 +373,44 @@ func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.WriteDelay = 10 * time.Second
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A ban, and bans.json written with it.
|
||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
||||
midnight(), bans.Notes{})
|
||||
time.Sleep(params.WriteDelay)
|
||||
synctest.Wait()
|
||||
|
||||
// A second ban is to be written WriteDelay later. Just before
|
||||
// then, an admin saves bans.json with the first ban lifted and
|
||||
// another added.
|
||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
||||
midnight(), bans.Notes{})
|
||||
time.Sleep(params.WriteDelay - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
edit(t, dir, bansJSON, permanentBansJSON)
|
||||
|
||||
// The write takes the edit in first, and writes it back. The second
|
||||
// ban, made after the admin opened the file, is lost, as "Edits
|
||||
// while running" in SPEC.md says.
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
|
||||
if got := readFile(t, filepath.Join(dir, bansJSON)); got != permanentBansJSON {
|
||||
t.Errorf("bans.json holds\n%s\nwant the edit", got)
|
||||
}
|
||||
|
||||
wantEqual(t, bansJSON, params.Ledger.Snapshot(), []bans.Ban{permanentBan()})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -459,24 +506,312 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
|
||||
float64(len(permanentBansJSON)))
|
||||
}
|
||||
|
||||
func TestFailedRenameLeavesNoTemporaryFile(t *testing.T) {
|
||||
func TestFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
files := load(t, newParams(dir))
|
||||
path := filepath.Join(dir, bansJSON)
|
||||
params := newParams(dir)
|
||||
files := load(t, params)
|
||||
|
||||
// A directory named bans.json cannot be renamed over.
|
||||
err := os.Mkdir(filepath.Join(dir, bansJSON), 0o700)
|
||||
// bans.json is a socket, which cannot be opened as a file, even by
|
||||
// root, as the tests run in Docker, but which a rename could replace.
|
||||
// Whether it holds an edit cannot be told, so it is left as it is.
|
||||
socket, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = socket.Close()
|
||||
}()
|
||||
|
||||
err = files.WriteAll()
|
||||
if err == nil {
|
||||
t.Error("writing with bans.json unreadable did not fail")
|
||||
}
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || info.Mode().Type() != fs.ModeSocket {
|
||||
t.Errorf("bans.json is now %v (%v), want the socket", info, err)
|
||||
}
|
||||
|
||||
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
|
||||
wantWriteFailed(t, params, bansJSON)
|
||||
}
|
||||
|
||||
func TestBrokenEditThatCannotBeSetAsideIsNotWrittenOver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const broken = `{"version": 1, "bans": [`
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, bansJSON)
|
||||
params := newParams(dir)
|
||||
files := load(t, params)
|
||||
|
||||
// A directory named bans.json.bad cannot be renamed over, so the
|
||||
// broken edit cannot be set aside, and is left as it is.
|
||||
edit(t, dir, bansJSON, broken)
|
||||
|
||||
err := os.Mkdir(path+".bad", 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = files.WriteAll()
|
||||
if err == nil {
|
||||
t.Error("writing over a directory did not fail")
|
||||
t.Error("writing with bans.json.bad in the way did not fail")
|
||||
}
|
||||
|
||||
if got := readFile(t, path); got != broken {
|
||||
t.Errorf("bans.json holds\n%s\nwant the edit", got)
|
||||
}
|
||||
|
||||
wantWriteFailed(t, params, bansJSON)
|
||||
}
|
||||
|
||||
func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
fill(params)
|
||||
files := load(t, params)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
watch(t, files, lines)
|
||||
|
||||
// Each edit holds one entry, for a client the parts did not hold, and
|
||||
// takes the place of everything the part held.
|
||||
client := netip.MustParsePrefix("198.51.100.7/32")
|
||||
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": [{"netblock": "198.51.100.7/32", `+
|
||||
`"start": "2026-10-06T00:00:00Z", "expires": null}]}`)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
wantEqual(t, bansJSON, params.Ledger.Snapshot(),
|
||||
[]bans.Ban{{Netblock: client, Start: midnight()}})
|
||||
|
||||
edit(t, dir, clientsJSON, `{"version": 1, "clients": [`+
|
||||
`{"client": "198.51.100.7/32", "history": {"requests": 7}}]}`)
|
||||
wantTakenIn(t, lines, dir, clientsJSON)
|
||||
wantEqual(t, clientsJSON, params.Limiter.Snapshot(),
|
||||
[]ratelimit.Client{{Client: client, History: ratelimit.History{Requests: 7}}})
|
||||
|
||||
edit(t, dir, lookupsJSON, `{"version": 1, "lookups": [{"client": "198.51.100.7/32", `+
|
||||
`"country": "FR", "answered": "2026-10-06T00:00:00Z"}]}`)
|
||||
wantTakenIn(t, lines, dir, lookupsJSON)
|
||||
wantEqual(t, lookupsJSON, params.GeoJS.Snapshot(),
|
||||
[]lookup.Answer{{Client: client, Country: "FR", Answered: midnight()}})
|
||||
}
|
||||
|
||||
func TestOwnWritesAreNotTakenIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
fill(params)
|
||||
files := load(t, params)
|
||||
watch(t, files, lines)
|
||||
|
||||
// Every file is written while watched, and then lookups.json edited:
|
||||
// the first edit taken in is that one.
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
edit(t, dir, lookupsJSON, `{"version": 1, "lookups": []}`)
|
||||
wantTakenIn(t, lines, dir, lookupsJSON)
|
||||
}
|
||||
|
||||
func TestFileRenamedOverAStateFileTakenIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, bansJSON)
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
files := load(t, params)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
watch(t, files, lines)
|
||||
|
||||
// The admin mends bans.json.bad and moves it back, as editors that
|
||||
// save by renaming do with a file of their own: nothing is written
|
||||
// into bans.json itself. An edit of clients.json after it must be
|
||||
// taken in second.
|
||||
edit(t, dir, bansJSON+".bad", permanentBansJSON)
|
||||
|
||||
err = os.Rename(path+".bad", path)
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
|
||||
edit(t, dir, clientsJSON, `{"version": 1, "clients": []}`)
|
||||
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
wantEqual(t, bansJSON, params.Ledger.Snapshot(), []bans.Ban{permanentBan()})
|
||||
}
|
||||
|
||||
func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
watch(t, load(t, params), lines)
|
||||
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
|
||||
// An entry added, as an admin writes it, bans its netblock.
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": [{"netblock": "203.0.113.0/24", `+
|
||||
`"start": "2026-10-06T00:00:00Z", "expires": null}]}`)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
|
||||
_, banned := params.Ledger.Check(client, midnight())
|
||||
if !banned {
|
||||
t.Error("the ban added to bans.json does not refuse")
|
||||
}
|
||||
|
||||
// The entry removed lifts the ban.
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": []}`)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
|
||||
_, banned = params.Ledger.Check(client, midnight())
|
||||
if banned {
|
||||
t.Error("the ban removed from bans.json still refuses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// It ends a ban's entry with a comma.
|
||||
const broken = "{\n \"version\": 1,\n \"bans\": [\n" +
|
||||
" {\"netblock\": \"203.0.113.9/32\",}\n ]\n}\n"
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, bansJSON)
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
params.Ledger.Load([]bans.Ban{permanentBan()})
|
||||
files := load(t, params)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
watch(t, files, lines)
|
||||
|
||||
// While smallwebwaf runs, the broken edit is left as it is: an edit
|
||||
// of clients.json, made after it and taken in, shows that it has been
|
||||
// seen.
|
||||
edit(t, dir, bansJSON, broken)
|
||||
edit(t, dir, clientsJSON, `{"version": 1, "clients": []}`)
|
||||
wantTakenIn(t, lines, dir, clientsJSON)
|
||||
wantFiles(t, dir, 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.
|
||||
err = files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
line := lines.waitFor(t, "set aside an edit of a state file that does not parse")
|
||||
message, _ := line["error"].(string)
|
||||
|
||||
if line["file"] != path+".bad" ||
|
||||
!strings.HasPrefix(message, path+", line 4, column 39: ") {
|
||||
t.Errorf("set aside with %v", line)
|
||||
}
|
||||
|
||||
wantFiles(t, dir, 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)
|
||||
}
|
||||
|
||||
if got := readFile(t, path); got != permanentBansJSON {
|
||||
t.Errorf("bans.json holds\n%s\nwant\n%s", got, permanentBansJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
files := load(t, params)
|
||||
|
||||
// One edit is taken in by the write of its file, before Watch runs,
|
||||
// and one by Watch.
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": []}`)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
watch(t, files, lines)
|
||||
edit(t, dir, bansJSON, permanentBansJSON)
|
||||
wantTakenIn(t, lines, dir, bansJSON)
|
||||
|
||||
wantMetric(t, scrape(t, params),
|
||||
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json"}`, 2)
|
||||
}
|
||||
|
||||
func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
files := load(t, params)
|
||||
|
||||
edit(t, dir, bansJSON, `{"version": 1, "bans": [`)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
wantMetric(t, scrape(t, params),
|
||||
`smallwebwaf_state_file_edits_set_aside_total{file="bans.json"}`, 1)
|
||||
}
|
||||
|
||||
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
lines := logInto(¶ms)
|
||||
files := load(t, params)
|
||||
|
||||
err := os.Remove(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
|
||||
// Watch returns at once.
|
||||
files.Watch(t.Context())
|
||||
|
||||
line := lines.waitFor(t, "cannot watch the state files for edits")
|
||||
if line["level"] != "ERROR" {
|
||||
t.Errorf("logged as %v", line)
|
||||
}
|
||||
}
|
||||
|
||||
// midnight is the time of the tests' clock.
|
||||
@@ -573,15 +908,16 @@ func load(t *testing.T, params state.Params) *state.Files {
|
||||
return files
|
||||
}
|
||||
|
||||
// run runs files' writes until the test ends.
|
||||
func run(t *testing.T, files *state.Files) {
|
||||
// run runs task, the Run or the Watch of state files, until the test
|
||||
// ends.
|
||||
func run(t *testing.T, task func(context.Context)) {
|
||||
t.Helper()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
files.Run(ctx)
|
||||
task(ctx)
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
@@ -591,6 +927,79 @@ func run(t *testing.T, files *state.Files) {
|
||||
})
|
||||
}
|
||||
|
||||
// watch runs files' Watch until the test ends, and waits until it
|
||||
// watches the directory.
|
||||
func watch(t *testing.T, files *state.Files, lines processLog) {
|
||||
t.Helper()
|
||||
|
||||
run(t, files.Watch)
|
||||
lines.waitFor(t, watching)
|
||||
}
|
||||
|
||||
// processLog receives the lines of a process log, each a JSON object, for
|
||||
// a test to wait for.
|
||||
type processLog chan string
|
||||
|
||||
// logInto has params' process log write its lines into a new processLog,
|
||||
// and returns that.
|
||||
func logInto(params *state.Params) processLog {
|
||||
lines := make(processLog, maxLogLines)
|
||||
params.ProcessLog = slog.New(slog.NewJSONHandler(lines, nil))
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Write receives a line of the process log.
|
||||
func (l processLog) Write(line []byte) (int, error) {
|
||||
l <- string(line)
|
||||
|
||||
return len(line), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// that a slow test process cannot fail the test.
|
||||
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
for line := range l {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil {
|
||||
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
||||
}
|
||||
|
||||
if fields["msg"] == msg {
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
return nil // never reached: nothing closes the log
|
||||
}
|
||||
|
||||
// wantTakenIn waits for the next edit taken in, and checks that it is of
|
||||
// the state file name in dir.
|
||||
func wantTakenIn(t *testing.T, lines processLog, dir, name string) {
|
||||
t.Helper()
|
||||
|
||||
line := lines.waitFor(t, tookIn)
|
||||
if line["file"] != filepath.Join(dir, name) {
|
||||
t.Fatalf("took in %v, want an edit of %s", line, name)
|
||||
}
|
||||
}
|
||||
|
||||
// edit writes content to the state file name in dir, as an admin saves an
|
||||
// edit of it.
|
||||
func edit(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// wantEqual checks that the entries read back from file are those
|
||||
// written.
|
||||
func wantEqual[E comparable](t *testing.T, file string, got, want []E) {
|
||||
@@ -735,6 +1144,18 @@ func metric(t *testing.T, text, series string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// wantWriteFailed checks that the metrics of params count one write of the
|
||||
// state file name, and that it failed.
|
||||
func wantWriteFailed(t *testing.T, params state.Params, name string) {
|
||||
t.Helper()
|
||||
|
||||
got := scrape(t, params)
|
||||
file := `{file="` + name + `"}`
|
||||
|
||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1)
|
||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1)
|
||||
}
|
||||
|
||||
// wantMetric checks the value of series in text, the metrics, as metric
|
||||
// reads it.
|
||||
func wantMetric(t *testing.T, text, series string, want float64) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The test is on write itself: a state file is read before it is
|
||||
// written, and a directory in its place fails that read first.
|
||||
func TestFailedRenameLeavesNoTemporaryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
// A directory named bans.json cannot be renamed over.
|
||||
err := os.Mkdir(filepath.Join(dir, bansJSON), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = write(dir, bansJSON, []byte("{}\n"))
|
||||
if err == nil {
|
||||
t.Error("writing over a directory did not fail")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
|
||||
if len(entries) != 1 || entries[0].Name() != bansJSON {
|
||||
t.Errorf("%s holds %v, want only bans.json", dir, entries)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user