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

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:
Ilia Denisov
2026-06-21 08:54:20 +02:00
parent 3fffee7817
commit 041106d623
28 changed files with 1295 additions and 19 deletions
+19 -1
View File
@@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go)
internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker + the temporary IP banlist
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
@@ -89,6 +89,11 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` |
| `GATEWAY_ABUSE_BAN_ENABLED` | `false` | enable the temporary IP ban (prod-only — keys by real client IP, off in the shared-NAT test contour) |
| `GATEWAY_ABUSE_BAN_THRESHOLD` | `100` | rate-limiter rejections within the window that ban an IP |
| `GATEWAY_ABUSE_BAN_WINDOW` | `2m` | rolling window the rejection strikes accumulate over |
| `GATEWAY_ABUSE_BAN_DURATION` | `15m` | length of a rejection-earned ban (tripwire 1h, honeytoken 24h are fixed) |
| `GATEWAY_HONEYTOKEN` | unset | planted bearer value; presenting it bans the caller and raises an alarm |
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
@@ -104,6 +109,19 @@ per-key rejection tracker every 30 s, emits a Warn summary per throttled key and
posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding
the admin console's throttled view and the high-rate auto-flag.
Temporary IP ban (prod-only, `GATEWAY_ABUSE_BAN_ENABLED`): a fail2ban-style block
keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed classes),
a **honeypot** decoy-path hit (the contour caddy tags decoy paths with
`X-Scrabble-Honeypot`), and a **honeytoken** (`GATEWAY_HONEYTOKEN`). A banned IP is
refused with 429 by the `abuseGuard` edge middleware before any work — covering the
Connect edge, the live stream and the static SPA/landing. Each ban increments
`gateway_abuse_banned_total{reason}` (`rejections`/`tripwire`/`honeytoken`). The ban
is in-memory (resets on restart); it is **off by default** because it keys by the real
client IP, which the shared-NAT test contour does not expose (detection still logs
there, only the ban action is gated). The gateway syncs its active set to the backend
every 30 s (`/api/v1/internal/bans/sync`) for the console's **Active IP bans** panel
and applies the operator unbans the response returns.
## Run
```sh
+41
View File
@@ -68,6 +68,9 @@ const (
// throttleReportInterval is the cadence of the rate-limiter rejection
// summary: the Warn log per throttled key and the report to the backend.
throttleReportInterval = 30 * time.Second
// banSyncInterval is the cadence of the active-ban sync to the backend (which
// feeds the admin-console view) and the operator-unban pull.
banSyncInterval = 30 * time.Second
)
func main() {
@@ -119,6 +122,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
sessions := session.NewCache(backend, cfg.SessionTTL, cfg.SessionCacheMax)
limiter := ratelimit.New()
tracker := ratelimit.NewTracker()
banlist := ratelimit.NewBanlist(ratelimit.BanConfig{
Enabled: cfg.Abuse.BanEnabled,
Threshold: cfg.Abuse.BanThreshold,
Window: cfg.Abuse.BanWindow,
Duration: cfg.Abuse.BanDuration,
})
hub := push.NewHub(0)
var validator transcode.TelegramValidator
@@ -183,6 +192,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Sessions: sessions,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
@@ -197,6 +208,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
go runPushPump(ctx, backend, hub, botHub, logger)
// Periodically summarise rate-limiter rejections (Warn log + backend report).
go runThrottleReporter(ctx, tracker, backend, logger)
// When the IP ban is enabled (prod), sync the active set to the backend (the
// admin-console view) and apply the operator unbans it returns.
if cfg.Abuse.BanEnabled {
go runBanSync(ctx, banlist, backend, logger)
}
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
servers := []*namedServer{{name: "public", srv: public}}
@@ -298,6 +314,31 @@ func runThrottleReporter(ctx context.Context, tracker *ratelimit.Tracker, backen
}
}
// runBanSync periodically reports the gateway's active IP bans to the backend (the
// admin-console view) and applies the operator unbans it returns, until the
// context is done. A failed sync is logged and dropped — the next tick reports
// fresh state, and a missed unban is retried on it.
func runBanSync(ctx context.Context, banlist *ratelimit.Banlist, backend *backendclient.Client, logger *zap.Logger) {
ticker := time.NewTicker(banSyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
unban, err := backend.SyncBans(ctx, banlist.Active())
if err != nil {
logger.Warn("ban sync failed", zap.Error(err))
continue
}
for _, ip := range unban {
banlist.Unban(ip)
logger.Info("ban cleared by operator", zap.String("client_ip", ip))
}
}
}
// runPushPump keeps a backend push subscription open, forwarding every event to
// the hub and re-subscribing after the stream ends, until the context is done. For
// the out-of-app push kinds it also routes events whose recipient has no live
+19
View File
@@ -137,3 +137,22 @@ func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entri
}{WindowSeconds: windowSeconds, Entries: entries}
return c.do(ctx, http.MethodPost, "/api/v1/internal/ratelimit/report", "", "", body, nil)
}
// SyncBans reports the gateway's currently-active IP bans to the backend and
// returns the IPs an operator has marked for unban since the previous sync. It is
// the ban mirror of ReportRateLimited plus the manual-unban backchannel: the
// backend renders the active set in the admin console and drains the operator's
// unban requests into the response. Like the rejection report it carries no user
// identity and rides the trusted internal segment.
func (c *Client) SyncBans(ctx context.Context, active []ratelimit.Ban) ([]string, error) {
body := struct {
Active []ratelimit.Ban `json:"active"`
}{Active: active}
var out struct {
Unban []string `json:"unban"`
}
if err := c.do(ctx, http.MethodPost, "/api/v1/internal/bans/sync", "", "", body, &out); err != nil {
return nil, err
}
return out.Unban, nil
}
@@ -46,3 +46,39 @@ func TestReportRateLimited(t *testing.T) {
t.Fatalf("backend received %+v, want window 30 + %+v", got, entries[0])
}
}
// TestSyncBans verifies the gateway reports its active bans to the backend's
// internal endpoint and returns the operator unban list the backend replies with.
func TestSyncBans(t *testing.T) {
var got struct {
Active []ratelimit.Ban `json:"active"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/bans/sync" {
t.Errorf("call = %s %s, want POST /api/v1/internal/bans/sync", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode sync: %v", err)
}
_, _ = w.Write([]byte(`{"unban":["203.0.113.9"]}`))
}))
defer srv.Close()
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
defer func() { _ = c.Close() }()
active := []ratelimit.Ban{{IP: "198.51.100.4", Reason: ratelimit.ReasonTripwire}}
unban, err := c.SyncBans(context.Background(), active)
if err != nil {
t.Fatalf("SyncBans: %v", err)
}
if len(got.Active) != 1 || got.Active[0].IP != "198.51.100.4" || got.Active[0].Reason != ratelimit.ReasonTripwire {
t.Fatalf("backend received active = %+v, want one tripwire ban for 198.51.100.4", got.Active)
}
if len(unban) != 1 || unban[0] != "203.0.113.9" {
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
}
}
+68
View File
@@ -46,6 +46,8 @@ type Config struct {
MaxBodyBytes int
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
@@ -87,8 +89,32 @@ type RateLimitConfig struct {
EmailBurst int
}
// AbuseConfig configures the gateway's temporary IP ban (fail2ban-style) and the
// honeytoken trap. BanEnabled gates the ban action and is off by default: it is
// only safe where the real client IP is visible (i.e. in prod, not behind the
// shared-NAT test contour). Detection of honeypot/honeytoken hits is logged
// regardless of BanEnabled — only the ban action is gated.
type AbuseConfig struct {
// BanEnabled turns the IP ban on. Off by default (prod-only).
BanEnabled bool
// BanThreshold is the rate-limiter rejection count within BanWindow that bans
// a client IP.
BanThreshold int
// BanWindow is the rolling window the rejection strikes accumulate over.
BanWindow time.Duration
// BanDuration is the length of a rejection-earned ban (tripwire and honeytoken
// bans use their own, longer, fixed durations).
BanDuration time.Duration
// Honeytoken, when non-empty, is a planted bearer value: presenting it bans the
// caller and raises a high-severity alarm. Empty disables the trap.
Honeytoken string
}
// Defaults applied when the corresponding environment variable is unset.
const (
defaultAbuseBanThreshold = 100
defaultAbuseBanWindow = 2 * time.Minute
defaultAbuseBanDuration = 15 * time.Minute
defaultHTTPAddr = ":8081"
defaultLogLevel = "info"
defaultBackendHTTPURL = "http://localhost:8080"
@@ -120,6 +146,17 @@ func DefaultRateLimit() RateLimitConfig {
}
}
// DefaultAbuse returns the built-in anti-abuse settings: the ban disabled
// (prod-only) with the agreed thresholds, and no honeytoken.
func DefaultAbuse() AbuseConfig {
return AbuseConfig{
BanEnabled: false,
BanThreshold: defaultAbuseBanThreshold,
BanWindow: defaultAbuseBanWindow,
BanDuration: defaultAbuseBanDuration,
}
}
// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
@@ -134,6 +171,7 @@ func Load() (Config, error) {
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
@@ -162,6 +200,19 @@ func Load() (Config, error) {
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
return Config{}, err
}
c.Abuse.Honeytoken = os.Getenv("GATEWAY_HONEYTOKEN")
if c.Abuse.BanEnabled, err = envBool("GATEWAY_ABUSE_BAN_ENABLED", c.Abuse.BanEnabled); err != nil {
return Config{}, err
}
if c.Abuse.BanThreshold, err = envInt("GATEWAY_ABUSE_BAN_THRESHOLD", c.Abuse.BanThreshold); err != nil {
return Config{}, err
}
if c.Abuse.BanWindow, err = envDuration("GATEWAY_ABUSE_BAN_WINDOW", c.Abuse.BanWindow); err != nil {
return Config{}, err
}
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
@@ -199,6 +250,9 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
@@ -219,6 +273,20 @@ func envOr(key, fallback string) string {
return fallback
}
// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("config: %s: %w", key, err)
}
return b, nil
}
// envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
+50
View File
@@ -2,6 +2,7 @@ package config
import (
"testing"
"time"
pkgtel "scrabble/pkg/telemetry"
)
@@ -45,3 +46,52 @@ func TestLoadMaxBodyBytes(t *testing.T) {
t.Fatal("Load: expected an error for a non-positive body cap, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := DefaultAbuse()
if c.Abuse != want {
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
}
if c.Abuse.BanEnabled {
t.Error("ban must default to disabled (enabled only in prod)")
}
}
// TestLoadAbuseOverrides verifies the anti-abuse environment variables are parsed.
func TestLoadAbuseOverrides(t *testing.T) {
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "50")
t.Setenv("GATEWAY_ABUSE_BAN_WINDOW", "90s")
t.Setenv("GATEWAY_ABUSE_BAN_DURATION", "30m")
t.Setenv("GATEWAY_HONEYTOKEN", "deadbeef")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := AbuseConfig{
BanEnabled: true,
BanThreshold: 50,
BanWindow: 90 * time.Second,
BanDuration: 30 * time.Minute,
Honeytoken: "deadbeef",
}
if c.Abuse != want {
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
}
}
// TestLoadAbuseRejectsBadThreshold verifies an enabled ban with a non-positive
// threshold fails validation.
func TestLoadAbuseRejectsBadThreshold(t *testing.T) {
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an enabled ban with a zero threshold, got nil")
}
}
+219
View File
@@ -0,0 +1,219 @@
package connectsrv_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"connectrpc.com/connect"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
)
const honeypotHeader = "X-Scrabble-Honeypot"
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
// backend, returning the front URL, a Connect client and a cleanup func.
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Banlist: bl,
Honeytoken: honeytoken,
Hub: push.NewHub(0),
RateLimit: limits,
Heartbeat: 15 * time.Second,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return edgeSrv.URL, client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
// noRedirect is an HTTP client that surfaces a redirect instead of following it,
// so the test can tell a 308 (passed the guard) from a 429 (blocked).
func noRedirect() *http.Client {
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
}
func enabledBanlist(threshold int) *ratelimit.Banlist {
return ratelimit.NewBanlist(ratelimit.BanConfig{
Enabled: true, Threshold: threshold, Window: time.Minute, Duration: time.Hour,
})
}
// TestAbuseGuardBlocksBannedIP verifies a banned client IP is refused with 429 at
// the HTTP layer, before any handler runs.
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
bl := enabledBanlist(100)
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("banned GET / = %d, want 429", resp.StatusCode)
}
}
// TestHoneypotHeaderTrips verifies a request carrying the honeypot header is 404'd
// and bans the client IP, so the next request is blocked.
func TestHoneypotHeaderTrips(t *testing.T) {
bl := enabledBanlist(100)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for a honeypot hit")
})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
req.Header.Set(honeypotHeader, "1")
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("honeypot get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("honeypot hit = %d, want 404", resp.StatusCode)
}
if !bl.Banned("127.0.0.1") {
t.Fatal("a honeypot hit must ban the client IP")
}
follow, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("follow-up get: %v", err)
}
_ = follow.Body.Close()
if follow.StatusCode != http.StatusTooManyRequests {
t.Fatalf("post-trip GET / = %d, want 429", follow.StatusCode)
}
}
// TestHoneypotDetectsWithoutBanWhenDisabled verifies the prod-only gate: a disabled
// banlist still 404s the decoy (detection/logging) but bans nothing.
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
req.Header.Set(honeypotHeader, "1")
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("honeypot get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("decoy = %d, want 404", resp.StatusCode)
}
if bl.Banned("127.0.0.1") {
t.Fatal("a disabled banlist must not ban")
}
follow, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("follow-up: %v", err)
}
_ = follow.Body.Close()
if follow.StatusCode != http.StatusPermanentRedirect {
t.Fatalf("post-trip GET / = %d, want 308 (not banned)", follow.StatusCode)
}
}
// TestPublicRejectionStrikesBan verifies a public-class limiter rejection feeds the
// banlist: with a one-strike threshold the rejected IP is then banned.
func TestPublicRejectionStrikesBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.PublicPerMinute, limits.PublicBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
if _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest})); err != nil {
t.Fatalf("first execute: %v", err)
}
_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest}))
if connect.CodeOf(err) != connect.CodeResourceExhausted {
t.Fatalf("second execute code = %v, want ResourceExhausted", connect.CodeOf(err))
}
if !bl.Banned("127.0.0.1") {
t.Fatal("a public rejection must strike the banlist")
}
}
// TestUserRejectionDoesNotBan verifies the user limiter class (keyed by account id,
// not IP) does not feed the IP banlist — that path is the backend's soft flag.
func TestUserRejectionDoesNotBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.UserPerMinute, limits.UserBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
case "/api/v1/user/feedback/unread":
_, _ = w.Write([]byte(`{"reply_unread":false}`))
default:
t.Errorf("unexpected backend path %s", r.URL.Path)
}
})
defer cleanup()
for i := range 2 {
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread})
req.Header().Set("Authorization", "Bearer tok")
_, err := client.Execute(context.Background(), req)
if i == 1 && connect.CodeOf(err) != connect.CodeResourceExhausted {
t.Fatalf("second execute code = %v, want ResourceExhausted", connect.CodeOf(err))
}
}
if len(bl.Active()) != 0 {
t.Fatalf("user-class rejection must not ban; active = %v", bl.Active())
}
}
// TestHoneytokenBansAndRejects verifies presenting the planted honeytoken bans the
// caller and returns the ordinary invalid-session error without a backend call.
func TestHoneytokenBansAndRejects(t *testing.T) {
bl := enabledBanlist(100)
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for the honeytoken")
})
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgProfileGet})
req.Header().Set("Authorization", "Bearer s3cr3t-trap")
_, err := client.Execute(context.Background(), req)
if connect.CodeOf(err) != connect.CodeUnauthenticated {
t.Fatalf("honeytoken code = %v, want Unauthenticated", connect.CodeOf(err))
}
if !bl.Banned("127.0.0.1") {
t.Fatal("the honeytoken must ban the caller")
}
}
+13 -1
View File
@@ -26,6 +26,7 @@ var activeUserWindows = []struct {
type serverMetrics struct {
edge metric.Float64Histogram
rateLimited metric.Int64Counter
banned metric.Int64Counter
active *activeUsers
}
@@ -48,7 +49,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_rate_limited_total")
}
m := &serverMetrics{edge: h, rateLimited: c, active: newActiveUsers()}
b, err := meter.Int64Counter("gateway_abuse_banned_total",
metric.WithDescription("Temporary IP bans applied at the edge, by reason (rejections, tripwire or honeytoken)."))
if err != nil {
b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total")
}
m := &serverMetrics{edge: h, rateLimited: c, banned: b, active: newActiveUsers()}
gauge, err := meter.Int64ObservableGauge("active_users",
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
@@ -86,3 +92,9 @@ func (m *serverMetrics) recordActive(uid string) {
func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) {
m.rateLimited.Add(ctx, 1, metric.WithAttributes(attribute.String("class", class)))
}
// recordBan counts one temporary IP ban under reason (rejections, tripwire or
// honeytoken).
func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
@@ -90,3 +90,41 @@ func TestRateLimitedMetric(t *testing.T) {
t.Errorf("rate_limited counts = %v, want user=2 public=1", counts)
}
}
// TestBannedMetric records ban events through a manual reader and asserts
// gateway_abuse_banned_total splits by reason.
func TestBannedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "honeytoken")
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
counts := map[string]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "gateway_abuse_banned_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("gateway_abuse_banned_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
counts[reason.AsString()] += dp.Value
}
}
}
if counts["tripwire"] != 2 || counts["honeytoken"] != 1 {
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
}
}
+72 -7
View File
@@ -8,6 +8,7 @@ package connectsrv
import (
"context"
"crypto/subtle"
"errors"
"net"
"net/http"
@@ -34,6 +35,12 @@ import (
// heartbeatKind is the live-stream keep-alive event kind.
const heartbeatKind = "heartbeat"
// honeypotHeader marks a request the edge proxy routed from a honeypot decoy path;
// any request carrying it is treated as a scanner hit. The proxy strips any
// client-supplied value before setting its own, and a spoofed value only bans the
// spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot"
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -63,6 +70,8 @@ type Server struct {
sessions *session.Cache
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
honeytoken string
hub *push.Hub
heartbeat time.Duration
log *zap.Logger
@@ -86,7 +95,13 @@ type Deps struct {
// Tracker accumulates limiter rejections for the periodic report; nil
// selects a private tracker (rejections are then only counted, never
// reported).
Tracker *ratelimit.Tracker
Tracker *ratelimit.Tracker
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
Hub *push.Hub
RateLimit config.RateLimitConfig
Heartbeat time.Duration
@@ -116,6 +131,10 @@ func NewServer(d Deps) *Server {
if limiter == nil {
limiter = ratelimit.New()
}
banlist := d.Banlist
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
@@ -125,6 +144,8 @@ func NewServer(d Deps) *Server {
sessions: d.Sessions,
limiter: limiter,
tracker: tracker,
banlist: banlist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
@@ -172,9 +193,11 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
// Every request body on the public listener is capped (the admin proxy POSTs
// included); the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
// honeypot hit is turned away before the body cap and the mux. Every request
// body on the public listener is then capped (the admin proxy POSTs included);
// the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout,
})
@@ -189,6 +212,32 @@ func maxBodyHandler(limit int, next http.Handler) http.Handler {
})
}
// abuseGuard refuses a banned client IP with 429 before any work, and turns a
// honeypot decoy hit (the proxy-set honeypotHeader) into an instant ban plus a
// bland 404 that is indistinguishable from an ordinary miss. The ban check and the
// tripwire ban are inert on a disabled banlist (the prod-only gate); the tripwire
// hit is logged either way as scanner telemetry.
func (s *Server) abuseGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := peerIP(r.RemoteAddr, r.Header)
if s.banlist.Banned(ip) {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
zap.String("client_ip", ip))
if s.banlist.BanNow(ip, ratelimit.ReasonTripwire) {
s.metrics.recordBan(r.Context(), string(ratelimit.ReasonTripwire))
}
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
@@ -207,7 +256,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, isGuest, err := s.resolve(ctx, req.Header())
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
if err != nil {
result = "unauthenticated"
return nil, err
@@ -263,7 +312,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, _, err := s.resolve(ctx, req.Header())
uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
}
@@ -311,6 +360,12 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
s.metrics.recordRateLimited(ctx, class)
s.tracker.Add(class, key)
// IP-keyed rejections (public, email, admin — the key is the client IP) feed
// the ban; the user class is keyed by account id and is the backend soft-flag's
// concern, not the IP ban's.
if class != classUser && s.banlist.Strike(key) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonRejections))
}
s.log.Debug("rate limited",
zap.String("class", class),
zap.String("key", key),
@@ -344,11 +399,21 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) {
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
// The honeytoken is a planted value no real client holds: presenting it is a
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
// return the ordinary invalid-session error so the trap stays indistinguishable.
if s.honeytoken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.honeytoken)) == 1 {
s.log.Warn("honeytoken presented", zap.String("client_ip", clientIP))
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
}
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
uid, isGuest, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
+211
View File
@@ -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
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package ratelimit
import (
"testing"
"time"
)
// banlistAt builds an enabled banlist whose clock the test drives through clk.
func banlistAt(clk *time.Time, cfg BanConfig) *Banlist {
bl := NewBanlist(cfg)
bl.now = func() time.Time { return *clk }
return bl
}
func enabledCfg() BanConfig {
return BanConfig{Enabled: true, Threshold: 3, Window: time.Minute, Duration: 15 * time.Minute}
}
func TestBanlistStrikeThreshold(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
if bl.Strike("1.2.3.4") {
t.Fatal("first strike must not ban")
}
if bl.Strike("1.2.3.4") {
t.Fatal("second strike must not ban")
}
if bl.Banned("1.2.3.4") {
t.Fatal("must not be banned before the third strike")
}
if !bl.Strike("1.2.3.4") {
t.Fatal("third strike within the window must ban")
}
if !bl.Banned("1.2.3.4") {
t.Fatal("must be banned after the threshold strike")
}
}
func TestBanlistStrikeWindowResets(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
// Strikes spaced wider than the window never accumulate to a ban.
for i := range 5 {
if bl.Strike("9.9.9.9") {
t.Fatalf("strike %d should not ban: each falls outside the previous window", i)
}
clk = clk.Add(2 * time.Minute)
}
if bl.Banned("9.9.9.9") {
t.Fatal("strikes outside the rolling window must not ban")
}
}
func TestBanlistBanExpires(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
bl.BanNow("5.5.5.5", ReasonRejections)
if !bl.Banned("5.5.5.5") {
t.Fatal("must be banned right after BanNow")
}
clk = clk.Add(15*time.Minute + time.Second)
if bl.Banned("5.5.5.5") {
t.Fatal("ban must lapse after its duration")
}
}
func TestBanlistReasonDurations(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
bl.BanNow("a", ReasonTripwire) // 1h
bl.BanNow("b", ReasonHoneytoken) // 24h
clk = clk.Add(90 * time.Minute)
if bl.Banned("a") {
t.Fatal("tripwire ban (1h) must have lapsed after 90m")
}
if !bl.Banned("b") {
t.Fatal("honeytoken ban (24h) must still hold after 90m")
}
}
func TestBanlistUnban(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
bl.BanNow("7.7.7.7", ReasonTripwire)
bl.Unban("7.7.7.7")
if bl.Banned("7.7.7.7") {
t.Fatal("Unban must clear the ban")
}
}
func TestBanlistActiveSnapshot(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
bl := banlistAt(&clk, enabledCfg())
bl.BanNow("a", ReasonTripwire)
bl.BanNow("b", ReasonHoneytoken)
active := bl.Active()
if len(active) != 2 {
t.Fatalf("Active = %d bans, want 2", len(active))
}
byIP := map[string]Ban{}
for _, b := range active {
byIP[b.IP] = b
}
if byIP["a"].Reason != ReasonTripwire || byIP["b"].Reason != ReasonHoneytoken {
t.Fatalf("Active reasons = %+v", byIP)
}
if !byIP["a"].Expires.After(byIP["a"].Since) {
t.Fatal("Expires must be after Since")
}
clk = clk.Add(2 * time.Hour) // tripwire (1h) lapses, honeytoken (24h) holds
if got := len(bl.Active()); got != 1 {
t.Fatalf("Active after 2h = %d, want 1 (only honeytoken)", got)
}
}
func TestBanlistDisabledIsInert(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
cfg := enabledCfg()
cfg.Enabled = false
bl := banlistAt(&clk, cfg)
for range 10 {
if bl.Strike("1.1.1.1") {
t.Fatal("disabled banlist must never ban via Strike")
}
}
if bl.BanNow("2.2.2.2", ReasonHoneytoken) {
t.Fatal("disabled banlist must never ban via BanNow")
}
if bl.Banned("1.1.1.1") || bl.Banned("2.2.2.2") {
t.Fatal("disabled banlist must report nothing banned")
}
if len(bl.Active()) != 0 {
t.Fatal("disabled banlist must expose no active bans")
}
}