ADR-0137: VayuShield Aegis — sovereign surge defense, swarm-scale classification, and self-maintaining detection
- Status: Accepted
- Date: 2026-07-16
- Deciders: BDFL / Owner
- Relates to: ADR-0111 (VayuShield core), ADR-0112 (resilience tiers), ADR-0118 (auto-block & detection-driven learning — this ADR extends and, for the admin "recent blocks list", supersedes it), ADR-0120 (shield console), ADR-0123 (kernel offload)
Context
VayuShield already fields a strong sovereign stack: a lock-free admin sovereignty lane (L0), a 256 KiB Count-Min-Sketch fair-shed pre-filter (L2), a sharded reputation brain (L5), a self-calibrating challenge ladder (L4), an HMAC-stateless proof-of-work challenge, and a per-IP TTL blocklist with an optional in-kernel offload. Yet three real gaps stop it short of "handle a 1-million-bot swarm without slowing the site, with no CDN":
-
A distributed swarm reaches the expensive path. The cheap gates (blocklist, reputation jail, rate-limit, fair-shed) all key on repeat volume from a source. A swarm of ~1M distinct IPs each sending only a handful of requests stays under every per-source budget, so each request flows through to
Classify()— a per-request TLS/HTTP fingerprint plus a SQLiteSELECTon the adaptive signature DB (internal/vayushield/vayushield.goClassify→botdb.Store.Lookup). Under a distributed swarm this is millions of fingerprint computations and DB reads competing with real readers for the same L0 public-concurrency budget. The process stays up (L0 sheds the overflow with a 503) but new human visitors are shed alongside the bots — the site "slows down" for real users. There is no cheap way to separate an unproven human from a bot before the expensive work. -
The learning loop is partly dead and slow to close.
jailBadActorcorrectly folds a blocked fingerprint intovayushield_signaturesas abad_botcandidate (ADR-0118), so the block→learn path works. ButmaybeLearn— the path meant to record a suspicious-but-not-yet-blocked fingerprint — is unreachable: it fires only whenBotScore > 0.75 AND ClientType == Unknown, while the scorer classifies anyscore >= 0.75asBadBotand only labelsUnknownfor0.4 ≤ score < 0.75. The two conditions are mutually exclusive, so that learning path never runs. And an auto-learnedbad_botcandidate only begins to influence live verdicts once its confidence reaches0.8(scorer step 2), which the+0.05/sighting upsert reaches only after ~7 recurrences — so learning is slow to act. -
The blocked-IP admin list is stale and misleading. The Bot Shield panel renders the last 50 rows of
vayushield_blocked, but the live enforcement state is the in-memory jail (blocklist + reputation), which the list does not reflect; and nothing prunes the table, so it drifts. Operators read the list as "the current block list" when it is really a historical log. The owner's directive: stop showing a per-IP list; maintain detection state efficiently in a background job and surface only live, trustworthy aggregates.
All fixes must hold the constitution: pure Go, zero CGo, zero third-party
runtime services (no CDN, no Turnstile/hCaptcha), strict CSP (script-src 'self' 'nonce-…', no unsafe-inline/unsafe-eval), single binary, one SQLite
writer, fail-open (protection never takes the site down), GDPR posture (no
plaintext IP at rest), and no measurable slowdown for legitimate traffic.
Decision
1. Sovereign Surge Mode (Aegis L3) — a stateless "under attack" front gate
Add a surge gate that runs before classification, fingerprinting or the
signature SELECT. Surge is a deliberate escalation ("challenge every unproven
visitor"), so it engages only when the shield is genuinely under attack:
- the operator's explicit "Under Attack" switch is on (auto-expiring); or
- the global attack-RPS meter is tripped (adaptive under-attack mode); or
- the L0 sovereignty lane is critically saturated (≈90% of its concurrency cap) — the site is being overwhelmed. This is the zero-configuration trigger that catches a low-and-slow, high-cardinality swarm: a million distinct IPs each sending a little fills the lane without ever spiking per-second RPS, so the RPS meter alone would miss it, but the lane occupancy will not.
It deliberately does not engage on mild L0 pressure (≈75%): that gentler signal drives the graceful L2 fair-shed, which only sheds heavy hitters and never touches a client within its fair budget, so a merely-busy site never challenges everyone. When surge is engaged, any request that is not already trusted is answered with the existing HMAC-stateless proof-of-work interstitial up front:
- Bypass (no challenge): a valid shield session cookie (proven human), a
trusted operator session, a bypassed prefix (
/os,/api, feeds, health, the challenge endpoints), or a cheap in-memory static good-bot / AI-agent User-Agent match (so Google, Bing and sanctioned AI crawlers are never challenged). All of these are O(1), allocation-free checks already present. - Challenge (cost shifts to the client): everyone else gets a signed PoW the
browser solves once in a Web Worker and echoes back for a session cookie; from
then on they ride the verified/priority lane and are never challenged,
classified, or shed again. A bot that will not run JS / solve the PoW never
obtains a session, so it never reaches fingerprinting, the signature
SELECT, template rendering or SQLite — it bounces off one HMAC issue (microseconds, zero DB, zero render, fixed memory). This is Cloudflare-style "Under Attack Mode", implemented sovereignly in-binary: 1M unsolved bots cost ~1M HMACs, not 1M renders.
Hardening (from the adversarial design review):
- Client-bound clearance. The session/clearance token is cryptographically bound to the solver's client key (its IP, folded into the token MAC, never stored — no new PII at rest). A cookie solved once by one source is therefore worthless when replayed from another, defeating "solve once, replay the cookie across the whole botnet." A roaming client whose IP changes simply re-proves.
- Real cost, not identity theatre. Surge issues the harder PoW tier (not the trivial default), since the adversary includes headless browsers that can run JS; PoW is a cost-imposition + client-binding tool, not proof of humanity.
- Never breaks feeds or crawlers. Feed/sitemap/robots endpoints are bypassed
by shape (any
*.xml/.atom/.rss, a/feedor/rsssegment), so JS-less legitimate machine clients are never handed an unsolvable challenge. The 503 carriesRetry-After, so a crawler challenged during a genuine attack treats it as transient (no deindex). - No stuck switch. Auto-engage (RPS flood) relaxes when the flood passes; an operator-forced surge auto-expires after a bounded window (12h) so a forgotten toggle can never challenge visitors indefinitely.
- Calibration isolation. Surge challenges every unproven visitor regardless of score, so they are excluded from the L4 pass-rate calibrator — they cannot skew the score-based ladder's self-tuning.
Surge is automatic (engages on a detected flood, disengages when it clears) or operator-forced. It is off when there is no attack, so normal traffic sees exactly today's behaviour. Fail-open throughout: a nil signer or an issue error serves the request rather than an empty response.
2. Swarm-scale classification — an in-memory verdict cache (L6)
Front the adaptive-DB Lookup with a fixed-size, sharded, lock-cheap
fingerprint→classification cache so steady-state classification does not hit
SQLite per request. Fixed memory (hard-capped entries, evicted by age), so a
1M-distinct-fingerprint swarm cannot grow it unbounded; a miss falls through to
the existing read-pool SELECT exactly as today. This removes the per-request
DB read that a distributed swarm would otherwise multiply.
3. Close the learning loop
- Fix
maybeLearnto record a genuinely suspicious fingerprint (a high-but-sub-block heuristic score, or a headless/transport-contradiction tell) as an auto-learned candidate — so the knowledge base grows from near-misses, not only from hard blocks. - Faster, safe auto-promotion: a recurring auto-learned
bad_botcandidate reaches an actionable confidence sooner (tunable), guarded by the existing false-positive counter so a mis-fingerprinted real client decays back out. The scorer already acts onbad_botat confidence ≥ 0.8; promotion makes the loop close on real repeat offenders in a few sightings instead of many.
4. Self-maintaining detection — background job, not a stale list
- A single background maintenance job (one goroutine, ticking on a slow
interval, shutdown-aware) prunes stale
vayushield_signaturescandidates (PurgeStale), ages out the historicalvayushield_blockedlog, and computes the live aggregates the panel shows. The live blocklist/reputation jails are already self-expiring in memory and need no sweeper. - Remove the per-IP "recent blocks" list from the admin UI. Replace it with trustworthy live aggregates (currently-jailed count, sources under suspicion, fair-shed/blocked totals, signatures learned) plus a clear "detection is maintained automatically in the background" statement. No per-row IP/UA table.
Consequences
- A 1M-bot swarm is absorbed at O(1)/request with no CDN. Unproven traffic under surge is bounced by a stateless HMAC challenge before any expensive work; proven humans solve once and are never bothered again. The site stays fast for real readers during an attack, not just alive.
- Faster steady state. The verdict cache removes a per-request SQLite read from the classification path; the surge gate removes fingerprinting entirely for unproven traffic during an attack.
- Learning actually learns and acts. Suspicious near-misses are recorded and recurring offenders auto-promote to an actionable signature quickly, with the false-positive guard intact.
- Honest, self-maintaining UI. The panel shows live, background-maintained aggregates instead of a stale historical IP list; the detection tables prune themselves; memory stays bounded under any flood.
- Constitution held. Pure Go, zero third-party/CDN, strict CSP (the
interstitial's solver is the same nonce-free same-origin
/__vayushield/ challenge.js), single binary, fail-open, GDPR posture unchanged (no new plaintext IP at rest; the verdict cache keys on fingerprint hashes, never IPs). - Supersedes the ADR-0118 "operator-visible recent-blocks list" UI decision; the auto-block + detection-driven-learning engine it introduced is kept and extended.