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
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:
1 parent
301edd2c9a
commit
3e1a3f92c8
8 files changed
+448
-65
No files matched your search
+106
-47
@@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user