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.
112 lines
3.1 KiB
TypeScript
112 lines
3.1 KiB
TypeScript
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;
|
|
}
|