4ba9da6721
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.
107 lines
3.1 KiB
Go
107 lines
3.1 KiB
Go
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)")
|
|
}
|
|
}
|