check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
1447 lines
41 KiB
Go
1447 lines
41 KiB
Go
// 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_WAF_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_WAF_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_WAF_STATE_DIR), and
|
|
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
|
|
Dir string
|
|
CacheDir string
|
|
// WriteDelay is how long after a ban is made bans.json is written
|
|
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
|
// is (SWW_WAF_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_WAF_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_WAF_STATE_DIR cannot be written: %w", err)
|
|
}
|
|
|
|
err = checkWritable(params.CacheDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SWW_WAF_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 and cache 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 and cache 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)
|
|
}
|
|
|
|
watching := "watching the state and cache files for edits"
|
|
|
|
switch {
|
|
case len(watched) == 0:
|
|
return
|
|
case len(watched) == 1 && watched[0] == f.params.Dir:
|
|
watching = "watching the state files for edits"
|
|
case len(watched) == 1:
|
|
watching = "watching the cache files for edits"
|
|
}
|
|
|
|
f.params.ProcessLog.Info(watching, "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 and cache 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
|
|
}
|
|
|
|
tookIn := "took in an edit of a state file"
|
|
if isCacheFile(name) {
|
|
tookIn = "took in an edit of a cache file"
|
|
}
|
|
|
|
// 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(tookIn, "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 {
|
|
//nolint:gosec // SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR itself
|
|
directory, err := os.Open(dir)
|
|
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_WAF_STATE_DIR or SWW_WAF_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())
|
|
}
|