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
171 lines
4.8 KiB
TypeScript
171 lines
4.8 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,
|
|
// 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.
|
|
|
|
export type CrowdsecStatMetric =
|
|
| "lookups"
|
|
| "blocks"
|
|
| "reports"
|
|
| "report_fail";
|
|
|
|
export type CrowdsecBreakdownKind = "category" | "reputation";
|
|
|
|
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}`;
|
|
}
|
|
|
|
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,
|
|
): 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
lookups: number;
|
|
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). 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 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.
|
|
return dates.map(blankRow);
|
|
}
|
|
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),
|
|
]);
|
|
return {
|
|
date,
|
|
lookups: toCount(lookups),
|
|
blocks: toCount(blocks),
|
|
reports: toCount(reports),
|
|
reportFailures: toCount(reportFailures),
|
|
categories: toMap(categories),
|
|
reputations: toMap(reputations),
|
|
};
|
|
}),
|
|
);
|
|
}
|