// Package state keeps sww's state in JSON files, as the "Persistent // state" section of SPEC.md requires and docs/state-files.md lists them. // // The state files are in SWW_STATE_DIR: // - bans.json holds the bans; // - clients.json holds each client's counters and history; // - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today; // - alerts.json holds the cooldowns, the hour under way, the alerts // waiting for each destination and the anomaly counters. // // The cache files are in SWW_CACHE_DIR. Each keeps answers from an // outside service, which are fetched again when the file is lost: // - lookups.json holds GeoJS's answers; // - lists.json holds the last try and last good copy of each list // fetched from a URL; // - dnsbl.json holds the DNSBL zones' verdicts; // - abuseipdb-scores.json holds AbuseIPDB's scores; // - routewatch.json holds routewatch's answers. // // Load reads them at start, Watch takes in an admin's edit of one while // sww 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" "io/fs" "log/slog" "maps" "net/netip" "os" "path/filepath" "slices" "sync" "time" "cloud.google.com/go/civil" "github.com/fsnotify/fsnotify" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/routewatch" ) // version is the version of the files' format, the only one read. const version = 1 // fileMode lets the sww user alone read and write the files, which // hold visitors' addresses. const fileMode = 0o600 // The state files' names, in Dir. const ( bansJSON = "bans.json" clientsJSON = "clients.json" abuseIPDBChecksSpentJSON = "abuseipdb-checks-spent.json" alertsJSON = "alerts.json" ) // The cache files' names, in CacheDir. const ( lookupsJSON = "lookups.json" listsJSON = "lists.json" dnsblJSON = "dnsbl.json" abuseIPDBScoresJSON = "abuseipdb-scores.json" routeWatchJSON = "routewatch.json" ) // fileNames returns the names of the state files, then those of the // cache files, in the order they are written. func fileNames() []string { return []string{ bansJSON, clientsJSON, abuseIPDBChecksSpentJSON, alertsJSON, lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON, } } // isCacheFile reports whether name is the name of a cache file. func isCacheFile(name string) bool { switch name { case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON: return true default: return false } } var ( errVersion = errors.New("unknown version") // errMissing is for an entry without a field it needs. errMissing = errors.New("has no") errCause = errors.New("is not limit, attack, admin or crowdsec") errExpires = errors.New("is not after its start") errDestination = errors.New("is not webhook, slack or ntfy") errScope = errors.New("is not client, net, asn, total or watched_net") errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` + `destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` + `or remove the file`) ) // Params are what Load needs. type Params struct { // Dir is the directory of the state files (SWW_STATE_DIR), and // CacheDir that of the cache files (SWW_CACHE_DIR). Dir string CacheDir string // WriteDelay is how long after a ban is made bans.json is written // (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file // is (SWW_STATE_COUNTER_INTERVAL). WriteDelay time.Duration CounterInterval time.Duration // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and // Anomalies hold the state. RouteWatch is nil while SWW_ROUTEWATCH_URL is // unset, and routewatch.json then holds no answers. Alerts also receive a // file_error alert for an edit set aside, and for a write that fails while // sww runs. Ledger *bans.Ledger Limiter *ratelimit.Limiter GeoJS *lookup.GeoJS Lists *reputation.Lists DNSBL *reputation.DNSBL AbuseIPDB *reputation.AbuseIPDB RouteWatch *routewatch.Client Alerts *alerts.Queue Anomalies *anomaly.Counters // 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 taken in, the edits set aside, // and the writes that fail. ProcessLog *slog.Logger // Metrics count each file's writes, and the edits taken in and set // aside. Metrics *metrics.Metrics } // Files are the state and cache files of a running sww. 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 // sww 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. type bansFile struct { Version int `json:"version"` Bans []BanEntry `json:"bans"` } // BanEntry is a ban as bans.json holds it: a permanent ban's expires is // null, a ban an admin added may have no cause, which makes it an // admin's, and lifted is left out until an admin lifts the ban. The ban // endpoints answer with bans in this form too. type BanEntry struct { Netblock netip.Prefix `json:"netblock"` Start time.Time `json:"start"` Expires *time.Time `json:"expires"` Cause string `json:"cause"` Reason string `json:"reason,omitempty"` Lifted *time.Time `json:"lifted,omitempty"` Notes bans.Notes `json:"notes"` } // clientsFile is clients.json, with each client on a line of its own. type clientsFile struct { Version int `json:"version"` Clients []ratelimit.Client `json:"clients"` } // lookupsFile is lookups.json, with each answer on a line of its own. type lookupsFile struct { Version int `json:"version"` Lookups []lookup.Answer `json:"lookups"` } // abuseIPDBChecksSpentFile is abuseipdb-checks-spent.json, indented for an // admin to read and edit. type abuseIPDBChecksSpentFile struct { Version int `json:"version"` Day civil.Date `json:"day,omitzero"` Spent int `json:"spent"` } // listsFile is lists.json, indented for an admin to read and edit, so // that each line of a list's copy is on a line of its own. type listsFile struct { Version int `json:"version"` Lists []reputation.List `json:"lists"` } // dnsblFile is dnsbl.json, with each verdict on a line of its own. type dnsblFile struct { Version int `json:"version"` Verdicts []reputation.Verdict `json:"verdicts"` } // abuseIPDBScoresFile is abuseipdb-scores.json, with each score on a line // of its own. type abuseIPDBScoresFile struct { Version int `json:"version"` Scores []reputation.Score `json:"scores"` } // routeWatchFile is routewatch.json, with each answer on a line of its own. type routeWatchFile struct { Version int `json:"version"` Answers []routewatch.Answer `json:"answers"` } // alertsFile is alerts.json, indented for an admin to read and edit. // //nolint:tagliatelle // the state files use snake_case, as the request log does type alertsFile struct { Version int `json:"version"` Cooldowns []alerts.Cooldown `json:"cooldowns"` Hour alerts.Hour `json:"hour"` Waiting map[string][]alerts.Alert `json:"waiting"` AnomalyCounters []anomaly.Counter `json:"anomaly_counters"` } // 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 // file, for a field that may be null or "" but not left out, which the // struct cannot tell apart. Its toUTC then converts every time in it to // UTC, since an admin may write a time with an offset. type stateFile interface { check(data []byte) error toUTC() } // Load checks that files can be written in Dir and CacheDir, and reads the // files in them into the parts of Params that hold the state. A missing // file is empty state, as on a first start. A state file that does not // parse, has an unknown version, or has an entry without a field it // needs, is an error that names the file and, where the JSON decoder // tells it, the line and column, or else the entry. A cache file is never // an error, as readCache describes. func Load(params Params) (*Files, error) { err := checkWritable(params.Dir) if err != nil { return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err) } err = checkWritable(params.CacheDir) if err != nil { return nil, fmt.Errorf("SWW_CACHE_DIR cannot be written: %w", err) } f := &Files{params: params, sums: map[string][sha256.Size]byte{}} bansRead, bansErr := f.read(bansJSON) clientsRead, clientsErr := f.read(clientsJSON) _, checksErr := f.read(abuseIPDBChecksSpentJSON) alertsRead, alertsErr := f.read(alertsJSON) err = errors.Join(bansErr, clientsErr, checksErr, alertsErr) if err != nil { return nil, err } lookupsRead := f.readCache(lookupsJSON) listsRead := f.readCache(listsJSON) f.readCache(dnsblJSON) f.readCache(abuseIPDBScoresJSON) f.readCache(routeWatchJSON) params.ProcessLog.Info("read the state files", "directory", params.Dir, "cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead, "lookups", lookupsRead, "lists", listsRead, "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, 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() var bansDue <-chan time.Time // nil while no ban waits to be written for { select { case <-ctx.Done(): return case <-f.params.Ledger.Changed(): if bansDue == nil { bansDue = time.After(f.params.WriteDelay) } case <-bansDue: bansDue = nil f.logFailure(bansJSON, f.writeFile(bansJSON)) case <-interval.C: for _, name := range fileNames() { f.logFailure(name, f.writeFile(name)) } } } } // WriteAll writes every state and cache file, as sww stops. A file that // fails does not keep the others from being written. func (f *Files) WriteAll() error { names := fileNames() errs := make([]error, 0, len(names)) for _, name := range names { errs = append(errs, f.writeFile(name)) } return errors.Join(errs...) } // Watch watches Dir and CacheDir until ctx is done, and takes in an // admin's edit of a file as soon as it is saved: what the file holds // replaces what sww 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. A directory that cannot be watched // is logged, and the other is still watched. An edit in a directory not // watched is taken in only before its file is written. func (f *Files) Watch(ctx context.Context) { watcher, err := fsnotify.NewWatcher() if err != nil { f.params.ProcessLog.Error("cannot watch the state files for edits", "error", err.Error()) return } defer func() { _ = watcher.Close() }() var watched []string for _, dir := range []struct{ path, failed string }{ {f.params.Dir, "cannot watch the state files for edits"}, {f.params.CacheDir, "cannot watch the cache files for edits"}, } { err = watcher.Add(dir.path) if err != nil { f.params.ProcessLog.Error(dir.failed, "directory", dir.path, "error", err.Error()) continue } watched = append(watched, dir.path) } if len(watched) == 0 { return } f.params.ProcessLog.Info("watching the state files for edits", "directories", watched) for { select { case <-ctx.Done(): return case event := <-watcher.Events: name := filepath.Base(event.Name) if slices.Contains(fileNames(), name) && filepath.Join(f.dir(name), name) == event.Name { f.fileChanged(name) } case err = <-watcher.Errors: f.params.ProcessLog.Warn("watching the state files failed", "error", err.Error()) } } } // logFailure logs a write of the file name that failed, and raises // a file_error alert for it. func (f *Files) logFailure(name string, err error) { if err != nil { failed := "writing the state files failed" if isCacheFile(name) { failed = "writing the cache 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.dir(name), name), "error": err.Error(), }, }) f.params.ProcessLog.Error(failed, "error", err.Error()) } } // fileChanged takes in what the file name holds, as Watch sees it // change, if that is an edit made since sww 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) { f.mu.Lock() defer f.mu.Unlock() data, changed, err := f.readChanged(name) if err != nil || !changed { return } _ = f.takeInEdit(name, data) } // takeInEdit takes in data, an edit of the file name, as takeIn // does, and counts and logs it. Every edit taken in while sww // 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, true) if err != nil { return err } // 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.dir(name), name)) return nil } // dir returns the directory of the file name: CacheDir for a cache file, // and Dir for a state file. func (f *Files) dir(name string) string { if isCacheFile(name) { return f.params.CacheDir } return f.params.Dir } // read takes in the 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.takeIn(name, data, false) } // readCache takes in the cache file name at start, as read does. A cache // file that cannot be read, or does not parse, does not stop the start: // it is logged, and its cache starts empty and fills again. One that does // not parse is set aside as an edit is, and one that cannot be read is // written over at its next write. func (f *Files) readCache(name string) int { data, changed, err := f.readChanged(name) if err != nil || !changed { f.logUnread(name, err) return 0 } entries, err := f.takeIn(name, data, false) if err != nil { f.logUnread(name, f.setAside(name, err)) } return entries } // logUnread logs err, the reason the cache file name was not read at // start, unless it is nil. func (f *Files) logUnread(name string, err error) { if err != nil { f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty", "file", filepath.Join(f.dir(name), name), "error", err.Error()) } } // readChanged returns what the file name holds, and whether that has // changed since sww last read or wrote the file, as it has for a file // sww 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.dir(name), name) data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } if err != nil { return nil, false, err } return data, sha256.Sum256(data) != f.sums[name], nil } // takeIn parses data, what the 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. edit is whether data is an admin's // edit taken in while sww runs, rather than the file read at the // start. An error names the file and, where the JSON decoder tells it, // the line and column, or else the entry. abuseipdb-checks-spent.json // holds no entries. func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { path := filepath.Join(f.dir(name), name) var ( entries int err error ) if isCacheFile(name) { entries, err = f.takeInCache(name, path, data) } else { entries, err = f.takeInState(name, path, data, edit) } if err != nil { return 0, err } f.sums[name] = sha256.Sum256(data) return entries, nil } // takeInState parses data, what the state file name, at path, holds, and // puts it in, as takeIn describes. func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) { 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()) } if edit { f.params.Ledger.LoadEdit(held) } else { 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 abuseIPDBChecksSpentJSON: var file abuseIPDBChecksSpentFile err := parse(path, data, &file) if err != nil { return 0, err } f.params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{ Day: file.Day, Spent: file.Spent, }) case alertsJSON: waiting, err := f.takeInAlerts(path, data) if err != nil { return 0, err } entries = waiting } return entries, nil } // takeInCache parses data, what the cache file name, at path, holds, and // puts it in, as takeIn describes. func (f *Files) takeInCache(name, path string, data []byte) (int, error) { switch name { case lookupsJSON: var file lookupsFile err := parse(path, data, &file) if err != nil { return 0, err } f.params.GeoJS.Load(file.Lookups) return len(file.Lookups), nil case listsJSON: var file listsFile err := parse(path, data, &file) if err != nil { return 0, err } err = f.params.Lists.Load(file.Lists) if err != nil { return 0, fmt.Errorf("%s: %w", path, err) } return len(file.Lists), nil case dnsblJSON: var file dnsblFile err := parse(path, data, &file) if err != nil { return 0, err } f.params.DNSBL.Load(file.Verdicts) return len(file.Verdicts), nil case routeWatchJSON: var file routeWatchFile err := parse(path, data, &file) if err != nil { return 0, err } if f.params.RouteWatch != nil { f.params.RouteWatch.Load(file.Answers) } return len(file.Answers), nil default: // abuseipdb-scores.json var file abuseIPDBScoresFile err := parse(path, data, &file) if err != nil { return 0, err } f.params.AbuseIPDB.Load(file.Scores) return len(file.Scores), nil } } // takeInAlerts parses data, what alerts.json, at path, holds, puts it // into the alerts and the anomaly counters, in place of what they held, // and returns how many alerts wait in it, as takeIn describes. func (f *Files) takeInAlerts(path string, data []byte) (int, error) { // waiting was a list, of the alerts waiting for the webhook, before // alerts went to Slack and ntfy too. var written struct { Waiting json.RawMessage `json:"waiting"` } if json.Unmarshal(data, &written) == nil && bytes.HasPrefix(written.Waiting, []byte("[")) { return 0, fmt.Errorf("%s: %w", path, errWaitingList) } 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, }) f.params.Anomalies.Load(file.AnomalyCounters, f.params.Now()) entries := 0 for _, waiting := range file.Waiting { entries += len(waiting) } return entries, nil } // writeFile writes the file name from what sww holds. An edit made since // sww 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 state file that // cannot be read, or an edit that cannot be set aside, is left as it is, // and the write given up. A cache file that cannot be read is written // over, since what it held is fetched again. 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 && isCacheFile(name) { err = nil } if err == nil && changed { err = f.takeInEdit(name, data) if err != nil { 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.dir(name), 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.dir(name)) } f.params.Metrics.StateFileWritten(name, len(data), err) return err } // setAside renames the 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, 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.dir(name), name) err := os.Rename(path, path+".bad") if err != nil { return errors.Join(parseErr, err) } setAside := "set aside an edit of a state file that does not parse" if isCacheFile(name) { setAside = "set aside an edit of a cache 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 } // encode returns the file name as sww writes it, from a // snapshot of the part that keeps that state. func (f *Files) encode(name string) ([]byte, error) { switch name { case bansJSON: return encodeIndented(bansFile{ Version: version, Bans: BanEntries(f.params.Ledger.Snapshot()), }) case clientsJSON: return encodeOnePerLine("clients", f.params.Limiter.Snapshot()) case lookupsJSON: return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot()) case listsJSON: return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()}) case dnsblJSON: return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot()) case abuseIPDBScoresJSON: return encodeOnePerLine("scores", f.params.AbuseIPDB.Snapshot()) case routeWatchJSON: var answers []routewatch.Answer if f.params.RouteWatch != nil { answers = f.params.RouteWatch.Snapshot() } return encodeOnePerLine("answers", answers) case abuseIPDBChecksSpentJSON: checks := f.params.AbuseIPDB.ChecksSpent() return encodeIndented(abuseIPDBChecksSpentFile{ Version: version, Day: checks.Day, Spent: checks.Spent, }) default: // alerts.json held := f.params.Alerts.Snapshot() return encodeIndented(alertsFile{ Version: version, Cooldowns: held.Cooldowns, Hour: held.Hour, Waiting: held.Waiting, AnomalyCounters: f.params.Anomalies.Snapshot(), }) } } // encodeIndented encodes file, a state file's struct, indented for an // admin to read and edit. func encodeIndented(file any) ([]byte, error) { data, err := json.MarshalIndent(file, "", " ") if err != nil { return nil, err } return append(data, '\n'), nil } // BanEntries returns held as bans.json lists them, an empty list for // none. func BanEntries(held []bans.Ban) []BanEntry { entries := make([]BanEntry, 0, len(held)) for _, ban := range held { entries = append(entries, newBanEntry(ban)) } return entries } // newBanEntry returns ban as bans.json holds it. func newBanEntry(ban bans.Ban) BanEntry { entry := BanEntry{ Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Reason: ban.Reason, Notes: ban.Notes, } if !ban.Permanent() { entry.Expires = &ban.Expires } if !ban.Lifted.IsZero() { entry.Lifted = &ban.Lifted } return entry } // ban returns the ban an entry of bans.json holds. func (e BanEntry) ban() bans.Ban { ban := bans.Ban{ Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Reason: e.Reason, Notes: e.Notes, } if e.Expires != nil { ban.Expires = *e.Expires } if e.Lifted != nil { ban.Lifted = *e.Lifted } return ban } // check refuses a ban without a netblock, which would refuse every IPv6 // client, a start, from which the length of the netblock's next ban is // worked out, or an expires, which would make it permanent. A permanent // ban's expires is null, which Bans cannot tell from a missing one, so // each expires is read again as written. An expires not after the start // is refused too, since the netblock's next ban for a broken limit, three // times as long as this one, would then refuse nothing; and so is a cause // other than limit, attack, admin or crowdsec, most likely misspelt. func (f *bansFile) check(data []byte) error { var written struct { Bans []struct { Expires json.RawMessage `json:"expires"` } `json:"bans"` } err := json.Unmarshal(data, &written) if err != nil { return err } for i, entry := range f.Bans { switch { case !entry.Netblock.IsValid(): return missing(i, "netblock") case entry.Start.IsZero(): return missing(i, "start") case written.Bans[i].Expires == nil: return missing(i, "expires") case entry.Expires != nil && !entry.Expires.After(entry.Start): return fmt.Errorf("entry %d's expires %w", i+1, errExpires) case entry.Cause != "" && entry.Cause != bans.CauseLimit && entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin && entry.Cause != bans.CauseCrowdSec: return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause) } } return nil } // check refuses a client without its address, which would count nobody's // requests, or with requests, bytes or refusals in a window but no start, // which would drop them and give the client a fresh allowance. func (f *clientsFile) check([]byte) error { for i, client := range f.Clients { switch { case !client.Client.IsValid(): return missing(i, "client") case countsWithoutStart(client.Minute): return missing(i, "minute.start") case countsWithoutStart(client.Hour): return missing(i, "hour.start") case countsWithoutStart(client.Day): return missing(i, "day.start") case countsWithoutStart(client.MinuteBytes): return missing(i, "minute_bytes.start") case countsWithoutStart(client.HourBytes): return missing(i, "hour_bytes.start") case countsWithoutStart(client.DayBytes): return missing(i, "day_bytes.start") case countsWithoutStart(client.MinuteRefusals): return missing(i, "minute_refusals.start") } } return nil } // check refuses an answer without a client, which would answer for // nobody, a country, which would place the client nowhere, or the time // GeoJS gave it, which would drop it. "" is the country of a client // GeoJS cannot place, which Lookups cannot tell from a missing one, so // each country is read again as written. func (f *lookupsFile) check(data []byte) error { var written struct { Lookups []struct { Country *string `json:"country"` } `json:"lookups"` } err := json.Unmarshal(data, &written) if err != nil { return err } for i, answer := range f.Lookups { switch { case !answer.Client.IsValid(): return missing(i, "client") case written.Lookups[i].Country == nil: return missing(i, "country") case answer.Answered.IsZero(): return missing(i, "answered") } } return nil } // check refuses a list without its URL, which would name no list, or the // time it was last tried, which would have it fetched at once, and a copy // of it without the time it was fetched, or without its lines, which hold // the list. func (f *listsFile) check([]byte) error { for i, kept := range f.Lists { switch { case kept.URL == "": return missing(i, "url") case kept.Tried.IsZero(): return missing(i, "tried") case kept.Fetched.IsZero() && kept.Lines != nil: return missing(i, "fetched") case kept.Lines == nil && !kept.Fetched.IsZero(): return missing(i, "lines") } } return nil } // check refuses a verdict without its zone or its client, which would be // about no one, whether the zone lists the client, or the time it was // fetched, which would drop it. A verdict's listed is false for a client // the zone does not list, which Verdict cannot tell from a missing one, // so each listed is read again as written. func (f *dnsblFile) check(data []byte) error { var written struct { Verdicts []struct { Listed *bool `json:"listed"` } `json:"verdicts"` } err := json.Unmarshal(data, &written) if err != nil { return err } for i, verdict := range f.Verdicts { switch { case verdict.Zone == "": return missing(i, "zone") case !verdict.Client.IsValid(): return missing(i, "client") case written.Verdicts[i].Listed == nil: return missing(i, "listed") case verdict.Fetched.IsZero(): return missing(i, "fetched") } } return nil } // check refuses an AbuseIPDB score without its client, the score, or the // time it was fetched. A score can be 0, which Score cannot tell from a // missing one, so each score is read again as written. func (f *abuseIPDBScoresFile) check(data []byte) error { var written struct { Scores []struct { Score *int64 `json:"score"` } `json:"scores"` } err := json.Unmarshal(data, &written) if err != nil { return err } for i, kept := range f.Scores { switch { case !kept.Client.IsValid(): return missing(i, "client") case written.Scores[i].Score == nil: return missing(i, "score") case kept.Fetched.IsZero(): return missing(i, "fetched") } } return nil } // check refuses an answer without its client, which would answer for // nobody, its netblock, or the time routewatch gave it, which would drop // it. "" is the netblock of an address without a live route, which Answer // cannot tell from a missing one, so each netblock is read again as // written. func (f *routeWatchFile) check(data []byte) error { var written struct { Answers []struct { Netblock *string `json:"netblock"` } `json:"answers"` } err := json.Unmarshal(data, &written) if err != nil { return err } for i, answer := range f.Answers { switch { case !answer.Client.IsValid(): return missing(i, "client") case written.Answers[i].Netblock == nil: return missing(i, "netblock") case answer.Answered.IsZero(): return missing(i, "answered") } } return nil } // check refuses nothing, since a missing day or count means no check // spent. func (f *abuseIPDBChecksSpentFile) check([]byte) error { return nil } // check refuses a cooldown without its event or when its alert was sent, // which would hold back no repeat, alerts waiting for a destination with // another name than webhook, slack or ntfy, most likely misspelt, an // alert waiting without its event or its time, and an anomaly counter as // checkAnomalyCounters does. 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 _, destination := range slices.Sorted(maps.Keys(f.Waiting)) { if !slices.Contains(alerts.Destinations(), destination) { return fmt.Errorf("waiting %q %w", destination, errDestination) } for i, alert := range f.Waiting[destination] { switch { case alert.Event == "": return fmt.Errorf("waiting %s %w", destination, missing(i, "event")) case alert.Time.IsZero(): return fmt.Errorf("waiting %s %w", destination, missing(i, "time")) } } } return checkAnomalyCounters(f.AnomalyCounters) } // checkAnomalyCounters refuses an anomaly counter whose scope is not // client, net, asn, total or watched_net, most likely misspelt, and one // without a field it needs, as missingFromCounter tells. func checkAnomalyCounters(counters []anomaly.Counter) error { for i, counter := range counters { if !slices.Contains(anomaly.Scopes(), counter.Scope) { return fmt.Errorf("anomaly_counters entry %d's scope %q %w", i+1, counter.Scope, errScope) } field := missingFromCounter(counter) if field != "" { return fmt.Errorf("anomaly_counters %w", missing(i, field)) } } return nil } // missingFromCounter returns the first field counter, an anomaly counter, // needs and has not, or "" when it has them all: what tells it from the // others in its scope, without which it would never be counted again, the // netblock of a client, net or watched_net counter, the AS number of an // asn one and the name of a watched_net one; and the start of a window in // which it has requests or bytes, without which they would be dropped. func missingFromCounter(counter anomaly.Counter) string { scope := counter.Scope switch { case scope != anomaly.ScopeASN && scope != anomaly.ScopeTotal && !counter.Netblock.IsValid(): return "netblock" case scope == anomaly.ScopeASN && counter.ASN == "": return "asn" case scope == anomaly.ScopeWatchedNet && counter.Name == "": return "name" case countsWithoutStart(counter.Minute): return "minute.start" case countsWithoutStart(counter.Hour): return "hour.start" case countsWithoutStart(counter.MinuteBytes): return "minute_bytes.start" case countsWithoutStart(counter.HourBytes): return "hour_bytes.start" default: return "" } } // countsWithoutStart reports whether b holds requests, bytes or refusals // but no start, which places them in time. func countsWithoutStart(b ratelimit.Buckets) bool { return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0) } // missing returns the error for entry i, counted from 0, of a state file, // which has no field. func missing(i int, field string) error { return fmt.Errorf("entry %d %w %q", i+1, errMissing, field) } // toUTC converts every time of each ban to UTC. func (f *bansFile) toUTC() { for i := range f.Bans { ban := &f.Bans[i] convertToUTC(&ban.Start, ban.Expires, ban.Lifted, &ban.Notes.Request.Time) } } // toUTC converts every time of each client to UTC. func (f *clientsFile) toUTC() { for i := range f.Clients { client := &f.Clients[i] convertToUTC(&client.Minute.Start, &client.Hour.Start, &client.Day.Start, &client.MinuteBytes.Start, &client.HourBytes.Start, &client.DayBytes.Start, &client.MinuteRefusals.Start, &client.History.FirstSeen, &client.History.LastSeen, &client.History.LookedUp) } } // toUTC converts both times of each answer to UTC. func (f *lookupsFile) toUTC() { for i := range f.Lookups { convertToUTC(&f.Lookups[i].Answered, &f.Lookups[i].Used) } } // toUTC converts both times of each list to UTC. func (f *listsFile) toUTC() { for i := range f.Lists { convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched) } } // toUTC converts the time of each verdict to UTC. func (f *dnsblFile) toUTC() { for i := range f.Verdicts { convertToUTC(&f.Verdicts[i].Fetched) } } // toUTC converts the time of each AbuseIPDB score to UTC. func (f *abuseIPDBScoresFile) toUTC() { for i := range f.Scores { convertToUTC(&f.Scores[i].Fetched) } } // toUTC converts the time of each answer to UTC. func (f *routeWatchFile) toUTC() { for i := range f.Answers { convertToUTC(&f.Answers[i].Answered) } } // toUTC does nothing: abuseipdb-checks-spent.json holds a day, not a time. func (f *abuseIPDBChecksSpentFile) toUTC() {} // toUTC converts every time of each cooldown, of the hour, of each alert // waiting and of each anomaly counter to UTC. func (f *alertsFile) toUTC() { for i := range f.Cooldowns { convertToUTC(&f.Cooldowns[i].Sent) } convertToUTC(&f.Hour.Start) for _, waiting := range f.Waiting { for i := range waiting { convertToUTC(&waiting[i].Time) } } for i := range f.AnomalyCounters { counter := &f.AnomalyCounters[i] convertToUTC(&counter.Minute.Start, &counter.Hour.Start, &counter.MinuteBytes.Start, &counter.HourBytes.Start) } } // convertToUTC converts each of times that is not nil to UTC. func convertToUTC(times ...*time.Time) { for _, at := range times { if at != nil { *at = at.UTC() } } } // encodeOnePerLine encodes a state file whose entries, under key, are one // to a line, so that grep shows everything about one client. func encodeOnePerLine[E any](key string, entries []E) ([]byte, error) { var b bytes.Buffer fmt.Fprintf(&b, "{\n \"version\": %d,\n %q: [", version, key) for i, entry := range entries { line, err := json.Marshal(entry) if err != nil { return nil, err } if i > 0 { b.WriteString(",") } b.WriteString("\n ") b.Write(line) } b.WriteString("\n ]\n}\n") return b.Bytes(), nil } // checkWritable makes a file in dir and removes it again. func checkWritable(dir string) error { file, err := os.CreateTemp(dir, "write-check-*") if err != nil { return err } return errors.Join(file.Close(), os.Remove(file.Name())) } // parse reads data, what the state file at path holds, into file, a // pointer to that file's struct, checks its entries, and converts every // time in it to UTC. 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) if err == nil && header.Version != version { err = fmt.Errorf("%w %d, where this sww reads version %d", errVersion, header.Version, version) } if err == nil { decoder := json.NewDecoder(bytes.NewReader(data)) // A field this version does not know is most likely misspelt, and // its value would be lost without a word. decoder.DisallowUnknownFields() err = decoder.Decode(file) } if err == nil { err = file.check(data) } if err != nil { return fmt.Errorf("%s%s: %w", path, position(data, err), err) } file.toUTC() return nil } // position returns where in data err was found, as ", line L, column C" // of the last byte the JSON decoder read, or "" when err does not tell. func position(data []byte, err error) string { var ( syntaxErr *json.SyntaxError typeErr *json.UnmarshalTypeError read int64 ) switch { case errors.As(err, &syntaxErr): read = syntaxErr.Offset case errors.As(err, &typeErr): read = typeErr.Offset default: return "" } before := data[:max(min(read, int64(len(data)))-1, 0)] line := bytes.Count(before, []byte("\n")) + 1 column := len(before) - bytes.LastIndexByte(before, '\n') return fmt.Sprintf(", line %d, column %d", line, column) } // 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. func write(dir, name string, data []byte) error { path := filepath.Join(dir, name) temporary := path + ".tmp" err := writeSynced(temporary, data) if err == nil { err = os.Rename(temporary, path) } if err != nil { _ = os.Remove(temporary) } 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 // SWW_STATE_DIR or SWW_CACHE_DIR itself if err != nil { return err } return errors.Join(directory.Sync(), directory.Close()) } // writeSynced writes data to the file at path, and syncs it to the disk. func writeSynced(path string, data []byte) error { //nolint:gosec // a file's temporary file, in SWW_STATE_DIR or SWW_CACHE_DIR file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { return err } _, err = file.Write(data) if err == nil { err = file.Sync() } return errors.Join(err, file.Close()) }