feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken
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
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.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user