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.
93 lines
2.4 KiB
Go
93 lines
2.4 KiB
Go
// Package banview mirrors the gateway's active IP bans for the admin console and
|
|
// collects operator unban requests for the gateway to apply. Like ratewatch it is
|
|
// in-memory, single-instance and resets on a backend restart by design — the
|
|
// gateway re-reports its active set on the next sync, and the durable effect (the
|
|
// ban itself) lives in the gateway, not here.
|
|
package banview
|
|
|
|
import (
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Ban is one active IP ban as reported by the gateway.
|
|
type Ban struct {
|
|
IP string
|
|
Reason string
|
|
Since time.Time
|
|
Expires time.Time
|
|
}
|
|
|
|
// View holds the last-reported active bans and the operator's pending unbans.
|
|
type View struct {
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
bans map[string]Ban // last reported active set, keyed by IP
|
|
unban map[string]struct{} // IPs an operator marked for unban
|
|
}
|
|
|
|
// New constructs an empty View.
|
|
func New() *View {
|
|
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
|
|
}
|
|
|
|
// Ingest replaces the mirrored active set with the gateway's latest report,
|
|
// skipping entries with an empty IP or one that has already expired.
|
|
func (v *View) Ingest(active []Ban) {
|
|
now := v.now()
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.bans = make(map[string]Ban, len(active))
|
|
for _, b := range active {
|
|
if b.IP == "" || !now.Before(b.Expires) {
|
|
continue
|
|
}
|
|
v.bans[b.IP] = b
|
|
}
|
|
}
|
|
|
|
// Recent returns the mirrored active bans, most recently banned first.
|
|
func (v *View) Recent() []Ban {
|
|
now := v.now()
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
out := make([]Ban, 0, len(v.bans))
|
|
for _, b := range v.bans {
|
|
if now.Before(b.Expires) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
|
|
return out
|
|
}
|
|
|
|
// RequestUnban records an operator request to lift the ban on ip; the gateway
|
|
// applies it on its next sync (so the console reflects it within the sync
|
|
// interval). An empty ip is ignored.
|
|
func (v *View) RequestUnban(ip string) {
|
|
if ip == "" {
|
|
return
|
|
}
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.unban[ip] = struct{}{}
|
|
}
|
|
|
|
// DrainUnbans returns and clears the IPs operators have marked for unban since the
|
|
// previous drain. It returns nil when there are none.
|
|
func (v *View) DrainUnbans() []string {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
if len(v.unban) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(v.unban))
|
|
for ip := range v.unban {
|
|
out = append(out, ip)
|
|
}
|
|
clear(v.unban)
|
|
return out
|
|
}
|