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

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:
openhands committed 2026-09-23 14:45:35 +02:00
1 parent 5e4fc9ab59
commit 301edd2c9a
9 files changed
+620 -28

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
import "server-only";
import { redis } from "@/lib/redis";
// === CrowdSec daily counters ===============================================
//
// Small Redis counters so ops can see whether the reputation pipeline is
// actually doing anything: lookups executed, blocks created, signals pushed,
// and push failures — all bucketed per UTC calendar day
// (crowdsec:stat:{metric}:{YYYY-MM-DD}). Both the CTI client and the signal
// pusher feed them; the admin panel renders the last N days. Cheap INCRs on
// non-hot paths only, so they never tax the request path.
export type CrowdsecStatMetric =
| "lookups"
| "blocks"
| "reports"
| "report_fail";
const STAT_PREFIX = "crowdsec:stat:";
const STAT_KEY_TTL_SECONDS = 16 * 24 * 3_600;
function statDate(): string {
return new Date().toISOString().slice(0, 10);
}
function statKey(metric: CrowdsecStatMetric, date: string): string {
return `${STAT_PREFIX}${metric}:${date}`;
}
/** Count one occurrence of a pipeline event for today. Best effort. */
export async function bumpCrowdsecStat(
metric: CrowdsecStatMetric,
): Promise<void> {
if (!redis) return;
const key = statKey(metric, statDate());
try {
await redis.incr(key);
await redis.expire(key, STAT_KEY_TTL_SECONDS);
} catch {
// tracking is best-effort — a miss only loses a day's histogram
}
}
export interface CrowdsecDailyStat {
/** UTC calendar day (YYYY-MM-DD). */
date: string;
lookups: number;
blocks: number;
reports: number;
reportFailures: number;
}
/**
* Read the per-day counters for the last `days` days (oldest first, ending
* with today). Reads the four metric keys per day via one round-trip of
* parallel GETs; never throws.
*/
export async function getCrowdsecStats(
days = 14,
): Promise<CrowdsecDailyStat[]> {
const today = statDate();
const rows: CrowdsecDailyStat[] = [];
if (!redis) {
// No shared store — still return a blank timeline for the UI.
for (let i = days - 1; i >= 0; i -= 1) {
const date = new Date(Date.now() - i * 86_400_000)
.toISOString()
.slice(0, 10);
rows.push({ date, lookups: 0, blocks: 0, reports: 0, reportFailures: 0 });
}
return rows;
}
for (let i = days - 1; i >= 0; i -= 1) {
const date = new Date(Date.now() - i * 86_400_000)
.toISOString()
.slice(0, 10);
try {
const [lookups, blocks, reports, reportFailures] = await Promise.all([
redis.get(statKey("lookups", date)),
redis.get(statKey("blocks", date)),
redis.get(statKey("reports", date)),
redis.get(statKey("report_fail", date)),
]);
const num = (raw: string | null): number => {
const n = Number(raw ?? 0);
return Number.isFinite(n) ? n : 0;
};
rows.push({
date,
lookups: num(lookups),
blocks: num(blocks),
reports: num(reports),
reportFailures: num(reportFailures),
});
} catch {
rows.push({ date, lookups: 0, blocks: 0, reports: 0, reportFailures: 0 });
}
}
// Guard against an odd clock roll-back leaving a hole; keep chronological.
if (rows.length && rows[rows.length - 1]?.date !== today) {
rows.push({
date: today,
lookups: 0,
blocks: 0,
reports: 0,
reportFailures: 0,
});
}
return rows;
}