Every time in UTC, and every day as YYYY-MM-DD (closes #313)
check / check (push) Failing after 1m25s

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.
TestMainRunsWAF runs sww waf 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:
sneak committed 2026-11-11 01:46:04 +00:00
1 parent f7e3190654
commit 42b99007c8
11 files changed
+256 -22

No files matched your search

+7 -8
View File
@@ -14,6 +14,7 @@ import (
"sync"
"time"
"cloud.google.com/go/civil"
"github.com/hashicorp/golang-lru/v2/simplelru"
"sneak.berlin/go/smallwebwaf/internal/alerts"
)
@@ -27,8 +28,6 @@ const (
AbuseIPDBSource = "abuseipdb"
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
maxAnswerBytes = 64 << 10
// day is the length of the day the checks are counted in, in UTC.
day = 24 * time.Hour
)
var (
@@ -50,9 +49,9 @@ type Score struct {
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
// first, and the scores still in use.
type Checks struct {
Day time.Time `json:"day,omitzero"`
Spent int `json:"spent"`
Scores []Score `json:"scores"`
Day civil.Date `json:"day,omitzero"`
Spent int `json:"spent"`
Scores []Score `json:"scores"`
}
// AbuseIPDBParams are what NewAbuseIPDB needs.
@@ -93,7 +92,7 @@ type AbuseIPDB struct {
// checking are the clients whose check is under way.
checking map[netip.Prefix]bool
// day is the day, in UTC, of the checks spent counts.
day time.Time
day civil.Date
spent int
// checks and failures count the checks made and those that failed,
// and retryAt is when a client may be checked again after the last
@@ -143,7 +142,7 @@ func (a *AbuseIPDB) Hit(
return kept.Score, kept.Score >= a.params.MinScore
}
if today := now.Truncate(day); !a.day.Equal(today) {
if today := civil.DateOf(now.UTC()); a.day != today {
a.day, a.spent = today, 0
}
@@ -190,7 +189,7 @@ func (a *AbuseIPDB) BudgetLeft() int {
a.mu.Lock()
defer a.mu.Unlock()
if !a.day.Equal(a.params.Now().Truncate(day)) {
if a.day != civil.DateOf(a.params.Now().UTC()) {
return a.params.DailyBudget
}
+37
View File
@@ -202,6 +202,43 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
})
}
func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
// A clock 14 hours ahead of UTC, whose day starts at 10:00 UTC.
ahead := time.FixedZone("UTC+14", 14*60*60)
p := abuseIPDBParams()
p.DailyBudget = 1
p.Now = func() time.Time { return time.Now().In(ahead) }
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
checker := newAbuseIPDB(abuseIPDB, p)
// At 09:00 UTC the one check is spent.
time.Sleep(9 * time.Hour)
hitFrom(t, checker, suspect, true)
synctest.Wait()
wantBudgetLeft(t, checker, 0)
// At 11:00 UTC the clock's day has changed, but the UTC day has not.
time.Sleep(2 * time.Hour)
wantBudgetLeft(t, checker, 0)
encoded, err := json.Marshal(checker.Snapshot())
if err != nil {
t.Fatalf("encode: %v", err)
}
if !strings.Contains(string(encoded), `"day":"2000-01-01"`) {
t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded)
}
// At 00:00 UTC the budget is whole again.
time.Sleep(13 * time.Hour)
wantBudgetLeft(t, checker, 1)
})
}
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
t.Parallel()