Every time in UTC, and every day as YYYY-MM-DD (closes #313)
check / check (push) Waiting to run
check / check (push) Waiting to run
AbuseIPDB's daily count keeps its day as a civil.Date, taken from the UTC clock and written to reputation.json as "YYYY-MM-DD"; it was a time.Time at midnight. Every time read from a state file is converted to UTC, so a time an admin writes with an offset is written back in UTC. TestMainRunsDiscriminator runs sww discriminator with TZ 14 hours ahead of UTC and checks that the process line, the request line and clients.json are in UTC. SPEC.md states the rule near its start; AGENTS.md makes it an iron rule. Model: opus-5-5
This commit is contained in:
1 parent
ad36dd1775
commit
2bdecf2f34
11 files changed
+256
-22
No files matched your search
+81
-2
@@ -171,9 +171,11 @@ type alertsFile struct {
|
||||
// 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.
|
||||
// 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 reads the state files
|
||||
@@ -918,6 +920,80 @@ 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 every time of each list, verdict and AbuseIPDB score to
|
||||
// UTC.
|
||||
func (f *reputationFile) toUTC() {
|
||||
for i := range f.Lists {
|
||||
convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched)
|
||||
}
|
||||
|
||||
for i := range f.Verdicts {
|
||||
convertToUTC(&f.Verdicts[i].Fetched)
|
||||
}
|
||||
|
||||
for i := range f.AbuseIPDB.Scores {
|
||||
convertToUTC(&f.AbuseIPDB.Scores[i].Fetched)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -955,7 +1031,8 @@ func checkWritable(dir string) error {
|
||||
}
|
||||
|
||||
// parse reads data, what the state file at path holds, into file, a
|
||||
// pointer to that file's struct, and checks its entries.
|
||||
// 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.
|
||||
@@ -985,6 +1062,8 @@ func parse(path string, data []byte, file stateFile) error {
|
||||
return fmt.Errorf("%s%s: %w", path, position(data, err), err)
|
||||
}
|
||||
|
||||
file.toUTC()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/civil"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
@@ -258,7 +260,7 @@ const filledReputationJSON = `{
|
||||
}
|
||||
],
|
||||
"abuseipdb": {
|
||||
"day": "2026-10-06T00:00:00Z",
|
||||
"day": "2026-10-06",
|
||||
"spent": 3,
|
||||
"scores": [
|
||||
{
|
||||
@@ -1306,7 +1308,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
||||
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
|
||||
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+
|
||||
`"abuseipdb": {"day": "2026-10-06T00:00:00Z", "spent": 9, "scores": [`+
|
||||
`"abuseipdb": {"day": "2026-10-06", "spent": 9, "scores": [`+
|
||||
`{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`)
|
||||
wantTakenIn(t, lines, dir, reputationJSON)
|
||||
|
||||
@@ -1321,7 +1323,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
}})
|
||||
|
||||
checks := reputation.Checks{
|
||||
Day: midnight(), Spent: 9,
|
||||
Day: civil.DateOf(midnight()), Spent: 9,
|
||||
Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}},
|
||||
}
|
||||
if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) {
|
||||
@@ -1350,6 +1352,62 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimesEditedWithAnOffsetWrittenBackInUTC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
fill(params)
|
||||
// A lifted ban, so that bans.json holds every time a ban has.
|
||||
params.Ledger.Lift(netip.MustParseAddr("192.0.2.1"), midnight())
|
||||
|
||||
files := load(t, params)
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// An admin saves each file with every time in it two hours ahead of
|
||||
// UTC, midnight as "2026-10-06T02:00:00+02:00".
|
||||
stamp := regexp.MustCompile(`"\d{4}-\d\d-\d\dT[^"]*Z"`)
|
||||
ahead := time.FixedZone("UTC+2", 2*60*60)
|
||||
written := map[string]string{}
|
||||
|
||||
for _, name := range []string{
|
||||
bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON,
|
||||
} {
|
||||
written[name] = readFile(t, filepath.Join(dir, name))
|
||||
edited := stamp.ReplaceAllStringFunc(written[name], func(quoted string) string {
|
||||
at, err := time.Parse(time.RFC3339Nano, strings.Trim(quoted, `"`))
|
||||
if err != nil {
|
||||
t.Fatalf("%s holds %s: %v", name, quoted, err)
|
||||
}
|
||||
|
||||
return `"` + at.In(ahead).Format(time.RFC3339Nano) + `"`
|
||||
})
|
||||
|
||||
if !strings.Contains(edited, "+02:00") {
|
||||
t.Fatalf("%s holds no time:\n%s", name, edited)
|
||||
}
|
||||
|
||||
edit(t, dir, name, edited)
|
||||
}
|
||||
|
||||
// Each write takes its edit in first, and writes the same times back in
|
||||
// UTC.
|
||||
err = files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
for name, want := range written {
|
||||
if got := readFile(t, filepath.Join(dir, name)); got != want {
|
||||
t.Errorf("%s written back as\n%s\nwant\n%s", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnWritesAreNotTakenIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1837,10 +1895,12 @@ func fill(params state.Params) {
|
||||
},
|
||||
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
|
||||
})
|
||||
params.AbuseIPDB.Load(reputation.Checks{Day: now, Spent: 3, Scores: []reputation.Score{
|
||||
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
|
||||
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
|
||||
}})
|
||||
params.AbuseIPDB.Load(reputation.Checks{
|
||||
Day: civil.DateOf(now), Spent: 3, Scores: []reputation.Score{
|
||||
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
|
||||
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
|
||||
},
|
||||
})
|
||||
|
||||
// An alert waiting, a repeat of it the cooldown holds back, another
|
||||
// alert waiting, and one past the two an hour, for the hour's summary.
|
||||
|
||||
Reference in new issue
Block a user