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
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:
@@ -5,6 +5,26 @@
|
||||
list is in-memory and resets on a backend restart. An account sustaining
|
||||
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
|
||||
below — never banned automatically; clear the flag on the user card.</p>
|
||||
<section class="panel"><h2>Active IP bans</h2>
|
||||
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
|
||||
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Bans}}
|
||||
<tr>
|
||||
<td><code>{{.IP}}</code></td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.Since}}</td>
|
||||
<td>{{.Expires}}</td>
|
||||
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Recent episodes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
|
||||
|
||||
@@ -389,17 +389,27 @@ type BroadcastView struct {
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
||||
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
||||
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
||||
// auto-flag tuning.
|
||||
// ThrottledView is the rate-limit observability page: the temporary IP bans the
|
||||
// gateway is currently enforcing, the recent gateway-reported throttle episodes
|
||||
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
|
||||
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
|
||||
type ThrottledView struct {
|
||||
Bans []BanRow
|
||||
Episodes []ThrottleEpisodeRow
|
||||
Flagged []FlaggedAccountRow
|
||||
FlagThreshold int
|
||||
FlagWindow string
|
||||
}
|
||||
|
||||
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
|
||||
// since/expiry timestamps; the row carries an unban action.
|
||||
type BanRow struct {
|
||||
IP string
|
||||
Reason string
|
||||
Since string
|
||||
Expires string
|
||||
}
|
||||
|
||||
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
||||
// user card and is set only for the user class (the other classes key by IP).
|
||||
type ThrottleEpisodeRow struct {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package banview
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func viewAt(clk *time.Time) *View {
|
||||
v := New()
|
||||
v.now = func() time.Time { return *clk }
|
||||
return v
|
||||
}
|
||||
|
||||
func TestIngestRecentDropsExpired(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{
|
||||
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
|
||||
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
|
||||
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
|
||||
})
|
||||
got := v.Recent()
|
||||
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
|
||||
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestReplaces(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||
got := v.Recent()
|
||||
if len(got) != 1 || got[0].IP != "2.2.2.2" {
|
||||
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentOrdersBySince(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{
|
||||
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||
})
|
||||
got := v.Recent()
|
||||
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
|
||||
t.Fatalf("Recent order = %+v, want most recent first", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanRoundTrip(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.RequestUnban("3.3.3.3")
|
||||
v.RequestUnban("") // ignored
|
||||
drained := v.DrainUnbans()
|
||||
if len(drained) != 1 || drained[0] != "3.3.3.3" {
|
||||
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
|
||||
}
|
||||
if again := v.DrainUnbans(); again != nil {
|
||||
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ func (s *Server) registerRoutes() {
|
||||
// admin console's throttled view and the high-rate auto-flag.
|
||||
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
||||
}
|
||||
if s.banview != nil {
|
||||
// The gateway's periodic active-ban sync: feeds the admin console's
|
||||
// active-bans panel and returns the operator's pending unbans.
|
||||
s.internal.POST("/bans/sync", s.handleBanSync)
|
||||
}
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
u.GET("/profile", s.handleProfile)
|
||||
|
||||
@@ -66,6 +66,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
|
||||
gm.GET("/throttled", s.consoleThrottled)
|
||||
gm.POST("/bans/unban", s.consoleUnban)
|
||||
gm.GET("/games", s.consoleGames)
|
||||
gm.GET("/games/:id", s.consoleGameDetail)
|
||||
gm.GET("/complaints", s.consoleComplaints)
|
||||
@@ -874,6 +875,13 @@ func (s *Server) consoleThrottled(c *gin.Context) {
|
||||
view.Episodes = append(view.Episodes, row)
|
||||
}
|
||||
}
|
||||
if s.banview != nil {
|
||||
for _, b := range s.banview.Recent() {
|
||||
view.Bans = append(view.Bans, adminconsole.BanRow{
|
||||
IP: b.IP, Reason: b.Reason, Since: fmtTime(b.Since), Expires: fmtTime(b.Expires),
|
||||
})
|
||||
}
|
||||
}
|
||||
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
@@ -887,6 +895,21 @@ func (s *Server) consoleThrottled(c *gin.Context) {
|
||||
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
|
||||
}
|
||||
|
||||
// consoleUnban lifts a temporary IP ban — the operator's manual override. The
|
||||
// gateway applies it on its next active-ban sync, so the ban clears within the
|
||||
// sync interval rather than immediately.
|
||||
func (s *Server) consoleUnban(c *gin.Context) {
|
||||
ip := trimForm(c, "ip")
|
||||
if ip == "" {
|
||||
s.renderConsoleMessage(c, "Invalid", "an IP address is required", "/_gm/throttled")
|
||||
return
|
||||
}
|
||||
if s.banview != nil {
|
||||
s.banview.RequestUnban(ip)
|
||||
}
|
||||
s.renderConsoleMessage(c, "Unban requested", fmt.Sprintf("%s will be unbanned on the next gateway sync", ip), "/_gm/throttled")
|
||||
}
|
||||
|
||||
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
|
||||
// reversible review action.
|
||||
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/banview"
|
||||
)
|
||||
|
||||
// banSyncRequest mirrors the gateway's active-ban report: every entry is one
|
||||
// currently-enforced IP ban.
|
||||
type banSyncRequest struct {
|
||||
Active []banSyncEntry `json:"active"`
|
||||
}
|
||||
|
||||
// banSyncEntry is one active ban in the sync request.
|
||||
type banSyncEntry struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
Since time.Time `json:"since"`
|
||||
Expires time.Time `json:"expires"`
|
||||
}
|
||||
|
||||
// banSyncResponse returns the IPs an operator has marked for unban for the gateway
|
||||
// to apply on its next sync.
|
||||
type banSyncResponse struct {
|
||||
Unban []string `json:"unban"`
|
||||
}
|
||||
|
||||
// handleBanSync ingests the gateway's active-ban report into the ban view (the
|
||||
// admin console's active-bans panel) and returns the operator's pending unbans.
|
||||
// Internal, gateway-only: like the rate-limit report it trusts the network
|
||||
// segment and carries no user identity.
|
||||
func (s *Server) handleBanSync(c *gin.Context) {
|
||||
var req banSyncRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid ban sync")
|
||||
return
|
||||
}
|
||||
bans := make([]banview.Ban, 0, len(req.Active))
|
||||
for _, e := range req.Active {
|
||||
bans = append(bans, banview.Ban{IP: e.IP, Reason: e.Reason, Since: e.Since, Expires: e.Expires})
|
||||
}
|
||||
s.banview.Ingest(bans)
|
||||
c.JSON(http.StatusOK, banSyncResponse{Unban: s.banview.DrainUnbans()})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/banview"
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/feedback"
|
||||
@@ -83,6 +84,10 @@ type Deps struct {
|
||||
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
||||
// disables the internal report endpoint and the console view.
|
||||
RateWatch *ratewatch.Watch
|
||||
// BanView mirrors the gateway's active IP bans for the admin console and
|
||||
// collects operator unban requests. A nil BanView disables the internal
|
||||
// ban-sync endpoint and the console's active-bans panel.
|
||||
BanView *banview.View
|
||||
// Ads is the advertising-banner domain service: campaign rotation feeding the
|
||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||
// omits the banner block and disables the banner console.
|
||||
@@ -115,6 +120,7 @@ type Server struct {
|
||||
dictDir string
|
||||
connector *connector.Client
|
||||
ratewatch *ratewatch.Watch
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
@@ -164,6 +170,7 @@ func New(addr string, deps Deps) *Server {
|
||||
dictDir: deps.DictDir,
|
||||
connector: deps.Connector,
|
||||
ratewatch: deps.RateWatch,
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
notifier: notifier,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
Reference in New Issue
Block a user