check / check (push) Waiting to run
SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST /_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET /_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong token gets 401, in observe mode too. They go through every check, as the metrics do. POST takes a netblock, not IPv4-mapped and without a zone, or a client's address, a duration or permanent, and a reason, and makes an admin ban even while another lasts. DELETE lifts every active ban covering the address, kept and marked lifted. Bans come back as bans.json entries; a client as clients.json holds it, with its bans. Judgement call: answers leave out bans.json's version field. Judgement call: DELETE takes an address, not a netblock. Rule suppressed: gosec G304 on a test reading bans.json. Model: opus-5-5
728 lines
21 KiB
Go
728 lines
21 KiB
Go
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
|
// netblocks of clients that break a rate limit or show a clear sign of
|
|
// attack, and those an admin makes, 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 (
|
|
"fmt"
|
|
"math"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
|
)
|
|
|
|
// The causes of bans.
|
|
const (
|
|
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
|
CauseLimit = "limit"
|
|
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
|
CauseAttack = "attack"
|
|
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
|
// admin keeps. It is never dropped.
|
|
CauseAdmin = "admin"
|
|
)
|
|
|
|
// 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 lasts, and how many bans are held.
|
|
type Rules struct {
|
|
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
|
LimitBanDuration time.Duration
|
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
|
// ban that ended last, other than one for a clear sign of attack, 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 for a broken limit; one that would
|
|
// be longer is permanent instead.
|
|
MaxBanDuration time.Duration
|
|
// AttackBanDuration is how long a first ban for a clear sign of attack
|
|
// lasts.
|
|
AttackBanDuration time.Duration
|
|
// MaxBans is the most bans held whose cause is not CauseAdmin, at
|
|
// least one. Past it, the earliest such ban of the netblock that has
|
|
// gone longest without a request is dropped. Bans whose cause is
|
|
// CauseAdmin are held besides, and never dropped.
|
|
MaxBans int
|
|
}
|
|
|
|
// Ban is a ban on a netblock.
|
|
type Ban struct {
|
|
Netblock netip.Prefix
|
|
Start time.Time
|
|
// Expires is when the ban ends, zero for a permanent ban.
|
|
Expires time.Time
|
|
// Cause is CauseLimit, CauseAttack or CauseAdmin.
|
|
Cause string
|
|
// Reason is a short text: for a ban smallwebwaf made, the limit broken
|
|
// or the rule that matched; for an admin's, what the admin wrote.
|
|
Reason string
|
|
// Lifted is when an admin lifted the ban, zero while no admin has. A
|
|
// lifted ban refuses nothing, and does not make the netblock's next
|
|
// ban longer.
|
|
Lifted 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: it has not
|
|
// been lifted, and has not run out.
|
|
func (b Ban) ActiveAt(now time.Time) bool {
|
|
return b.Lifted.IsZero() && (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, for a ban for a broken limit, 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,omitempty"`
|
|
Window string `json:"window,omitempty"`
|
|
Count float64 `json:"count,omitempty"`
|
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
|
// of the rule file rule that matched, and its target.
|
|
RuleID string `json:"rule_id,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
// Request is the request that broke the limit, or that was the clear
|
|
// sign of attack.
|
|
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, by
|
|
// cause.
|
|
EarlierBans EarlierBans `json:"earlier_bans"`
|
|
}
|
|
|
|
// EarlierBans counts a netblock's bans before a ban, by cause.
|
|
type EarlierBans struct {
|
|
Limit int `json:"limit"`
|
|
Attack int `json:"attack"`
|
|
Admin int `json:"admin"`
|
|
}
|
|
|
|
// 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 and
|
|
// Find make each netblock they find the most recently seen.
|
|
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
|
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
|
// at most rules.MaxBans.
|
|
held int
|
|
// made is how many bans have been made since the start, by cause: by
|
|
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
|
// bans.json.
|
|
made map[string]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 {
|
|
// The ledger drops bans itself, and never those whose cause is
|
|
// CauseAdmin, however many there are, so the LRU has no limit of its
|
|
// own: it keeps the netblocks in the order they were last seen.
|
|
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, 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,
|
|
made: map[string]int{},
|
|
}
|
|
}
|
|
|
|
// Changed receives a value after a ban is made, lifted or made permanent,
|
|
// so that bans.json can be written. Several changes before it is read
|
|
// leave one value.
|
|
func (l *Ledger) Changed() <-chan struct{} {
|
|
return l.changed
|
|
}
|
|
|
|
// Check is called for a 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. A ban for a clear sign of
|
|
// attack is made permanent by the request: the netblock is malicious.
|
|
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
ban := l.active(client, now)
|
|
if ban == nil {
|
|
return Ban{}, false
|
|
}
|
|
|
|
ban.Notes.Requests++
|
|
ban.Notes.Refused++
|
|
|
|
if ban.Cause == CauseAttack && !ban.Permanent() {
|
|
ban.Expires = time.Time{}
|
|
|
|
l.markChanged()
|
|
}
|
|
|
|
return *ban, true
|
|
}
|
|
|
|
// Find is Check without counting the request among those the ban
|
|
// refused: in observe mode a ban refuses nothing.
|
|
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
ban := l.active(client, now)
|
|
if ban == nil {
|
|
return Ban{}, false
|
|
}
|
|
|
|
return *ban, true
|
|
}
|
|
|
|
// 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, other
|
|
// than one for a clear sign of attack or a lifted one, 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, and gives the ban the reason "requests per
|
|
// <Window> over the limit of <Limit>", from the notes.
|
|
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
|
reason := fmt.Sprintf("requests per %s over the limit of %d",
|
|
notes.Window, notes.Limit)
|
|
|
|
return l.ban(netblock, now, CauseLimit, reason, notes)
|
|
}
|
|
|
|
// BanForAttack bans netblock at now for a clear sign of attack, with
|
|
// notes, and returns the ban, as BanForLimit does. A first ban lasts
|
|
// AttackBanDuration; once the netblock has had one that was not lifted,
|
|
// the next is permanent. Its reason is "matched the rule <RuleID>".
|
|
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
|
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
|
|
}
|
|
|
|
// BanForAdmin bans netblock at now for an admin, with reason, until
|
|
// expires, or for good when expires is zero, and returns the ban, whose
|
|
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
|
// ban even while another on netblock is active, since the admin asked
|
|
// for this one. The ledger fills in the notes' EarlierBans, and counts
|
|
// the ban among those made.
|
|
func (l *Ledger) BanForAdmin(
|
|
netblock netip.Prefix, now, expires time.Time, reason string,
|
|
) Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
ban := Ban{
|
|
Netblock: netblock.Masked(), Start: now, Expires: expires, Cause: CauseAdmin,
|
|
Reason: reason,
|
|
}
|
|
|
|
held, found := l.netblocks.Get(ban.Netblock)
|
|
if found {
|
|
ban.Notes.EarlierBans = earlierBans(*held)
|
|
}
|
|
|
|
l.add(ban)
|
|
l.made[CauseAdmin]++
|
|
l.markChanged()
|
|
|
|
return ban
|
|
}
|
|
|
|
// Lift lifts, at now, every ban active then on a netblock client is in,
|
|
// as an admin does, and returns those bans. A lifted ban is kept, refuses
|
|
// nothing, and does not make the netblock's next ban longer.
|
|
func (l *Ledger) Lift(client netip.Addr, now time.Time) []Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var lifted []Ban
|
|
|
|
for _, bans := range l.covering(client) {
|
|
for i := range *bans {
|
|
ban := &(*bans)[i]
|
|
if ban.ActiveAt(now) {
|
|
ban.Lifted = now
|
|
lifted = append(lifted, *ban)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(lifted) > 0 {
|
|
l.markChanged()
|
|
}
|
|
|
|
return lifted
|
|
}
|
|
|
|
// Covering returns every ban held on a netblock client is in, active or
|
|
// not, sorted by netblock, and each netblock's bans oldest first. It is
|
|
// not a request from client, and leaves when the netblocks were last seen
|
|
// unchanged.
|
|
func (l *Ledger) Covering(client netip.Addr) []Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var held []Ban
|
|
for _, bans := range l.covering(client) {
|
|
held = append(held, *bans...)
|
|
}
|
|
|
|
slices.SortStableFunc(held, func(a, b Ban) int {
|
|
return a.Netblock.Compare(b.Netblock)
|
|
})
|
|
|
|
return held
|
|
}
|
|
|
|
// 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 for cause have been made since the start:
|
|
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
|
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
|
// them. The bans read from bans.json at the start are not among them.
|
|
func (l *Ledger) Made(cause string) int {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.made[cause]
|
|
}
|
|
|
|
// Count returns how many of the bans held are active at now, and how many
|
|
// of those are permanent. A lifted ban is neither.
|
|
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) {
|
|
continue
|
|
}
|
|
|
|
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 at the start 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. A ban without a cause is an admin's, and gets CauseAdmin. 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 whose cause is not CauseAdmin are dropped, as
|
|
// when they are made.
|
|
func (l *Ledger) Load(bans []Ban) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.load(bans)
|
|
}
|
|
|
|
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
|
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
|
// the ledger did not hold, with the same netblock and start, is one the
|
|
// admin made, and is counted among the bans made.
|
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.made[CauseAdmin] += l.load(bans)
|
|
}
|
|
|
|
// load does what Load describes, and returns how many of bans are bans
|
|
// whose cause is CauseAdmin that the ledger did not hold before.
|
|
func (l *Ledger) load(bans []Ban) int {
|
|
bans = slices.Clone(bans)
|
|
added := 0
|
|
|
|
for i := range bans {
|
|
ban := &bans[i]
|
|
ban.Netblock = ban.Netblock.Masked()
|
|
ban.Notes.Request = ban.Notes.Request.cut()
|
|
|
|
if ban.Cause == "" {
|
|
ban.Cause = CauseAdmin
|
|
}
|
|
|
|
if ban.Cause == CauseAdmin && !l.holds(ban.Netblock, ban.Start) {
|
|
added++
|
|
}
|
|
}
|
|
|
|
slices.SortStableFunc(bans, func(a, b Ban) int {
|
|
return a.Start.Compare(b.Start)
|
|
})
|
|
|
|
l.netblocks.Purge()
|
|
l.held = 0
|
|
l.v4Lengths, l.v6Lengths = nil, nil
|
|
|
|
for _, ban := range bans {
|
|
l.add(ban)
|
|
}
|
|
|
|
return added
|
|
}
|
|
|
|
// holds reports whether the ledger holds a ban on netblock that started
|
|
// at start.
|
|
func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
|
bans, found := l.netblocks.Peek(netblock)
|
|
|
|
return found && slices.ContainsFunc(*bans, func(ban Ban) bool {
|
|
return ban.Start.Equal(start)
|
|
})
|
|
}
|
|
|
|
// ban bans netblock at now for cause, with reason and notes, as
|
|
// BanForLimit and BanForAttack describe, and returns the ban.
|
|
func (l *Ledger) ban(
|
|
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes,
|
|
) Ban {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// held are the netblock's bans, none of them active.
|
|
var held []Ban
|
|
|
|
bans, found := l.netblocks.Get(netblock)
|
|
if found {
|
|
active := activeBan(*bans, now)
|
|
if active != nil {
|
|
return *active
|
|
}
|
|
|
|
held = *bans
|
|
notes.EarlierBans = earlierBans(held)
|
|
}
|
|
|
|
notes.Request = notes.Request.cut()
|
|
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
|
|
|
if cause == CauseAttack {
|
|
ban.Expires = l.attackExpiry(held, now)
|
|
} else {
|
|
ban.Expires = l.limitExpiry(held, now)
|
|
}
|
|
|
|
l.add(ban)
|
|
l.made[cause]++
|
|
l.markChanged()
|
|
|
|
return ban
|
|
}
|
|
|
|
// earlierBans returns how many bans a netblock with the bans held, oldest
|
|
// first, has had, by cause: the first ban held counts the bans the
|
|
// netblock had before that one, since dropped to make room, and each ban
|
|
// held adds one.
|
|
func earlierBans(held []Ban) EarlierBans {
|
|
earlier := held[0].Notes.EarlierBans
|
|
|
|
for _, ban := range held {
|
|
switch ban.Cause {
|
|
case CauseLimit:
|
|
earlier.Limit++
|
|
case CauseAttack:
|
|
earlier.Attack++
|
|
case CauseAdmin:
|
|
earlier.Admin++
|
|
}
|
|
}
|
|
|
|
return earlier
|
|
}
|
|
|
|
// markChanged has Changed receive a value, unless one is waiting already.
|
|
func (l *Ledger) markChanged() {
|
|
select {
|
|
case l.changed <- struct{}{}:
|
|
default: // a value is waiting already
|
|
}
|
|
}
|
|
|
|
// active returns the ban active at now on a netblock client is in, or
|
|
// nil.
|
|
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
|
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 {
|
|
return ban
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// covering returns the bans of each netblock held that client is in,
|
|
// leaving when the netblocks were last seen unchanged.
|
|
func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
|
lengths := l.v6Lengths
|
|
if client.Is4() {
|
|
lengths = l.v4Lengths
|
|
}
|
|
|
|
var found []*[]Ban
|
|
|
|
for _, length := range lengths {
|
|
bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked())
|
|
if ok {
|
|
found = append(found, bans)
|
|
}
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
// 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,
|
|
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
|
func (l *Ledger) add(ban Ban) {
|
|
counted := ban.Cause != CauseAdmin
|
|
if counted && 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)
|
|
|
|
if counted {
|
|
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())
|
|
}
|
|
}
|
|
|
|
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
|
// zero when it is permanent. held are the netblock's bans, none of them
|
|
// active, of which the one that ended last, other than a ban for a clear
|
|
// sign of attack or a lifted one, can make the new ban longer. A ban an
|
|
// admin adds to bans.json can start after another and end before it, so
|
|
// that one is looked for among them all.
|
|
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
|
length := l.rules.LimitBanDuration
|
|
|
|
var last *Ban
|
|
|
|
for i, ban := range held {
|
|
if ban.Cause != CauseAttack && ban.Lifted.IsZero() &&
|
|
(last == nil || ban.Expires.After(last.Expires)) {
|
|
last = &held[i]
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// attackExpiry returns when a ban for a clear sign of attack made at now
|
|
// ends. held are the netblock's bans, none of them active: if one of them
|
|
// is for a clear sign of attack too, and was not lifted, the new ban is
|
|
// permanent, and its end zero; otherwise it ends AttackBanDuration later.
|
|
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
|
for _, ban := range held {
|
|
if ban.Cause == CauseAttack && ban.Lifted.IsZero() {
|
|
return time.Time{}
|
|
}
|
|
}
|
|
|
|
return now.Add(l.rules.AttackBanDuration)
|
|
}
|
|
|
|
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
|
// netblock that has gone longest without a request, of those that hold
|
|
// such a ban, and the netblock with it if that was its only ban. It is
|
|
// called with at least one such ban held. It looks at each netblock once
|
|
// at most, and drops nothing when none holds such a ban.
|
|
func (l *Ledger) dropOne() {
|
|
for range l.netblocks.Len() {
|
|
netblock, bans, _ := l.netblocks.GetOldest()
|
|
|
|
i := slices.IndexFunc(*bans, func(ban Ban) bool {
|
|
return ban.Cause != CauseAdmin
|
|
})
|
|
if i < 0 {
|
|
// Its bans are all an admin's, and never dropped. Get makes
|
|
// it the most recently seen, so that the next netblock is
|
|
// looked at; when it was seen matters only for dropping a
|
|
// ban, and a ban added to it makes it the most recently seen
|
|
// anyway.
|
|
l.netblocks.Get(netblock)
|
|
|
|
continue
|
|
}
|
|
|
|
if len(*bans) == 1 {
|
|
l.netblocks.Remove(netblock)
|
|
} else {
|
|
*bans = slices.Delete(*bans, i, i+1)
|
|
}
|
|
|
|
l.held--
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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)])
|
|
}
|