check / check (push) Waiting to run
GeoJS's geo.json is asked about every new visitor unless SWWAF_LOOKUP_SOURCE is off. A request waits for its client's first answer only while a country list or SWWAF_ADD_LOOKUP_HEADERS needs it; otherwise the answer reaches the client's history and ban notes when it comes. The AS number and name go beside the country in the request log, history, ban notes, alerts and lookups.json, with metrics by AS number; 64512 counts as unknown. Judgement call: AS numbers are written AS64496, as SPEC's settings write them. Judgement call: SWWAF_LOOKUP_TIMEOUT is added, default 1s, and cannot be off. Judgement call: a client's own X-Client-* headers are removed only while SWWAF_ADD_LOOKUP_HEADERS is set. Model: opus-5-5
814 lines
24 KiB
Go
814 lines
24 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 {
|
|
// ASN, ASName and Country are the client's AS number, AS name and
|
|
// country, when they were looked up: when the request that caused the
|
|
// ban was made, or when GeoJS answered about the client afterwards.
|
|
ASN string `json:"asn"`
|
|
ASName string `json:"as_name"`
|
|
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.
|
|
// The last result reports whether the request made the ban permanent.
|
|
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
ban := l.active(client, now)
|
|
if ban == nil {
|
|
return Ban{}, false, false
|
|
}
|
|
|
|
ban.Notes.Requests++
|
|
ban.Notes.Refused++
|
|
|
|
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
|
|
if madePermanent {
|
|
ban.Expires = time.Time{}
|
|
|
|
l.markChanged()
|
|
}
|
|
|
|
return *ban, true, madePermanent
|
|
}
|
|
|
|
// Find is Check without counting the request among those the ban
|
|
// refused, and without making the ban permanent: in observe mode a ban
|
|
// refuses nothing. The last result reports whether Check would have made
|
|
// the ban permanent.
|
|
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
ban := l.active(client, now)
|
|
if ban == nil {
|
|
return Ban{}, false, false
|
|
}
|
|
|
|
return *ban, true, ban.Cause == CauseAttack && !ban.Permanent()
|
|
}
|
|
|
|
// 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, and true. 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 with false, 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, bool) {
|
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true)
|
|
}
|
|
|
|
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
|
// what observe mode would have done.
|
|
func (l *Ledger) WouldBanForLimit(
|
|
netblock netip.Prefix, now time.Time, notes Notes,
|
|
) (Ban, bool) {
|
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, false)
|
|
}
|
|
|
|
// BanForAttack bans netblock at now for a clear sign of attack, with
|
|
// notes, and returns the ban, and whether it made it, 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, bool) {
|
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, true)
|
|
}
|
|
|
|
// WouldBanForAttack returns what BanForAttack would, without making the
|
|
// ban: what observe mode would have done.
|
|
func (l *Ledger) WouldBanForAttack(
|
|
netblock netip.Prefix, now time.Time, notes Notes,
|
|
) (Ban, bool) {
|
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, false)
|
|
}
|
|
|
|
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit
|
|
// or CauseAttack, made at now would be permanent, as BanForLimit or
|
|
// BanForAttack would make it. It works out nothing else of the ban.
|
|
func (l *Ledger) WouldBePermanent(
|
|
netblock netip.Prefix, now time.Time, cause string,
|
|
) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var held []Ban
|
|
if bans, found := l.netblocks.Peek(netblock); found {
|
|
held = *bans
|
|
}
|
|
|
|
if cause == CauseAttack {
|
|
return l.attackExpiry(held, now).IsZero()
|
|
}
|
|
|
|
return l.limitExpiry(held, now).IsZero()
|
|
}
|
|
|
|
// limitReason is the reason of a ban for a broken limit, with notes.
|
|
func limitReason(notes Notes) string {
|
|
return fmt.Sprintf("requests per %s over the limit of %d", notes.Window, notes.Limit)
|
|
}
|
|
|
|
// attackReason is the reason of a ban for a clear sign of attack, with
|
|
// notes.
|
|
func attackReason(notes Notes) string {
|
|
return "matched the rule " + notes.RuleID
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// AddLookup gives the notes of netblock's bans that have no AS number, AS
|
|
// name or country yet those of a client in it, as GeoJS answered about
|
|
// it. It is not a request from netblock, and leaves when it was last seen
|
|
// unchanged. It does not have bans.json written at once: the notes are
|
|
// written with its next write, as the counts in them are.
|
|
func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
bans, found := l.netblocks.Peek(netblock)
|
|
if !found {
|
|
return
|
|
}
|
|
|
|
for i := range *bans {
|
|
notes := &(*bans)[i].Notes
|
|
if notes.ASN == "" && notes.ASName == "" && notes.Country == "" {
|
|
notes.ASN, notes.ASName, notes.Country = asn, asName, country
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, and whether
|
|
// it made it. Unless keep is true, the ban is not made, only returned: it
|
|
// is the ban that would have been made.
|
|
func (l *Ledger) ban(
|
|
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool,
|
|
) (Ban, bool) {
|
|
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, false
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if !keep {
|
|
return ban, true
|
|
}
|
|
|
|
l.add(ban)
|
|
l.made[cause]++
|
|
l.markChanged()
|
|
|
|
return ban, true
|
|
}
|
|
|
|
// 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)])
|
|
}
|