check / check (push) Successful in 2m59s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved edit of a state file in place of what it held. It tells its own writes from an admin's by the SHA-256 of what it last read or wrote; each write first takes in an edit made since. An edit that does not parse is renamed to <name>.bad at the file's next write. Every ban on a netblock is checked, and the next ban is worked out from the one that ended last. Two metrics count the edits taken in and set aside. README.md says how to add and lift a ban. Judgement call: a broken edit is set aside at the next write, since an editor's file can be read half written. Model: opus-5-5
413 lines
12 KiB
Go
413 lines
12 KiB
Go
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
|
// netblocks of clients that break a rate limit, with their notes, as the
|
|
// "Bans" section of SPEC.md describes. The bans are kept in memory, and
|
|
// written to bans.json and read from it by the state package.
|
|
package bans
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
|
)
|
|
|
|
// repeatFactor is how many times as long as the netblock's last ban a ban
|
|
// for a limit broken again within the repeat window lasts.
|
|
const repeatFactor = 3
|
|
|
|
// maxTextBytes is how much of each text in a ban's notes is kept.
|
|
const maxTextBytes = 256
|
|
|
|
// Rules are how long a ban for a broken limit lasts, and how many bans
|
|
// are held.
|
|
type Rules struct {
|
|
// LimitBanDuration is how long a first ban lasts.
|
|
LimitBanDuration time.Duration
|
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
|
// ban that ended last a broken limit counts as a repeat, which bans
|
|
// for repeatFactor times as long as that ban.
|
|
LimitBanRepeatWindow time.Duration
|
|
// MaxBanDuration is the longest ban; a ban that would be longer is
|
|
// permanent instead.
|
|
MaxBanDuration time.Duration
|
|
// MaxBans is the most bans held, at least one. Past it, the earliest
|
|
// ban of the netblock that has gone longest without a request is
|
|
// dropped.
|
|
MaxBans int
|
|
}
|
|
|
|
// Ban is a ban on a netblock for a broken limit, the only kind of ban
|
|
// smallwebwaf makes so far.
|
|
type Ban struct {
|
|
Netblock netip.Prefix
|
|
Start time.Time
|
|
// Expires is when the ban ends, zero for a permanent ban.
|
|
Expires time.Time
|
|
Notes Notes
|
|
}
|
|
|
|
// Permanent reports whether the ban never runs out.
|
|
func (b Ban) Permanent() bool {
|
|
return b.Expires.IsZero()
|
|
}
|
|
|
|
// ActiveAt reports whether the ban refuses requests at now.
|
|
func (b Ban) ActiveAt(now time.Time) bool {
|
|
return b.Permanent() || now.Before(b.Expires)
|
|
}
|
|
|
|
// Notes are what an admin needs to decide whether to lift a ban. The
|
|
// JSON names are those of bans.json.
|
|
//
|
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
|
type Notes struct {
|
|
// Country is the client's country, when it was looked up.
|
|
Country string `json:"country"`
|
|
// Limit, Window and Count are the limit that was broken, its window,
|
|
// "minute", "hour" or "day", and the count reached: the client's
|
|
// requests in the window, the one that broke the limit included.
|
|
// These are the requests that counted toward the ban, and the window
|
|
// is the time over which they came.
|
|
Limit int64 `json:"limit"`
|
|
Window string `json:"window"`
|
|
Count float64 `json:"count"`
|
|
// Request is the request that broke the limit.
|
|
Request Request `json:"request"`
|
|
// Requests is how many requests the netblock has sent since it was
|
|
// first seen, and Refused how many of them the ban has refused so
|
|
// far. Both go up with each request the ban refuses.
|
|
Requests int64 `json:"requests"`
|
|
Refused int64 `json:"refused"`
|
|
// EarlierBans is how many bans the netblock had before this one.
|
|
EarlierBans int `json:"earlier_bans"`
|
|
}
|
|
|
|
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
|
//
|
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
|
type Request struct {
|
|
Time time.Time `json:"time"`
|
|
Method string `json:"method"`
|
|
Host string `json:"host"`
|
|
// Path is the path with its query string.
|
|
Path string `json:"path"`
|
|
// Status is what the client was sent, 0 if nothing was.
|
|
Status int `json:"status"`
|
|
UserAgent string `json:"user_agent"`
|
|
}
|
|
|
|
// Ledger holds the bans. It is safe for concurrent use.
|
|
type Ledger struct {
|
|
rules Rules
|
|
// changed receives a value when a ban is made, unless one is waiting
|
|
// already.
|
|
changed chan struct{}
|
|
|
|
mu sync.Mutex
|
|
// netblocks holds each banned netblock's bans, oldest first. Check
|
|
// makes each netblock it finds the most recently seen.
|
|
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
|
// held is how many bans netblocks holds, at most rules.MaxBans.
|
|
held int
|
|
// made is how many bans BanForLimit has made since the start.
|
|
made int
|
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
|
// netblocks that have been banned. Check looks for a ban at each of
|
|
// them, so that a ban read from bans.json refuses every client in its
|
|
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
|
// or another length of an IPv6 client's netblock.
|
|
v4Lengths, v6Lengths []int
|
|
}
|
|
|
|
// New returns a Ledger with no ban yet.
|
|
func New(rules Rules) *Ledger {
|
|
// Every netblock held has a ban, so there are never more netblocks
|
|
// than rules.MaxBans, and the LRU never drops one itself.
|
|
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](rules.MaxBans, nil)
|
|
if err != nil {
|
|
panic(err) // NewLRU fails only for a size below one
|
|
}
|
|
|
|
return &Ledger{
|
|
rules: rules,
|
|
changed: make(chan struct{}, 1),
|
|
netblocks: netblocks,
|
|
}
|
|
}
|
|
|
|
// Changed receives a value after a ban is made, so that bans.json can be
|
|
// written. Several bans made before it is read leave one value.
|
|
func (l *Ledger) Changed() <-chan struct{} {
|
|
return l.changed
|
|
}
|
|
|
|
// Check is called for each request from client, at now. It reports
|
|
// whether a ban on a netblock client is in is active, and returns that
|
|
// ban, with the request counted among those it refused.
|
|
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
lengths := l.v6Lengths
|
|
if client.Is4() {
|
|
lengths = l.v4Lengths
|
|
}
|
|
|
|
for _, length := range lengths {
|
|
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
ban := activeBan(*bans, now)
|
|
if ban != nil {
|
|
ban.Notes.Requests++
|
|
ban.Notes.Refused++
|
|
|
|
return *ban, true
|
|
}
|
|
}
|
|
|
|
return Ban{}, false
|
|
}
|
|
|
|
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
|
// is active at now, or nil when none is. If several are, it returns the
|
|
// one that started last. Every ban is looked at, since a ban an admin adds
|
|
// to bans.json can start before the netblock's others and outlast them.
|
|
func activeBan(bans []Ban, now time.Time) *Ban {
|
|
for i := len(bans) - 1; i >= 0; i-- {
|
|
if bans[i].ActiveAt(now) {
|
|
return &bans[i]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
|
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
|
// LimitBanRepeatWindow after the netblock's ban that ended last lasts
|
|
// repeatFactor times as long as that one. A ban that would be longer
|
|
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
|
// active, as when two of its requests break a limit at once, that ban is
|
|
// returned and no other is made. The ledger fills in the notes' Refused
|
|
// and EarlierBans itself.
|
|
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var last *Ban
|
|
|
|
bans, found := l.netblocks.Get(netblock)
|
|
if found {
|
|
active := activeBan(*bans, now)
|
|
if active != nil {
|
|
return *active
|
|
}
|
|
|
|
// No ban is active, so each has an end. A ban an admin adds to
|
|
// bans.json can start after another and end before it, so the
|
|
// ban that ended last is looked for among them all.
|
|
ended := slices.MaxFunc(*bans, func(a, b Ban) int {
|
|
return a.Expires.Compare(b.Expires)
|
|
})
|
|
last = &ended
|
|
|
|
// The netblock's first ban held counts the bans it had before that
|
|
// one, since dropped to make room, and each ban held adds one.
|
|
notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans)
|
|
}
|
|
|
|
notes.Request = notes.Request.cut()
|
|
ban := Ban{
|
|
Netblock: netblock,
|
|
Start: now,
|
|
Expires: l.expiry(last, now),
|
|
Notes: notes,
|
|
}
|
|
l.add(ban)
|
|
l.made++
|
|
|
|
select {
|
|
case l.changed <- struct{}{}:
|
|
default: // a value is waiting already
|
|
}
|
|
|
|
return ban
|
|
}
|
|
|
|
// Bans returns the bans held on netblock, oldest first. It is not a
|
|
// request from netblock, and leaves when it was last seen unchanged.
|
|
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
bans, found := l.netblocks.Peek(netblock)
|
|
if !found {
|
|
return nil
|
|
}
|
|
|
|
return slices.Clone(*bans)
|
|
}
|
|
|
|
// Made returns how many bans the ledger has made since the start; bans
|
|
// read from bans.json are not among them.
|
|
func (l *Ledger) Made() int {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.made
|
|
}
|
|
|
|
// Count returns how many of the bans held are active at now, and how many
|
|
// are permanent.
|
|
func (l *Ledger) Count(now time.Time) (int, int) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
active, permanent := 0, 0
|
|
|
|
for _, bans := range l.netblocks.Values() {
|
|
for _, ban := range *bans {
|
|
if ban.ActiveAt(now) {
|
|
active++
|
|
}
|
|
|
|
if ban.Permanent() {
|
|
permanent++
|
|
}
|
|
}
|
|
}
|
|
|
|
return active, permanent
|
|
}
|
|
|
|
// Snapshot returns every ban held, sorted by netblock, and each
|
|
// netblock's bans oldest first, as bans.json lists them.
|
|
func (l *Ledger) Snapshot() []Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
held := make([]Ban, 0, l.held)
|
|
for _, bans := range l.netblocks.Values() {
|
|
held = append(held, *bans...)
|
|
}
|
|
|
|
slices.SortStableFunc(held, func(a, b Ban) int {
|
|
return a.Netblock.Compare(b.Netblock)
|
|
})
|
|
|
|
return held
|
|
}
|
|
|
|
// Load puts bans read from bans.json into the ledger, in place of the
|
|
// bans it holds, in the order they started, so that a netblock whose last
|
|
// ban started latest counts as the most recently seen. Each netblock is
|
|
// masked to its length, so that 203.0.113.9/24 is 203.0.113.0/24, and
|
|
// each text in the notes is cut to 256 bytes. Past MaxBans the earliest
|
|
// bans are dropped, as when they are made.
|
|
func (l *Ledger) Load(bans []Ban) {
|
|
bans = slices.Clone(bans)
|
|
slices.SortStableFunc(bans, func(a, b Ban) int {
|
|
return a.Start.Compare(b.Start)
|
|
})
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.netblocks.Purge()
|
|
l.held = 0
|
|
l.v4Lengths, l.v6Lengths = nil, nil
|
|
|
|
for _, ban := range bans {
|
|
ban.Netblock = ban.Netblock.Masked()
|
|
ban.Notes.Request = ban.Notes.Request.cut()
|
|
l.add(ban)
|
|
}
|
|
}
|
|
|
|
// add adds ban to its netblock's bans, after the last, and makes its
|
|
// netblock the most recently seen. With MaxBans held, it drops one first.
|
|
func (l *Ledger) add(ban Ban) {
|
|
if l.held == l.rules.MaxBans {
|
|
l.dropOne()
|
|
}
|
|
|
|
// dropOne can have dropped the netblock's last ban, and the netblock
|
|
// with it.
|
|
bans, found := l.netblocks.Get(ban.Netblock)
|
|
if !found {
|
|
bans = &[]Ban{}
|
|
l.netblocks.Add(ban.Netblock, bans)
|
|
}
|
|
|
|
*bans = append(*bans, ban)
|
|
l.held++
|
|
|
|
lengths := &l.v6Lengths
|
|
if ban.Netblock.Addr().Is4() {
|
|
lengths = &l.v4Lengths
|
|
}
|
|
|
|
if !slices.Contains(*lengths, ban.Netblock.Bits()) {
|
|
*lengths = append(*lengths, ban.Netblock.Bits())
|
|
}
|
|
}
|
|
|
|
// expiry returns when a ban for a broken limit made at now ends, or zero
|
|
// when it is permanent. last is the netblock's ban that ended last, or nil
|
|
// when it has none.
|
|
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
|
length := l.rules.LimitBanDuration
|
|
|
|
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
|
lastLength := last.Expires.Sub(last.Start)
|
|
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
|
// that it cannot overflow.
|
|
if lastLength > l.rules.MaxBanDuration/repeatFactor {
|
|
return time.Time{}
|
|
}
|
|
|
|
length = repeatFactor * lastLength
|
|
}
|
|
|
|
if length > l.rules.MaxBanDuration {
|
|
return time.Time{}
|
|
}
|
|
|
|
return now.Add(length)
|
|
}
|
|
|
|
// dropOne drops the earliest ban of the netblock that has gone longest
|
|
// without a request, and the netblock with it if that was its only ban.
|
|
func (l *Ledger) dropOne() {
|
|
netblock, bans, _ := l.netblocks.GetOldest()
|
|
if len(*bans) == 1 {
|
|
l.netblocks.Remove(netblock)
|
|
} else {
|
|
*bans = slices.Delete(*bans, 0, 1)
|
|
}
|
|
|
|
l.held--
|
|
}
|
|
|
|
// cut returns r with each text cut to maxTextBytes and copied, so that
|
|
// the notes do not keep the rest of the request in memory.
|
|
func (r Request) cut() Request {
|
|
r.Method = cutText(r.Method)
|
|
r.Host = cutText(r.Host)
|
|
r.Path = cutText(r.Path)
|
|
r.UserAgent = cutText(r.UserAgent)
|
|
|
|
return r
|
|
}
|
|
|
|
// cutText returns a copy of the first maxTextBytes of text.
|
|
func cutText(text string) string {
|
|
return strings.Clone(text[:min(len(text), maxTextBytes)])
|
|
}
|