feat(gateway): temporary IP ban (fail2ban) + honeypot/honeytoken (PRERELEASE AG) #97
@@ -40,6 +40,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
|
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
|
||||||
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
|
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
|
||||||
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
|
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
|
||||||
|
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
@@ -83,6 +84,19 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
||||||
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
||||||
**no auto-ban**.
|
**no auto-ban**.
|
||||||
|
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
|
||||||
|
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
|
||||||
|
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
|
||||||
|
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
|
||||||
|
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
|
||||||
|
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
|
||||||
|
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
|
||||||
|
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
|
||||||
|
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
|
||||||
|
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
|
||||||
|
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
|
||||||
|
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
|
||||||
|
counter cover it) to keep the diff focused.
|
||||||
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
|
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
|
||||||
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
|
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
|
||||||
joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app
|
joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
|||||||
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||||||
**Clear** action on the user card; never an automatic ban).
|
**Clear** action on the user card; never an automatic ban).
|
||||||
|
|
||||||
|
The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to
|
||||||
|
`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's
|
||||||
|
**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns
|
||||||
|
the operator's pending unbans in the response, which the gateway applies on its next
|
||||||
|
sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives
|
||||||
|
in the gateway, not here.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -173,6 +180,7 @@ internal/adminconsole/ # server-rendered admin console (Go templates + embedded
|
|||||||
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
||||||
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
|
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
|
||||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||||
|
internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration (environment)
|
## Configuration (environment)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
@@ -211,6 +212,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
||||||
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
||||||
|
|
||||||
|
// Ban observability: mirror the gateway's active IP bans for the admin console's
|
||||||
|
// active-bans panel and collect operator unban requests.
|
||||||
|
banView := banview.New()
|
||||||
|
|
||||||
// Advertising-banner domain: campaign rotation feeding the profile.get banner
|
// Advertising-banner domain: campaign rotation feeding the profile.get banner
|
||||||
// block and the banner admin console section.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
@@ -233,6 +238,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
DictDir: cfg.Game.DictDir,
|
DictDir: cfg.Game.DictDir,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
RateWatch: rateWatch,
|
RateWatch: rateWatch,
|
||||||
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,26 @@
|
|||||||
list is in-memory and resets on a backend restart. An account sustaining
|
list is in-memory and resets on a backend restart. An account sustaining
|
||||||
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
|
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
|
||||||
below — never banned automatically; clear the flag on the user card.</p>
|
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>
|
<section class="panel"><h2>Recent episodes</h2>
|
||||||
<table class="list">
|
<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>
|
<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
|
ConnectorEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
// ThrottledView is the rate-limit observability page: the temporary IP bans the
|
||||||
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
// gateway is currently enforcing, the recent gateway-reported throttle episodes
|
||||||
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
|
||||||
// auto-flag tuning.
|
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
|
||||||
type ThrottledView struct {
|
type ThrottledView struct {
|
||||||
|
Bans []BanRow
|
||||||
Episodes []ThrottleEpisodeRow
|
Episodes []ThrottleEpisodeRow
|
||||||
Flagged []FlaggedAccountRow
|
Flagged []FlaggedAccountRow
|
||||||
FlagThreshold int
|
FlagThreshold int
|
||||||
FlagWindow string
|
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
|
// 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).
|
// user card and is set only for the user class (the other classes key by IP).
|
||||||
type ThrottleEpisodeRow struct {
|
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.
|
// admin console's throttled view and the high-rate auto-flag.
|
||||||
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
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
|
u := s.user
|
||||||
if s.accounts != nil {
|
if s.accounts != nil {
|
||||||
u.GET("/profile", s.handleProfile)
|
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/update", s.consoleUpdateReason)
|
||||||
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
|
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
|
||||||
gm.GET("/throttled", s.consoleThrottled)
|
gm.GET("/throttled", s.consoleThrottled)
|
||||||
|
gm.POST("/bans/unban", s.consoleUnban)
|
||||||
gm.GET("/games", s.consoleGames)
|
gm.GET("/games", s.consoleGames)
|
||||||
gm.GET("/games/:id", s.consoleGameDetail)
|
gm.GET("/games/:id", s.consoleGameDetail)
|
||||||
gm.GET("/complaints", s.consoleComplaints)
|
gm.GET("/complaints", s.consoleComplaints)
|
||||||
@@ -874,6 +875,13 @@ func (s *Server) consoleThrottled(c *gin.Context) {
|
|||||||
view.Episodes = append(view.Episodes, row)
|
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)
|
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
@@ -887,6 +895,21 @@ func (s *Server) consoleThrottled(c *gin.Context) {
|
|||||||
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
|
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
|
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
|
||||||
// reversible review action.
|
// reversible review action.
|
||||||
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
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/account"
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/feedback"
|
"scrabble/backend/internal/feedback"
|
||||||
@@ -83,6 +84,10 @@ type Deps struct {
|
|||||||
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
||||||
// disables the internal report endpoint and the console view.
|
// disables the internal report endpoint and the console view.
|
||||||
RateWatch *ratewatch.Watch
|
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
|
// Ads is the advertising-banner domain service: campaign rotation feeding the
|
||||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||||
// omits the banner block and disables the banner console.
|
// omits the banner block and disables the banner console.
|
||||||
@@ -115,6 +120,7 @@ type Server struct {
|
|||||||
dictDir string
|
dictDir string
|
||||||
connector *connector.Client
|
connector *connector.Client
|
||||||
ratewatch *ratewatch.Watch
|
ratewatch *ratewatch.Watch
|
||||||
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
@@ -164,6 +170,7 @@ func New(addr string, deps Deps) *Server {
|
|||||||
dictDir: deps.DictDir,
|
dictDir: deps.DictDir,
|
||||||
connector: deps.Connector,
|
connector: deps.Connector,
|
||||||
ratewatch: deps.RateWatch,
|
ratewatch: deps.RateWatch,
|
||||||
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
|
|||||||
+20
-2
@@ -38,10 +38,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# The game SPA and the Connect edge are served by the gateway.
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081
|
reverse_proxy gateway:8081 {
|
||||||
|
header_up -X-Scrabble-Honeypot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Honeypot decoy paths: classic vulnerability-scanner bait no real client ever
|
||||||
|
# requests. Route them to the gateway tagged with X-Scrabble-Honeypot — the set
|
||||||
|
# replaces any client-supplied value — so it logs the scanner hit and (in prod)
|
||||||
|
# bans the source IP. (A delete + set in one block would not work: Caddy applies
|
||||||
|
# header_up deletions after sets, which would strip the tag we just set; the real
|
||||||
|
# endpoints instead strip the header in the @gateway block above.) Keep this list
|
||||||
|
# disjoint from every legitimate landing/app path.
|
||||||
|
@honeypot path /.env /.git /.git/* /.aws/* /wp-login.php /wp-admin /wp-admin/* /phpmyadmin /phpmyadmin/*
|
||||||
|
handle @honeypot {
|
||||||
|
reverse_proxy gateway:8081 {
|
||||||
|
header_up X-Scrabble-Honeypot 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Everything else — the public landing at / and any stray path — is static.
|
# Everything else — the public landing at / and any stray path — is static.
|
||||||
|
|||||||
@@ -150,6 +150,13 @@ services:
|
|||||||
GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt
|
GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt
|
||||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
|
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
||||||
|
# honeypot/honeytoken. Off by default: it bans by client IP, which is only
|
||||||
|
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
||||||
|
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
||||||
|
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
||||||
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
GATEWAY_SERVICE_NAME: scrabble-gateway
|
GATEWAY_SERVICE_NAME: scrabble-gateway
|
||||||
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
||||||
|
|||||||
+23
-1
@@ -922,6 +922,28 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
list/detail, cleared by the operator, **never an automatic ban** and never a request
|
list/detail, cleared by the operator, **never an automatic ban** and never a request
|
||||||
gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection
|
gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection
|
||||||
rate by class.
|
rate by class.
|
||||||
|
- **Temporary IP ban (prod-only):** with `GATEWAY_ABUSE_BAN_ENABLED` set, the gateway
|
||||||
|
enforces a fail2ban-style block keyed by client IP, fed by three signals: an IP that
|
||||||
|
sustains `GATEWAY_ABUSE_BAN_THRESHOLD` rate-limiter rejections within
|
||||||
|
`GATEWAY_ABUSE_BAN_WINDOW` (the IP-keyed public/email/admin classes — the user class
|
||||||
|
stays the soft-flag's concern, never the ban's), a **honeypot** decoy-path hit, and a
|
||||||
|
**honeytoken** (a planted bearer no real client holds, `GATEWAY_HONEYTOKEN`). A banned
|
||||||
|
IP is refused with **429** by an edge middleware (`abuseGuard`) before any work —
|
||||||
|
covering the Connect edge, the live stream and the static SPA/landing the per-op limiter
|
||||||
|
never gated. A rejection ban lasts `GATEWAY_ABUSE_BAN_DURATION`; a tripwire/honeytoken
|
||||||
|
hit is near-zero-false-positive and earns a longer fixed ban (1 h / 24 h). The ban is
|
||||||
|
**in-memory, single-instance and resets on restart**, like `ratewatch`; each ban
|
||||||
|
increments `gateway_abuse_banned_total` (`reason` = rejections/tripwire/honeytoken). The
|
||||||
|
decoy paths live only in the contour **caddy**, which tags them with `X-Scrabble-Honeypot`
|
||||||
|
(stripping any client-supplied value) and routes them to the gateway. It is **off by
|
||||||
|
default and only enabled in prod**: the ban keys by real client IP, which the shared-NAT
|
||||||
|
test contour does not expose (every client arrives as one address), so a ban there would
|
||||||
|
be self-inflicted — the honeypot/honeytoken still **log** in the contour, only the ban
|
||||||
|
*action* is gated. Operators see the active bans and lift them on the admin console's
|
||||||
|
**Throttled** page; the gateway syncs its active set to the backend every 30 s
|
||||||
|
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
|
||||||
|
the operator unbans the response returns, so a manual unban takes effect within the sync
|
||||||
|
interval.
|
||||||
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||||
database answers a bounded ping and the session cache is warmed).
|
database answers a bounded ping and the session cache is warmed).
|
||||||
- The backend serves a **second listener** — a gRPC server
|
- The backend serves a **second listener** — a gRPC server
|
||||||
@@ -932,7 +954,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
|
|
||||||
| Concern | Enforced by |
|
| Concern | Enforced by |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
|
|||||||
+8
-2
@@ -295,8 +295,14 @@ recently throttled users/IPs the gateway reported (an in-memory window — it re
|
|||||||
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
|
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
|
||||||
account sustaining rejections past a tunable threshold is flagged automatically —
|
account sustaining rejections past a tunable threshold is flagged automatically —
|
||||||
the marker is reversible, shown as a badge in the user list and on the user card, and
|
the marker is reversible, shown as a badge in the user list and on the user card, and
|
||||||
**never blocks play**; the operator reviews and clears it from the user card. There is
|
**never blocks play**; the operator reviews and clears it from the user card. The
|
||||||
no automatic ban.
|
account flag itself is never a ban. In **production** the same page also lists the
|
||||||
|
**active IP bans** the gateway is enforcing: a temporary block of a client IP that
|
||||||
|
floods the service past a threshold, or trips a hidden **honeypot** path or a planted
|
||||||
|
**honeytoken** — a high-confidence sign of a scanner or hostile bot, never a normal
|
||||||
|
player. Each ban shows its reason and expiry with an **Unban** action; bans auto-expire
|
||||||
|
and the operator can lift one early. IP bans are a production-only safeguard — the
|
||||||
|
shared test environment cannot tell its clients apart, so it does not enforce them.
|
||||||
|
|
||||||
The console also lets an operator **manually block** an account — the hard counterpart to the
|
The console also lets an operator **manually block** an account — the hard counterpart to the
|
||||||
soft high-rate flag. From the user card the operator blocks the account **permanently** or
|
soft high-rate flag. From the user card the operator blocks the account **permanently** or
|
||||||
|
|||||||
@@ -303,7 +303,14 @@ Telegram-identity) или **отправить пост в игровой кан
|
|||||||
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
|
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
|
||||||
автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке
|
автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке
|
||||||
аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с
|
аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с
|
||||||
карточки пользователя. Автоматического бана нет.
|
карточки пользователя. Сам флаг аккаунта баном не является. В **проде** та же
|
||||||
|
страница дополнительно перечисляет **активные баны по IP**, которые применяет gateway:
|
||||||
|
временную блокировку IP клиента, превысившего порог наплыва, либо задевшего скрытую
|
||||||
|
**honeypot**-ловушку или подброшенный **honeytoken** — высокодостоверный признак
|
||||||
|
сканера или враждебного бота, но не нормального игрока. У каждого бана показаны причина
|
||||||
|
и срок, рядом действие **Unban**; баны истекают сами, а оператор может снять бан раньше.
|
||||||
|
Баны по IP — защита только для прода: общий тестовый контур не различает своих клиентов,
|
||||||
|
поэтому там не применяется.
|
||||||
|
|
||||||
Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого
|
Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого
|
||||||
high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты**
|
high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты**
|
||||||
|
|||||||
+19
-1
@@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go)
|
|||||||
internal/config/ # GATEWAY_* env config
|
internal/config/ # GATEWAY_* env config
|
||||||
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
|
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
|
||||||
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
|
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/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/push/ # live-event fan-out hub (per-user client streams)
|
||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
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_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_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_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_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
|
||||||
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
||||||
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `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
|
posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding
|
||||||
the admin console's throttled view and the high-rate auto-flag.
|
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
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ const (
|
|||||||
// throttleReportInterval is the cadence of the rate-limiter rejection
|
// throttleReportInterval is the cadence of the rate-limiter rejection
|
||||||
// summary: the Warn log per throttled key and the report to the backend.
|
// summary: the Warn log per throttled key and the report to the backend.
|
||||||
throttleReportInterval = 30 * time.Second
|
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() {
|
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)
|
sessions := session.NewCache(backend, cfg.SessionTTL, cfg.SessionCacheMax)
|
||||||
limiter := ratelimit.New()
|
limiter := ratelimit.New()
|
||||||
tracker := ratelimit.NewTracker()
|
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)
|
hub := push.NewHub(0)
|
||||||
|
|
||||||
var validator transcode.TelegramValidator
|
var validator transcode.TelegramValidator
|
||||||
@@ -183,6 +192,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
|
Banlist: banlist,
|
||||||
|
Honeytoken: cfg.Abuse.Honeytoken,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
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)
|
go runPushPump(ctx, backend, hub, botHub, logger)
|
||||||
// Periodically summarise rate-limiter rejections (Warn log + backend report).
|
// Periodically summarise rate-limiter rejections (Warn log + backend report).
|
||||||
go runThrottleReporter(ctx, tracker, backend, logger)
|
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}
|
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
|
||||||
servers := []*namedServer{{name: "public", srv: public}}
|
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
|
// 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 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
|
// the out-of-app push kinds it also routes events whose recipient has no live
|
||||||
|
|||||||
@@ -137,3 +137,22 @@ func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entri
|
|||||||
}{WindowSeconds: windowSeconds, Entries: entries}
|
}{WindowSeconds: windowSeconds, Entries: entries}
|
||||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/ratelimit/report", "", "", body, nil)
|
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])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type Config struct {
|
|||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
// RateLimit configures the in-memory anti-abuse limiter.
|
// RateLimit configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
|
Abuse AbuseConfig
|
||||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
||||||
Telemetry pkgtel.Config
|
Telemetry pkgtel.Config
|
||||||
}
|
}
|
||||||
@@ -87,8 +89,32 @@ type RateLimitConfig struct {
|
|||||||
EmailBurst int
|
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.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
const (
|
const (
|
||||||
|
defaultAbuseBanThreshold = 100
|
||||||
|
defaultAbuseBanWindow = 2 * time.Minute
|
||||||
|
defaultAbuseBanDuration = 15 * time.Minute
|
||||||
defaultHTTPAddr = ":8081"
|
defaultHTTPAddr = ":8081"
|
||||||
defaultLogLevel = "info"
|
defaultLogLevel = "info"
|
||||||
defaultBackendHTTPURL = "http://localhost:8080"
|
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
|
// Load reads the configuration from the environment, applies defaults, and
|
||||||
// validates the result.
|
// validates the result.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -134,6 +171,7 @@ func Load() (Config, error) {
|
|||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
|
Abuse: DefaultAbuse(),
|
||||||
BotLink: BotLinkConfig{
|
BotLink: BotLinkConfig{
|
||||||
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
||||||
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_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 {
|
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
|
||||||
return Config{}, err
|
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 {
|
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -199,6 +250,9 @@ func (c Config) validate() error {
|
|||||||
if c.MaxBodyBytes <= 0 {
|
if c.MaxBodyBytes <= 0 {
|
||||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
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.Addr != "" {
|
||||||
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
|
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")
|
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
|
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
|
// 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.
|
// when it is unset and an error when it is set but malformed.
|
||||||
func envInt(key string, fallback int) (int, error) {
|
func envInt(key string, fallback int) (int, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ var activeUserWindows = []struct {
|
|||||||
type serverMetrics struct {
|
type serverMetrics struct {
|
||||||
edge metric.Float64Histogram
|
edge metric.Float64Histogram
|
||||||
rateLimited metric.Int64Counter
|
rateLimited metric.Int64Counter
|
||||||
|
banned metric.Int64Counter
|
||||||
active *activeUsers
|
active *activeUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +49,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_rate_limited_total")
|
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",
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
|
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) {
|
func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) {
|
||||||
m.rateLimited.Add(ctx, 1, metric.WithAttributes(attribute.String("class", class)))
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package connectsrv
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,6 +35,12 @@ import (
|
|||||||
// heartbeatKind is the live-stream keep-alive event kind.
|
// heartbeatKind is the live-stream keep-alive event kind.
|
||||||
const heartbeatKind = "heartbeat"
|
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
|
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||||
// class field of the periodic rejection report.
|
// class field of the periodic rejection report.
|
||||||
const (
|
const (
|
||||||
@@ -63,6 +70,8 @@ type Server struct {
|
|||||||
sessions *session.Cache
|
sessions *session.Cache
|
||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
tracker *ratelimit.Tracker
|
tracker *ratelimit.Tracker
|
||||||
|
banlist *ratelimit.Banlist
|
||||||
|
honeytoken string
|
||||||
hub *push.Hub
|
hub *push.Hub
|
||||||
heartbeat time.Duration
|
heartbeat time.Duration
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
@@ -86,7 +95,13 @@ type Deps struct {
|
|||||||
// Tracker accumulates limiter rejections for the periodic report; nil
|
// Tracker accumulates limiter rejections for the periodic report; nil
|
||||||
// selects a private tracker (rejections are then only counted, never
|
// selects a private tracker (rejections are then only counted, never
|
||||||
// reported).
|
// 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
|
Hub *push.Hub
|
||||||
RateLimit config.RateLimitConfig
|
RateLimit config.RateLimitConfig
|
||||||
Heartbeat time.Duration
|
Heartbeat time.Duration
|
||||||
@@ -116,6 +131,10 @@ func NewServer(d Deps) *Server {
|
|||||||
if limiter == nil {
|
if limiter == nil {
|
||||||
limiter = ratelimit.New()
|
limiter = ratelimit.New()
|
||||||
}
|
}
|
||||||
|
banlist := d.Banlist
|
||||||
|
if banlist == nil {
|
||||||
|
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
|
||||||
|
}
|
||||||
rl := d.RateLimit
|
rl := d.RateLimit
|
||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
rl = config.DefaultRateLimit()
|
||||||
@@ -125,6 +144,8 @@ func NewServer(d Deps) *Server {
|
|||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
tracker: tracker,
|
tracker: tracker,
|
||||||
|
banlist: banlist,
|
||||||
|
honeytoken: d.Honeytoken,
|
||||||
hub: d.Hub,
|
hub: d.Hub,
|
||||||
heartbeat: d.Heartbeat,
|
heartbeat: d.Heartbeat,
|
||||||
log: log,
|
log: log,
|
||||||
@@ -172,9 +193,11 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||||
// Every request body on the public listener is capped (the admin proxy POSTs
|
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||||
// included); the h2c server carries explicit stream/idle sizing.
|
// honeypot hit is turned away before the body cap and the mux. Every request
|
||||||
return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{
|
// 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,
|
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||||
IdleTimeout: h2cIdleTimeout,
|
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
|
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||||
// session, unknown type, internal) become Connect errors.
|
// 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}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
uid, isGuest, err := s.resolve(ctx, req.Header())
|
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = "unauthenticated"
|
result = "unauthenticated"
|
||||||
return nil, err
|
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
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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) {
|
func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
|
||||||
s.metrics.recordRateLimited(ctx, class)
|
s.metrics.recordRateLimited(ctx, class)
|
||||||
s.tracker.Add(class, key)
|
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",
|
s.log.Debug("rate limited",
|
||||||
zap.String("class", class),
|
zap.String("class", class),
|
||||||
zap.String("key", key),
|
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
|
// 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
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// 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"))
|
token := bearerToken(h.Get("Authorization"))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
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)
|
uid, isGuest, err := s.sessions.Resolve(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user