feat(security): recovery alerts, gate-block sharing, rolling-window burst and admin breakdown for CrowdSec
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m50s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s

This commit is contained in:
openhands committed 2026-09-23 15:06:16 +02:00
1 parent 301edd2c9a
commit 3e1a3f92c8
8 files changed
+448 -65

No files matched your search

+106 -47
View File
@@ -6,7 +6,8 @@ import { redis } from "@/lib/redis";
//
// 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
// push failures, and per-block breakdowns (request category / CrowdSec
// reputation) — 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.
@@ -17,6 +18,8 @@ export type CrowdsecStatMetric =
| "reports"
| "report_fail";
export type CrowdsecBreakdownKind = "category" | "reputation";
const STAT_PREFIX = "crowdsec:stat:";
const STAT_KEY_TTL_SECONDS = 16 * 24 * 3_600;
@@ -28,6 +31,10 @@ function statKey(metric: CrowdsecStatMetric, date: string): string {
return `${STAT_PREFIX}${metric}:${date}`;
}
function breakdownKey(kind: CrowdsecBreakdownKind, date: string): string {
return `${STAT_PREFIX}${kind}:${date}`;
}
/** Count one occurrence of a pipeline event for today. Best effort. */
export async function bumpCrowdsecStat(
metric: CrowdsecStatMetric,
@@ -42,6 +49,30 @@ export async function bumpCrowdsecStat(
}
}
/**
* Count one block into today's per-category / per-reputation breakdown. Stored
* as a JSON object per day and merged by the admin reader; read-modify-write
* is fine because blocks are rare and the panel is display-only.
*/
export async function bumpCrowdsecBreakdownStat(
kind: CrowdsecBreakdownKind,
value: string,
): Promise<void> {
if (!redis) return;
const key = breakdownKey(kind, statDate());
try {
const raw = await redis.get(key);
const counts: Record<string, number> = raw
? (JSON.parse(raw) as Record<string, number>)
: {};
const label = String(value).slice(0, 64);
counts[label] = (counts[label] ?? 0) + 1;
await redis.set(key, JSON.stringify(counts), "EX", STAT_KEY_TTL_SECONDS);
} catch {
// best effort — a lost breakdown entry only hides a histogram bucket
}
}
export interface CrowdsecDailyStat {
/** UTC calendar day (YYYY-MM-DD). */
date: string;
@@ -49,63 +80,91 @@ export interface CrowdsecDailyStat {
blocks: number;
reports: number;
reportFailures: number;
/** Blocks per request category (api/pages/auth/global), today-to-date. */
categories: Record<string, number>;
/** Blocks per CrowdSec reputation (only community-sourced ones). */
reputations: Record<string, number>;
}
function blankRow(date: string): CrowdsecDailyStat {
return {
date,
lookups: 0,
blocks: 0,
reports: 0,
reportFailures: 0,
categories: {},
reputations: {},
};
}
function toCount(raw: string | null): number {
const n = Number(raw ?? 0);
return Number.isFinite(n) ? n : 0;
}
function toMap(raw: string | null): Record<string, number> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object") {
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>)
.map(([k, v]) => [k, typeof v === "number" ? v : Number(v) || 0])
.filter(([, v]) => Number.isFinite(v)),
);
}
} catch {
// corrupt counter — treat as empty
}
return {};
}
/**
* 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.
* with today). Every key is fetched in one parallel burst (6 GETs per day),
* then assembled client-side; never throws.
*/
export async function getCrowdsecStats(
days = 14,
): Promise<CrowdsecDailyStat[]> {
const today = statDate();
const rows: CrowdsecDailyStat[] = [];
const dates: string[] = [];
for (let i = days - 1; i >= 0; i -= 1) {
dates.push(
new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10),
);
}
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;
return dates.map(blankRow);
}
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 store = redis;
return Promise.all(
dates.map(async (date) => {
const [
lookups,
blocks,
reports,
reportFailures,
categories,
reputations,
] = await Promise.all([
store.get(statKey("lookups", date)).catch(() => null),
store.get(statKey("blocks", date)).catch(() => null),
store.get(statKey("reports", date)).catch(() => null),
store.get(statKey("report_fail", date)).catch(() => null),
store.get(breakdownKey("category", date)).catch(() => null),
store.get(breakdownKey("reputation", date)).catch(() => null),
]);
const num = (raw: string | null): number => {
const n = Number(raw ?? 0);
return Number.isFinite(n) ? n : 0;
};
rows.push({
return {
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;
lookups: toCount(lookups),
blocks: toCount(blocks),
reports: toCount(reports),
reportFailures: toCount(reportFailures),
categories: toMap(categories),
reputations: toMap(reputations),
};
}),
);
}