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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user