check / check (push) Successful in 4m58s
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
359 lines
9.9 KiB
Go
359 lines
9.9 KiB
Go
// 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"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
|
)
|
|
|
|
// maxClients is how many clients are kept. Past it, the least recently
|
|
// seen client is dropped, with its history, and starts afresh if it comes
|
|
// back.
|
|
const maxClients = 20000
|
|
|
|
const day = 24 * time.Hour
|
|
|
|
// Limits are the most requests a client may make in a minute, an hour and
|
|
// a day. Zero is no limit.
|
|
type Limits struct {
|
|
PerMinute int64
|
|
PerHour int64
|
|
PerDay int64
|
|
}
|
|
|
|
// 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 *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, *Client](maxClients, nil)
|
|
if err != nil {
|
|
panic(err) // NewLRU fails only for a size below one
|
|
}
|
|
|
|
return &Limiter{
|
|
windows: [3]window{
|
|
{name: "minute", length: time.Minute, limit: limits.PerMinute},
|
|
{name: "hour", length: time.Hour, limit: limits.PerHour},
|
|
{name: "day", length: day, limit: limits.PerDay},
|
|
},
|
|
clients: clients,
|
|
}
|
|
}
|
|
|
|
// Hit is a request that takes a client over a rate limit.
|
|
type Hit struct {
|
|
// Window is "minute", "hour" or "day".
|
|
Window string
|
|
// Limit is the window's limit.
|
|
Limit int64
|
|
// Requests is the client's requests counted in the window, this one
|
|
// included.
|
|
Requests float64
|
|
}
|
|
|
|
// Count counts a request from client at now, in every window, whether or
|
|
// not it is refused. It reports whether the request takes the client over
|
|
// a limit, and the window whose limit it goes over, the shortest if it is
|
|
// over several.
|
|
func (l *Limiter) Count(client netip.Prefix, now time.Time) (Hit, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var hit Hit
|
|
|
|
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}
|
|
}
|
|
}
|
|
|
|
return hit, hit.Window != ""
|
|
}
|
|
|
|
// 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()
|
|
|
|
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
|
|
// most requests a client may make in it.
|
|
type window struct {
|
|
name string
|
|
length time.Duration
|
|
limit 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
|
|
// that bucket the window still covers.
|
|
//
|
|
// Concurrent requests can be counted out of order, so now can be a moment
|
|
// before the bucket under way began; such a request is counted in that
|
|
// 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{}
|
|
}
|
|
|
|
start := now.Truncate(length)
|
|
if start.After(b.Start) {
|
|
if start.Equal(b.Start.Add(length)) {
|
|
b.Previous = b.Current
|
|
} else {
|
|
b.Previous = 0
|
|
}
|
|
|
|
b.Start = start
|
|
b.Current = 0
|
|
}
|
|
|
|
b.Current++
|
|
|
|
elapsed := max(now.Sub(b.Start), 0)
|
|
covered := 1 - float64(elapsed)/float64(length)
|
|
|
|
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++
|
|
}
|
|
}
|