feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s

Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
This commit is contained in:
Ilia Denisov
2026-07-11 13:33:28 +02:00
parent ad1cc361e9
commit 4ba9da6721
17 changed files with 627 additions and 13 deletions
+69
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
pkgtel "scrabble/pkg/telemetry"
@@ -57,6 +58,8 @@ type Config struct {
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
Blocklist BlocklistConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
@@ -166,6 +169,42 @@ func DefaultAbuse() AbuseConfig {
}
}
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
// Enabled turns edge blocklisting on. Off by default (prod-only).
Enabled bool
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
// operator sets it explicitly so no feed URL is assumed.
URL string
// Refresh is how often the feed is re-fetched.
Refresh time.Duration
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
// (fail-open — a legitimate client is never blocked on a frozen feed).
MaxStaleness time.Duration
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
// the feed can never block. Parsed at startup.
Allow []string
}
// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
defaultBlocklistRefresh = 6 * time.Hour
defaultBlocklistMaxStaleness = 48 * time.Hour
)
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
return BlocklistConfig{
Enabled: false,
Refresh: defaultBlocklistRefresh,
MaxStaleness: defaultBlocklistMaxStaleness,
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
@@ -203,6 +242,7 @@ func Load() (Config, error) {
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
Blocklist: DefaultBlocklist(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
@@ -244,6 +284,17 @@ func Load() (Config, error) {
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
return Config{}, err
}
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
return Config{}, err
}
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
return Config{}, err
}
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
@@ -284,6 +335,9 @@ func (c Config) validate() error {
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.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_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")
@@ -304,6 +358,21 @@ func envOr(key, fallback string) string {
return fallback
}
// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// 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) {
+31 -7
View File
@@ -2,6 +2,7 @@ package connectsrv_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -24,7 +25,7 @@ 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()) {
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, 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)
@@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Banlist: bl,
Blocklist: block,
Honeytoken: honeytoken,
Hub: push.NewHub(0),
RateLimit: limits,
@@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
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) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
@@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
// 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) {
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for a honeypot hit")
})
defer cleanup()
@@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
// 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) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
@@ -150,7 +152,7 @@ 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) {
_, client, cleanup := guardedEdge(t, bl, nil, "", 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()
@@ -173,7 +175,7 @@ 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) {
_, client, cleanup := guardedEdge(t, bl, nil, "", 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}`))
@@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
// 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) {
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for the honeytoken")
})
defer cleanup()
@@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
t.Fatal("the honeytoken must ban the caller")
}
}
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
// with 403 at the HTTP layer, before any handler runs.
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
block := ratelimit.NewBlocklist(true, nil)
_, feed, err := net.ParseCIDR("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
url, _, cleanup := guardedEdge(t, nil, block, "", 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.StatusForbidden {
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
}
}
+30 -1
View File
@@ -7,6 +7,8 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"scrabble/gateway/internal/ratelimit"
)
// meterName scopes the gateway edge's OpenTelemetry instruments.
@@ -27,6 +29,7 @@ type serverMetrics struct {
edge metric.Float64Histogram
rateLimited metric.Int64Counter
banned metric.Int64Counter
blocklisted metric.Int64Counter
active *activeUsers
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
localColdStart metric.Int64Counter
@@ -40,7 +43,7 @@ type serverMetrics struct {
// falling back to a no-op histogram on the (rare) construction error. The
// active_users gauge is registered as an observable callback over the in-memory
// tracker.
func newServerMetrics(meter metric.Meter) *serverMetrics {
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
if meter == nil {
meter = noop.NewMeterProvider().Meter(meterName)
}
@@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
}
m := &serverMetrics{
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
@@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
return nil
}, gauge)
}
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
// never-fetched blocklist reports no age (and entries 0).
if bl != nil {
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
if e1 == nil && e2 == nil {
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveInt64(entries, int64(bl.Len()))
if last := bl.LastFetch(); !last.IsZero() {
o.ObserveFloat64(age, time.Since(last).Seconds())
}
return nil
}, entries, age)
}
}
return m
}
@@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordBlocklistBlock counts one request refused by the community IP blocklist.
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
m.blocklisted.Add(ctx, 1)
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
+4 -4
View File
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordRateLimited(ctx, "user")
m.recordRateLimited(ctx, "user")
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "tripwire")
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
+17 -1
View File
@@ -77,6 +77,7 @@ type Server struct {
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
blocklist *ratelimit.Blocklist
honeytoken string
vkAppSecret string
hub *push.Hub
@@ -109,6 +110,9 @@ type Deps struct {
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
// disabled (inert) blocklist.
Blocklist *ratelimit.Blocklist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
@@ -148,6 +152,10 @@ func NewServer(d Deps) *Server {
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
blocklist := d.Blocklist
if blocklist == nil {
blocklist = ratelimit.NewBlocklist(false, nil)
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
@@ -160,12 +168,13 @@ func NewServer(d Deps) *Server {
limiter: limiter,
tracker: tracker,
banlist: banlist,
blocklist: blocklist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter),
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
@@ -255,6 +264,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
if s.blocklist.Blocked(ip) {
s.metrics.recordBlocklistBlock(r.Context())
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
+188
View File
@@ -0,0 +1,188 @@
package ratelimit
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"net"
"sort"
"strings"
"sync"
"time"
)
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
type ipRange struct{ lo, hi uint32 }
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
type Blocklist struct {
enabled bool
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
mu sync.RWMutex
ranges []ipRange // sorted, non-overlapping; the current feed
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
}
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
}
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
func (b *Blocklist) Blocked(ip string) bool {
if !b.enabled {
return false
}
v, ok := ipv4ToUint32(ip)
if !ok {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
return false
}
return rangesContain(b.ranges, v)
}
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
r := toRanges(cidrs)
b.mu.Lock()
b.ranges = r
b.fetchedAt = at
b.mu.Unlock()
}
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
// climbing. Blocked then returns false until a fresh feed loads.
func (b *Blocklist) Clear() {
b.mu.Lock()
b.ranges = nil
b.mu.Unlock()
}
// Len returns the number of ranges currently enforced.
func (b *Blocklist) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.ranges)
}
// LastFetch returns the time of the last successful feed load (zero if never).
func (b *Blocklist) LastFetch() time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.fetchedAt
}
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
type RefreshOutcome int
const (
// RefreshUpdated: a new feed was fetched and applied.
RefreshUpdated RefreshOutcome = iota
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
RefreshKept
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
RefreshDropped
)
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
if fetchErr == nil {
bl.SetCIDRs(cidrs, now)
return RefreshUpdated
}
last := bl.LastFetch()
if last.IsZero() || now.Sub(last) > maxStaleness {
bl.Clear()
return RefreshDropped
}
return RefreshKept
}
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
var out []*net.IPNet
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.Contains(line, "/") {
line += "/32"
}
_, ipnet, err := net.ParseCIDR(line)
if err != nil || ipnet.IP.To4() == nil {
continue
}
out = append(out, ipnet)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
}
return out, nil
}
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
// are dropped. Sorting by lo lets [rangesContain] binary-search.
func toRanges(cidrs []*net.IPNet) []ipRange {
out := make([]ipRange, 0, len(cidrs))
for _, n := range cidrs {
if n == nil {
continue
}
ip4 := n.IP.To4()
if ip4 == nil {
continue
}
ones, bits := n.Mask.Size()
if bits != 32 {
continue
}
lo := binary.BigEndian.Uint32(ip4)
hi := lo | (uint32(0xffffffff) >> uint(ones))
out = append(out, ipRange{lo: lo, hi: hi})
}
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
return out
}
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
// binary search for the last range whose lo is at most v.
func rangesContain(ranges []ipRange, v uint32) bool {
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
return i > 0 && ranges[i-1].hi >= v
}
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
func ipv4ToUint32(s string) (uint32, bool) {
ip := net.ParseIP(s)
if ip == nil {
return 0, false
}
ip4 := ip.To4()
if ip4 == nil {
return 0, false
}
return binary.BigEndian.Uint32(ip4), true
}
@@ -0,0 +1,106 @@
package ratelimit
import (
"errors"
"net"
"strings"
"testing"
"time"
)
// cidrs parses CIDR strings into networks for a test fixture.
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
t.Helper()
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("bad cidr %q: %v", s, err)
}
out = append(out, n)
}
return out
}
func TestBlocklistBlocked(t *testing.T) {
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
cases := map[string]bool{
"1.2.3.0": true, // range start
"1.2.3.255": true, // range end
"1.2.4.0": false, // just past the /24
"203.0.113.5": true, // /32 exact
"203.0.113.6": false,
"198.51.100.15": true, // within /28
"198.51.100.16": false, // past the /28
"9.9.9.9": false, // not listed
"10.20.30.99": false, // listed BUT allowlisted — never blocked
"::1": false, // IPv6 — never matched here
"not-an-ip": false,
}
for ip, want := range cases {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
}
}
}
func TestBlocklistDisabledOrEmpty(t *testing.T) {
off := NewBlocklist(false, nil)
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
if off.Blocked("1.2.3.4") {
t.Error("a disabled blocklist must not block")
}
empty := NewBlocklist(true, nil)
if empty.Blocked("1.2.3.4") {
t.Error("an empty (never-loaded) blocklist must not block")
}
}
func TestParseDROP(t *testing.T) {
in := "; Spamhaus DROP\n" +
"1.2.3.0/24 ; SBL1\n" +
" 198.51.100.0/28 ; SBL2\n" +
"\n" +
"203.0.113.5 ; a bare IP -> /32\n" +
"2001:db8::/32 ; IPv6 skipped\n" +
"garbage line\n"
nets, err := ParseDROP(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
if len(nets) != 3 {
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
}
bl := NewBlocklist(true, nil)
bl.SetCIDRs(nets, time.Now())
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
}
}
}
func TestApplyRefresh(t *testing.T) {
bl := NewBlocklist(true, nil)
now := time.Now()
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
t.Fatalf("success: want RefreshUpdated, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Fatal("a successful refresh must apply the feed")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Error("a kept feed must still block")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
}
if bl.Blocked("1.2.3.4") {
t.Error("a stale feed must be dropped (fail-open)")
}
}