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 } } }