Keep the bans, the clients and GeoJS's answers in state files (closes #17)
check / check (push) Successful in 3m24s
check / check (push) Successful in 3m24s
smallwebwaf now copies its state to bans.json, clients.json and lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md describes, and reads them back at start, so a restart lifts no ban and gives no client a fresh allowance. Each client gains a history, and a ban's notes count the netblock's requests. bans.json is written SWWAF_STATE_WRITE_DELAY after a ban, and every file every SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked to its netblock and refuses every client in it. A file that does not parse, an unknown version, an entry without a field it needs, or an unwritable directory stops the start. Deviation: no AS number or name, and no ban cause, reason or lifting yet. Model: opus-5-5
This commit was merged in pull request #72.
This commit is contained in:
+249
-42
@@ -1,11 +1,15 @@
|
||||
// Package ratelimit counts each client's requests over a minute, an hour
|
||||
// and a day, as the "Counting method" section of SPEC.md describes, and
|
||||
// tells when a request takes a client over a rate limit. The counts are
|
||||
// kept in memory only, for at most 20,000 clients.
|
||||
// Package ratelimit keeps the table of clients: each client's requests
|
||||
// counted over a minute, an hour and a day, as the "Counting method"
|
||||
// section of SPEC.md describes, which tell when a request takes the client
|
||||
// over a rate limit, and each client's history since it was first seen.
|
||||
// At most 20,000 clients are kept, in memory, and written to clients.json
|
||||
// and read from it by the state package.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +17,8 @@ import (
|
||||
)
|
||||
|
||||
// maxClients is how many clients are kept. Past it, the least recently
|
||||
// seen client is dropped, and starts afresh if it comes back.
|
||||
// seen client is dropped, with its history, and starts afresh if it comes
|
||||
// back.
|
||||
const maxClients = 20000
|
||||
|
||||
const day = 24 * time.Hour
|
||||
@@ -26,20 +31,94 @@ type Limits struct {
|
||||
PerDay int64
|
||||
}
|
||||
|
||||
// Limiter counts each client's requests against the limits. It is safe
|
||||
// for concurrent use.
|
||||
// Limiter counts each client's requests against the limits, and keeps
|
||||
// its history. It is safe for concurrent use.
|
||||
type Limiter struct {
|
||||
// windows are the minute, the hour and the day, in the order of
|
||||
// Client.buckets.
|
||||
windows [3]window
|
||||
|
||||
mu sync.Mutex
|
||||
// clients holds each client's buckets, one pair for each of windows,
|
||||
// in the same order.
|
||||
clients *simplelru.LRU[netip.Prefix, *[3]buckets]
|
||||
mu sync.Mutex
|
||||
clients *simplelru.LRU[netip.Prefix, *Client]
|
||||
}
|
||||
|
||||
// Client is a client in the table, as clients.json holds it: its buckets
|
||||
// in each window, and its history.
|
||||
type Client struct {
|
||||
Client netip.Prefix `json:"client"`
|
||||
Minute Buckets `json:"minute"`
|
||||
Hour Buckets `json:"hour"`
|
||||
Day Buckets `json:"day"`
|
||||
History History `json:"history"`
|
||||
}
|
||||
|
||||
// Buckets are a client's two buckets in one window: the requests in the
|
||||
// bucket under way, which began at Start, and in the bucket before it.
|
||||
type Buckets struct {
|
||||
Start time.Time `json:"start"`
|
||||
Current int64 `json:"current"`
|
||||
Previous int64 `json:"previous"`
|
||||
}
|
||||
|
||||
// History is what is known of a client since it was first seen.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type History struct {
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// Country is the client's country as it was last looked up, and
|
||||
// LookedUp when that was; both are empty while it never was.
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those passed to
|
||||
// the app, Refused those refused before anything reached it.
|
||||
Requests int64 `json:"requests"`
|
||||
Forwarded int64 `json:"forwarded"`
|
||||
Refused int64 `json:"refused"`
|
||||
// RequestBytes and ResponseBytes are the body bytes of its requests
|
||||
// and of the responses it was sent.
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
Responses Responses `json:"responses,omitzero"`
|
||||
Offences Offences `json:"offences,omitzero"`
|
||||
}
|
||||
|
||||
// Responses are the responses a client was sent, by status class;
|
||||
// Status5xx counts every status from 500 up.
|
||||
type Responses struct {
|
||||
Status1xx int64 `json:"1xx,omitempty"`
|
||||
Status2xx int64 `json:"2xx,omitempty"`
|
||||
Status3xx int64 `json:"3xx,omitempty"`
|
||||
Status4xx int64 `json:"4xx,omitempty"`
|
||||
Status5xx int64 `json:"5xx,omitempty"`
|
||||
}
|
||||
|
||||
// Offences are a client's offences, by kind.
|
||||
type Offences struct {
|
||||
// Limit is its requests that broke a rate limit.
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Country is the client's country, when the request looked it up.
|
||||
Country string
|
||||
// Forwarded is true for a request passed to the app, false for one
|
||||
// refused before anything reached it.
|
||||
Forwarded bool
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
Status int
|
||||
// RequestBytes and ResponseBytes are the body bytes of the request
|
||||
// and of its response.
|
||||
RequestBytes int64
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit.
|
||||
BrokeLimit bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet.
|
||||
func New(limits Limits) *Limiter {
|
||||
clients, err := simplelru.NewLRU[netip.Prefix, *[3]buckets](maxClients, nil)
|
||||
clients, err := simplelru.NewLRU[netip.Prefix, *Client](maxClients, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
@@ -73,16 +152,12 @@ func (l *Limiter) Count(client netip.Prefix, now time.Time) (Hit, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
counts, seen := l.clients.Get(client)
|
||||
if !seen {
|
||||
counts = &[3]buckets{}
|
||||
l.clients.Add(client, counts)
|
||||
}
|
||||
|
||||
var hit Hit
|
||||
|
||||
for i, w := range l.windows {
|
||||
requests := counts[i].add(now, w.length)
|
||||
for i, b := range l.get(client).buckets() {
|
||||
w := l.windows[i]
|
||||
|
||||
requests := b.add(now, w.length)
|
||||
if hit.Window == "" && w.limit > 0 && requests > float64(w.limit) {
|
||||
hit = Hit{Window: w.name, Limit: w.limit, Requests: requests}
|
||||
}
|
||||
@@ -91,12 +166,135 @@ func (l *Limiter) Count(client netip.Prefix, now time.Time) (Hit, bool) {
|
||||
return hit, hit.Window != ""
|
||||
}
|
||||
|
||||
// Reset sets client's counts in every window back to zero.
|
||||
// Reset sets client's counts in every window back to zero. Its history
|
||||
// keeps its totals.
|
||||
func (l *Limiter) Reset(client netip.Prefix) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.clients.Remove(client)
|
||||
c, seen := l.clients.Peek(client)
|
||||
if seen {
|
||||
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
||||
}
|
||||
}
|
||||
|
||||
// AddToHistory adds r, a request from client at now, to the client's
|
||||
// history.
|
||||
func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
h := &l.get(client).History
|
||||
if h.FirstSeen.IsZero() {
|
||||
h.FirstSeen = now
|
||||
}
|
||||
|
||||
h.LastSeen = now
|
||||
|
||||
if r.Country != "" {
|
||||
h.Country = r.Country
|
||||
h.LookedUp = now
|
||||
}
|
||||
|
||||
h.Requests++
|
||||
if r.Forwarded {
|
||||
h.Forwarded++
|
||||
} else {
|
||||
h.Refused++
|
||||
}
|
||||
|
||||
h.RequestBytes += r.RequestBytes
|
||||
h.ResponseBytes += r.ResponseBytes
|
||||
h.Responses.add(r.Status)
|
||||
|
||||
if r.BrokeLimit {
|
||||
h.Offences.Limit++
|
||||
}
|
||||
}
|
||||
|
||||
// Requests returns how many requests the clients inside netblock have
|
||||
// sent, as their histories count them.
|
||||
func (l *Limiter) Requests(netblock netip.Prefix) int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Most often the netblock is one client.
|
||||
c, seen := l.clients.Peek(netblock)
|
||||
if seen {
|
||||
return c.History.Requests
|
||||
}
|
||||
|
||||
var requests int64
|
||||
|
||||
for _, c := range l.clients.Values() {
|
||||
if netblock.Overlaps(c.Client) {
|
||||
requests += c.History.Requests
|
||||
}
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// Snapshot returns every client in the table, sorted by address, as
|
||||
// clients.json lists them.
|
||||
func (l *Limiter) Snapshot() []Client {
|
||||
l.mu.Lock()
|
||||
|
||||
clients := make([]Client, 0, l.clients.Len())
|
||||
for _, c := range l.clients.Values() {
|
||||
clients = append(clients, *c)
|
||||
}
|
||||
|
||||
l.mu.Unlock()
|
||||
|
||||
slices.SortFunc(clients, func(a, b Client) int {
|
||||
return a.Client.Compare(b.Client)
|
||||
})
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// Load puts clients read from clients.json into a table that holds none
|
||||
// yet, in the order they were last seen, so that the least recently seen
|
||||
// is dropped first. Buckets whose time has passed at now are emptied.
|
||||
func (l *Limiter) Load(clients []Client, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
clients = slices.Clone(clients)
|
||||
slices.SortStableFunc(clients, func(a, b Client) int {
|
||||
return a.History.LastSeen.Compare(b.History.LastSeen)
|
||||
})
|
||||
|
||||
for _, c := range clients {
|
||||
for i, b := range c.buckets() {
|
||||
// The window that ends at now covers neither bucket once it
|
||||
// begins after the bucket under way has ended.
|
||||
length := l.windows[i].length
|
||||
if !now.Add(-length).Before(b.Start.Add(length)) {
|
||||
*b = Buckets{}
|
||||
}
|
||||
}
|
||||
|
||||
l.clients.Add(c.Client, &c)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns client's entry in the table, a new one if it has none, and
|
||||
// makes it the most recently seen.
|
||||
func (l *Limiter) get(client netip.Prefix) *Client {
|
||||
c, seen := l.clients.Get(client)
|
||||
if !seen {
|
||||
c = &Client{Client: client}
|
||||
l.clients.Add(client, c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// buckets returns c's buckets in the minute, the hour and the day.
|
||||
func (c *Client) buckets() [3]*Buckets {
|
||||
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
||||
}
|
||||
|
||||
// window is a length of time over which requests are counted, and the
|
||||
@@ -107,14 +305,6 @@ type window struct {
|
||||
limit int64
|
||||
}
|
||||
|
||||
// buckets are a client's two buckets in one window: the requests in the
|
||||
// bucket under way, which began at start, and in the bucket before it.
|
||||
type buckets struct {
|
||||
start time.Time
|
||||
current int64
|
||||
previous int64
|
||||
}
|
||||
|
||||
// add counts a request at now in a window of length, and returns the
|
||||
// client's requests in the window that ends at now: those in the bucket
|
||||
// under way, and those in the bucket before it weighted by how much of
|
||||
@@ -125,27 +315,44 @@ type buckets struct {
|
||||
// bucket. A request dated more than a second before it means the clock
|
||||
// was set back, and the buckets start afresh: otherwise the bucket before
|
||||
// would keep its full weight until the clock caught up.
|
||||
func (b *buckets) add(now time.Time, length time.Duration) float64 {
|
||||
if now.Before(b.start.Add(-time.Second)) {
|
||||
*b = buckets{}
|
||||
func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
||||
if now.Before(b.Start.Add(-time.Second)) {
|
||||
*b = Buckets{}
|
||||
}
|
||||
|
||||
start := now.Truncate(length)
|
||||
if start.After(b.start) {
|
||||
if start.Equal(b.start.Add(length)) {
|
||||
b.previous = b.current
|
||||
if start.After(b.Start) {
|
||||
if start.Equal(b.Start.Add(length)) {
|
||||
b.Previous = b.Current
|
||||
} else {
|
||||
b.previous = 0
|
||||
b.Previous = 0
|
||||
}
|
||||
|
||||
b.start = start
|
||||
b.current = 0
|
||||
b.Start = start
|
||||
b.Current = 0
|
||||
}
|
||||
|
||||
b.current++
|
||||
b.Current++
|
||||
|
||||
elapsed := max(now.Sub(b.start), 0)
|
||||
elapsed := max(now.Sub(b.Start), 0)
|
||||
covered := 1 - float64(elapsed)/float64(length)
|
||||
|
||||
return float64(b.previous)*covered + float64(b.current)
|
||||
return float64(b.Previous)*covered + float64(b.Current)
|
||||
}
|
||||
|
||||
// add counts a response with status in its class. A status of 0, for
|
||||
// nothing sent, is not a response.
|
||||
func (r *Responses) add(status int) {
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
r.Status5xx++
|
||||
case status >= http.StatusBadRequest:
|
||||
r.Status4xx++
|
||||
case status >= http.StatusMultipleChoices:
|
||||
r.Status3xx++
|
||||
case status >= http.StatusOK:
|
||||
r.Status2xx++
|
||||
case status >= http.StatusContinue:
|
||||
r.Status1xx++
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user