feat(security): ops alerts, shared backoff, atomic quota and daily stats for CrowdSec
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m36s
CI / tests-unit (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m36s
CI / tests-unit (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
Add an alerting/stats layer over the existing CrowdSec integration: - New crowdsec-alerts.ts: cooldown-gated ops alerts (Redis NX lock, TTL from HEALTH_ALERT_COOLDOWN_MIN) fanning out through the app's sendAlert service. Raised for daily quota exhaustion, block bursts (5-min window past CROWDSEC_ALERT_BLOCK_BURST), and signal-push failures. - New crowdsec-stats.ts: daily counters (lookups/blocks/reports/report_fail) in Redis with a 14-day reader for the admin panel. - Shared 403/429 backoff: the pause marker now lives in Redis (crowdsec:backoff-until) so every instance honours it, not just the process that hit the limit. - Atomic quota reservation: INCR-before-call with self-rollback on overshoot, so concurrent instances can never slip calls past the daily ceiling. - Admin anti-DDoS page gains a last-14-days activity table next to the quota bar.
This commit is contained in:
1 parent
5e4fc9ab59
commit
301edd2c9a
9 files changed
+620
-28
No files matched your search
+133
-25
@@ -1,7 +1,9 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { raiseCrowdsecAlert } from "@/lib/crowdsec-alerts";
|
||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||
import { bumpCrowdsecStat } from "@/lib/crowdsec-stats";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { UNKNOWN_CLIENT_IP } from "./client-ip";
|
||||
@@ -113,6 +115,11 @@ const LAST_VERIFY_KEY = "crowdsec:last-verify";
|
||||
const BLOCK_META_PREFIX = "antiddos:block:meta:";
|
||||
const QUOTA_PREFIX = "crowdsec:usage:";
|
||||
const QUOTA_KEY_TTL_SECONDS = 48 * 3_600;
|
||||
/** Shared 403/429 pause marker, so every instance respects the backoff. */
|
||||
const BACKOFF_KEY = "crowdsec:backoff-until";
|
||||
/** Short-window block burst counter: crowdsec:burst:{unix-5min-bucket}. */
|
||||
const BURST_PREFIX = "crowdsec:burst:";
|
||||
const BURST_WINDOW_SECONDS = 300;
|
||||
/** In-process verdict cache cap so a flood of distinct IPs cannot grow it forever. */
|
||||
const MEMORY_VERDICT_CACHE_MAX = 2_000;
|
||||
/** Warn at this fraction of the daily quota, once per day. */
|
||||
@@ -313,6 +320,47 @@ async function acquireLookupLock(ip: string): Promise<boolean> {
|
||||
let backoffUntil = 0;
|
||||
let quotaWarnedDate: string | null = null;
|
||||
|
||||
/**
|
||||
* Next moment (epoch ms) the CTI API may be called again — the max of the
|
||||
* in-process view and the shared Redis marker so every instance respects a
|
||||
* backoff discovered by any of them. Redis is only read when the local view is
|
||||
* not already active, keeping the hot path cheap.
|
||||
*/
|
||||
async function getBackoffUntil(): Promise<number> {
|
||||
if (Date.now() < backoffUntil) return backoffUntil;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(BACKOFF_KEY);
|
||||
const shared = Number(raw ?? 0);
|
||||
if (Number.isFinite(shared) && shared > backoffUntil) {
|
||||
backoffUntil = shared;
|
||||
}
|
||||
} catch {
|
||||
// Redis hiccup — local view is enough
|
||||
}
|
||||
}
|
||||
return backoffUntil;
|
||||
}
|
||||
|
||||
async function setBackoff(ms: number): Promise<void> {
|
||||
const until = Date.now() + ms;
|
||||
backoffUntil = until;
|
||||
if (redis) {
|
||||
try {
|
||||
// EX rounds up so the marker outlives the wait it encodes, plus a
|
||||
// second of slack for the read path.
|
||||
await redis.set(
|
||||
BACKOFF_KEY,
|
||||
String(until),
|
||||
"EX",
|
||||
Math.ceil(ms / 1000) + 1,
|
||||
);
|
||||
} catch {
|
||||
// local view still protects this instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function quotaDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -350,33 +398,87 @@ export async function getCrowdsecQuotaUsage(): Promise<CrowdsecQuotaUsage> {
|
||||
return { date, used, quota, exhausted: quota > 0 && used >= quota };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve one API call against today's quota. Atomic: the counter is INCR'd
|
||||
* BEFORE the call and compared to the ceiling, so concurrent instances can
|
||||
* never slip calls past the budget; a reserve that overshoots rolls itself
|
||||
* back. Returns false once the budget is spent (and raises an ops alert).
|
||||
*/
|
||||
async function reserveQuota(): Promise<boolean> {
|
||||
const quota = dailyQuota();
|
||||
if (quota <= 0) return true;
|
||||
if (!redis) return true; // no shared counter → unlimited best-effort
|
||||
const date = quotaDate();
|
||||
const usage = await getCrowdsecQuotaUsage();
|
||||
if (usage.used >= quota) {
|
||||
// Stop consulting the API for the rest of the day: a flood of distinct
|
||||
// bucket-tripping IPs would otherwise burn every remaining call and
|
||||
// then sit in a 429 storm anyway.
|
||||
return false;
|
||||
}
|
||||
if (usage.used >= quota * QUOTA_WARN_RATIO && quotaWarnedDate !== date) {
|
||||
quotaWarnedDate = date;
|
||||
logger.warn("[crowdsec-api] CTI daily quota nearing its limit", {
|
||||
used: usage.used,
|
||||
quota,
|
||||
});
|
||||
}
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.incr(quotaKey(date));
|
||||
await redis.expire(quotaKey(date), QUOTA_KEY_TTL_SECONDS);
|
||||
} catch {
|
||||
// best effort — an uncounted call is better than a failed lookup
|
||||
const key = quotaKey(date);
|
||||
try {
|
||||
const used = await redis.incr(key);
|
||||
await redis.expire(key, QUOTA_KEY_TTL_SECONDS);
|
||||
if (used > quota) {
|
||||
// Concurrent reserves nudged us past the ceiling — give the slot
|
||||
// back and refuse: the budget would be spent the very next call
|
||||
// anyway, so stopping here is both safe and quota-exact.
|
||||
await redis.decr(key);
|
||||
logger.warn(
|
||||
"[crowdsec-api] CTI daily quota exhausted — pausing lookups until tomorrow",
|
||||
{ quota },
|
||||
);
|
||||
void raiseCrowdsecAlert("quota", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message: `CrowdSec reputation quota exhausted for today (${used} of ${quota} enrichment calls) — lookups are paused until tomorrow.`,
|
||||
context: { used, quota, date },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (used >= quota * QUOTA_WARN_RATIO && quotaWarnedDate !== date) {
|
||||
quotaWarnedDate = date;
|
||||
logger.warn("[crowdsec-api] CTI daily quota nearing its limit", {
|
||||
used,
|
||||
quota,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Redis hiccup at a moment we could not count — allow the call rather
|
||||
// than break the gate; the verdict cache still limits frequency.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Block burst threshold from env, defensively coerced (falls back to 10). */
|
||||
function dailyBlockBurstThreshold(): number {
|
||||
const raw = Number(env.CROWDSEC_ALERT_BLOCK_BURST ?? 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* A burst of new community-reputation blocks is usually an automated attack
|
||||
* wave. Count blocks into a rolling 5-minute bucket and alert once per
|
||||
* cooldown window when they cross CROWDSEC_ALERT_BLOCK_BURST. Fire-and-forget.
|
||||
*/
|
||||
async function trackBlockBurst(): Promise<void> {
|
||||
if (!redis) return;
|
||||
const bucket = Math.floor(Date.now() / 1000 / BURST_WINDOW_SECONDS);
|
||||
const key = `${BURST_PREFIX}${bucket}`;
|
||||
const threshold = dailyBlockBurstThreshold();
|
||||
try {
|
||||
const count = await redis.incr(key);
|
||||
await redis.expire(key, BURST_WINDOW_SECONDS * 2);
|
||||
if (count >= threshold) {
|
||||
void raiseCrowdsecAlert("block-burst", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message: `CrowdSec community reputation blocked ${count} IPs in the last ${BURST_WINDOW_SECONDS / 60} minutes — likely an automated attack wave.`,
|
||||
context: {
|
||||
blocks: count,
|
||||
windowSeconds: BURST_WINDOW_SECONDS,
|
||||
threshold,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// alert is best-effort — never break the block path
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,7 +492,7 @@ export async function lookupCrowdsecVerdict(
|
||||
): Promise<CrowdsecVerdict | null> {
|
||||
if (!crowdsecEnabled()) return null;
|
||||
if (!ip || ip === UNKNOWN_CLIENT_IP) return null;
|
||||
if (Date.now() < backoffUntil) return null;
|
||||
if (Date.now() < (await getBackoffUntil())) return null;
|
||||
|
||||
const cached = await readVerdictCache(ip);
|
||||
if (cached) return cached;
|
||||
@@ -417,6 +519,9 @@ export async function lookupCrowdsecVerdict(
|
||||
}
|
||||
|
||||
const response = await crowdsecRequest(`/smoke/${encodeURIComponent(ip)}`);
|
||||
// The enrichment call happened — count it for the daily histogram,
|
||||
// regardless of whether the verdict was positive, negative, or n/a.
|
||||
void bumpCrowdsecStat("lookups");
|
||||
|
||||
if (response.status === 404) {
|
||||
// Unknown to the community — cache the negative result so a clean
|
||||
@@ -426,13 +531,13 @@ export async function lookupCrowdsecVerdict(
|
||||
return verdict;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
backoffUntil = Date.now() + AUTH_BACKOFF_MS;
|
||||
await setBackoff(AUTH_BACKOFF_MS);
|
||||
throw new CrowdsecApiError(
|
||||
`CrowdSec API key rejected (HTTP 403): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
backoffUntil = Date.now() + RATE_LIMIT_BACKOFF_MS;
|
||||
await setBackoff(RATE_LIMIT_BACKOFF_MS);
|
||||
logger.warn("[crowdsec-api] CTI API rate limit hit — backing off", {
|
||||
ip,
|
||||
backoffMs: RATE_LIMIT_BACKOFF_MS,
|
||||
@@ -477,7 +582,7 @@ export async function maybeAutoBlockCrowdsec(input: {
|
||||
// The gate only ever reads its block key through shared Redis — without it
|
||||
// there is nowhere durable to record the block.
|
||||
if (!redis) return;
|
||||
if (Date.now() < backoffUntil) return;
|
||||
if (Date.now() < (await getBackoffUntil())) return;
|
||||
|
||||
try {
|
||||
const verdict = await lookupCrowdsecVerdict(ip);
|
||||
@@ -528,6 +633,9 @@ export async function maybeAutoBlockCrowdsec(input: {
|
||||
// Opt-in community signal push (CAPI), fire-and-forget: never awaited,
|
||||
// never throws, and internally deduped per IP.
|
||||
void reportCrowdsecSignal({ ip, category, ttlSeconds, verdict, meta });
|
||||
// Daily histogram + burst detection (cooldown-gated ops alert).
|
||||
void bumpCrowdsecStat("blocks");
|
||||
void trackBlockBurst();
|
||||
} catch (error) {
|
||||
logger.error("[crowdsec-api] Automatic IP block failed", {
|
||||
ip,
|
||||
|
||||
Reference in new issue
Block a user