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
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.
212 lines
6.0 KiB
Go
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
|
|
}
|
|
}
|
|
}
|