Files
scrabble-game/gateway/internal/ratelimit/banlist.go
T
Ilia Denisov 041106d623
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken
Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals:
sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the
user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the
contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway),
and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with
429 by the abuseGuard middleware before any work — covering the Connect edge, the
live stream and the static SPA/landing the per-op limiter never gated.

The ban is off by default: it keys by the real client IP the shared-NAT test contour
does not expose, so a ban there would be self-inflicted; detection still logs in the
contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last
GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and
earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}.

Operators see and lift active bans on the admin console's Throttled page; the gateway
syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync,
backend/internal/banview) and applies the operator unbans the response returns.

PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.
2026-06-21 08:54:20 +02:00

212 lines
6.0 KiB
Go

package ratelimit
import (
"sort"
"sync"
"time"
)
// Reason labels why a client IP was banned; it is the reason attribute of the
// gateway_abuse_banned_total metric and a field of the ban report to the backend.
type Reason string
const (
// ReasonRejections is a ban earned by sustained rate-limiter rejections: the IP
// accumulated BanConfig.Threshold strikes within BanConfig.Window.
ReasonRejections Reason = "rejections"
// ReasonTripwire is an instant ban from a honeypot decoy-path hit.
ReasonTripwire Reason = "tripwire"
// ReasonHoneytoken is an instant ban from a planted credential being presented.
ReasonHoneytoken Reason = "honeytoken"
)
// Ban durations for the high-confidence reasons. A rejection ban uses the
// configured BanConfig.Duration; a tripwire or honeytoken hit is near
// zero-false-positive, so it earns a markedly longer ban.
const (
tripwireBanDuration = time.Hour
honeytokenBanDuration = 24 * time.Hour
)
// BanConfig tunes the temporary IP ban. Enabled gates the whole mechanism — kept
// off where the real client IP is not visible (e.g. every client arriving as one
// shared NAT address); when false every method is inert.
type BanConfig struct {
// Enabled turns enforcement on. While false Strike/BanNow record nothing,
// Banned is always false and Active is empty.
Enabled bool
// Threshold is the strike count within Window that earns a rejection ban.
Threshold int
// Window is the rolling window the strikes accumulate over.
Window time.Duration
// Duration is the length of a rejection ban (tripwire/honeytoken use their own).
Duration time.Duration
}
// Ban is a snapshot of one active ban for the periodic report and the admin view.
// Its JSON shape is the gateway→backend ban-sync wire contract.
type Ban struct {
IP string `json:"ip"`
Reason Reason `json:"reason"`
Since time.Time `json:"since"`
Expires time.Time `json:"expires"`
}
// Banlist is the gateway's in-memory temporary IP ban: a fail2ban-style block fed
// by sustained rate-limiter rejections (Strike) and by instant honeypot /
// honeytoken hits (BanNow), enforced on the hot path by Banned and cleared either
// by lapse or by an operator Unban. Entries are swept lazily so an expired ban
// does not leak memory. Like the rate limiter it is single-instance and resets on
// restart by design.
type Banlist struct {
cfg BanConfig
now func() time.Time
mu sync.Mutex
entries map[string]*banEntry
lastSweep time.Time
}
// banEntry is one IP's ban state: a live ban (until set) or, before the
// threshold, only the recent strike times.
type banEntry struct {
reason Reason
since time.Time
until time.Time // zero while only accumulating strikes
strikes []time.Time // strike times within the window; pruned on each strike
}
// NewBanlist constructs a Banlist with cfg.
func NewBanlist(cfg BanConfig) *Banlist {
return &Banlist{cfg: cfg, now: time.Now, entries: make(map[string]*banEntry)}
}
// Strike records one rate-limiter rejection for ip and reports whether it earned a
// ban (Threshold strikes within Window). It is a no-op on a disabled banlist.
func (b *Banlist) Strike(ip string) bool {
if !b.cfg.Enabled {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
now := b.now()
b.sweepLocked(now)
e := b.entries[ip]
if e == nil {
e = &banEntry{}
b.entries[ip] = e
}
if now.Before(e.until) {
return false // already banned (a banned IP normally never reaches here)
}
cutoff := now.Add(-b.cfg.Window)
kept := e.strikes[:0]
for _, t := range e.strikes {
if t.After(cutoff) {
kept = append(kept, t)
}
}
e.strikes = append(kept, now)
if len(e.strikes) >= b.cfg.Threshold {
e.reason = ReasonRejections
e.since = now
e.until = now.Add(b.cfg.Duration)
e.strikes = nil
return true
}
return false
}
// BanNow bans ip immediately for reason and reports whether a ban is now in effect
// (true on an enabled banlist, false when disabled).
func (b *Banlist) BanNow(ip string, reason Reason) bool {
if !b.cfg.Enabled {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
now := b.now()
b.sweepLocked(now)
e := b.entries[ip]
if e == nil {
e = &banEntry{}
b.entries[ip] = e
}
e.reason = reason
e.since = now
e.until = now.Add(banDuration(reason, b.cfg.Duration))
e.strikes = nil
return true
}
// Banned reports whether ip is currently banned. It is always false on a disabled
// banlist.
func (b *Banlist) Banned(ip string) bool {
if !b.cfg.Enabled {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
e := b.entries[ip]
return e != nil && b.now().Before(e.until)
}
// Unban clears any ban and accumulated strikes for ip.
func (b *Banlist) Unban(ip string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.entries, ip)
}
// Active returns a snapshot of the currently-banned IPs, most recently banned
// first. It is empty on a disabled banlist.
func (b *Banlist) Active() []Ban {
if !b.cfg.Enabled {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
now := b.now()
out := make([]Ban, 0, len(b.entries))
for ip, e := range b.entries {
if now.Before(e.until) {
out = append(out, Ban{IP: ip, Reason: e.reason, Since: e.since, Expires: e.until})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
return out
}
// banDuration maps a reason to its ban length.
func banDuration(reason Reason, rejectionDuration time.Duration) time.Duration {
switch reason {
case ReasonTripwire:
return tripwireBanDuration
case ReasonHoneytoken:
return honeytokenBanDuration
default:
return rejectionDuration
}
}
// sweepLocked discards lapsed bans and stale strike-only entries, at most once per
// sweepInterval. The caller holds b.mu.
func (b *Banlist) sweepLocked(now time.Time) {
if now.Sub(b.lastSweep) < sweepInterval {
return
}
b.lastSweep = now
cutoff := now.Add(-b.cfg.Window)
for ip, e := range b.entries {
if !e.until.IsZero() {
if !now.Before(e.until) {
delete(b.entries, ip) // ban lapsed
}
continue
}
if len(e.strikes) == 0 || !e.strikes[len(e.strikes)-1].After(cutoff) {
delete(b.entries, ip) // strike-only entry gone stale
}
}
}