Files
EpicNext-Cms/src/app/admin/devops/antiddos/page.tsx
T
openhands 3e1a3f92c8
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
feat(security): recovery alerts, gate-block sharing, rolling-window burst and admin breakdown for CrowdSec
2026-09-23 15:06:16 +02:00

909 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadgeCheck,
Cloud,
Lock,
Radar,
Server,
ShieldAlert,
} from "lucide-react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import {
removeCloudflareRule,
resetAntiddosSettings,
saveAntiddosSettings,
unbanAntiddosIp,
verifyCloudflareConfiguration,
verifyCrowdsecConfiguration,
verifyCrowdsecReportingConfiguration,
} from "@/actions/admin-antiddos";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
antiddosDefaultsFromEnv,
getAntiddosConfig,
} from "@/lib/antiddos-config";
import { resolveClientIp } from "@/lib/client-ip";
import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare";
import {
type CloudflareBlockView,
cloudflareEnabled,
getLastCloudflareVerify,
listCloudflareBlocks,
sweepExpiredCloudflareBlocks,
} from "@/lib/cloudflare-api";
import {
CROWDSEC_BLOCK_SOURCE,
type CrowdsecBlockMeta,
crowdsecEnabled,
getCrowdsecBlockMeta,
getCrowdsecQuotaUsage,
getLastCrowdsecVerify,
} from "@/lib/crowdsec-api";
import {
crowdsecReportEnabled,
getLastCrowdsecReport,
} from "@/lib/crowdsec-report";
import { type CrowdsecDailyStat, getCrowdsecStats } from "@/lib/crowdsec-stats";
import { db, WebsiteSetting } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redis } from "@/lib/redis";
function seconds(ttlMs: number): string {
const s = Math.floor(ttlMs / 1000);
if (s <= 0) return "–";
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
function BarSparkline({ values }: { values: number[] }) {
if (values.length === 0) return null;
const max = Math.max(...values, 1);
return (
<div className="flex items-end gap-[3px] h-10" aria-hidden="true">
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static timeline position is the bar's identity
key={i}
className="w-full rounded-sm bg-primary/60"
style={{
height: `${Math.max(v > 0 ? 6 : 2, (v / max) * 100)}%`,
opacity: v === 0 ? 0.15 : 0.6 + (v / max) * 0.4,
}}
/>
))}
</div>
);
}
/** Merge per-day breakdown maps (categories / reputations) into range totals. */
function mergeBreakdowns(
rows: CrowdsecDailyStat[],
kind: keyof Pick<CrowdsecDailyStat, "categories" | "reputations">,
): Record<string, number> {
const totals: Record<string, number> = {};
for (const row of rows) {
for (const [k, v] of Object.entries(row[kind])) {
totals[k] = (totals[k] ?? 0) + v;
}
}
return totals;
}
export default async function AdminAntiDdosPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const [effective, defaults, requestHeaders, persistedRows] =
await Promise.all([
getAntiddosConfig(),
antiddosDefaultsFromEnv(),
headers(),
db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting)
.then((rows) => new Map(rows.map((r) => [r.key, r.value])))
.catch(() => new Map() as Map<string, string>),
]);
const cloudflare = isCloudflareProxied(requestHeaders);
const sourceHeader = preferredClientIpHeader(requestHeaders);
const viewerIp = resolveClientIp(requestHeaders);
let blocks: {
ip: string;
ttlMs: number;
count: number;
source: "gate" | "crowdsec";
meta: CrowdsecBlockMeta | null;
}[] = [];
let redisOk = false;
const rateStore = redis;
if (rateStore) {
redisOk = true;
try {
const blockKeys = await rateStore.keys("antiddos:block:*");
const violationKeys = await rateStore.keys("antiddos:v:*");
const violationCounts = new Map<string, number>();
for (const key of violationKeys.slice(0, 200)) {
// incr is used on writes; for display we just read the raw value.
const raw = await rateStore.get(key);
const n = Number(raw);
violationCounts.set(
key.replace(`antiddos:v:`, ""),
Number.isFinite(n) ? n : 0,
);
}
const withTtl = await Promise.all(
blockKeys.slice(0, 100).map(async (key) => {
const [ttlMs, value] = await Promise.all([
rateStore.pttl(key),
rateStore.get(key),
]);
const ip = key.replace("antiddos:block:", "");
const source =
value === CROWDSEC_BLOCK_SOURCE
? ("crowdsec" as const)
: ("gate" as const);
return {
ip,
ttlMs: ttlMs > 0 ? ttlMs : 0,
count: violationCounts.get(ip) ?? 0,
// The gate writes "1"; "crowdsec" marks a community-reputation block.
source,
// Why CrowdSec blocked this IP, when the meta was recorded.
meta: source === "crowdsec" ? await getCrowdsecBlockMeta(ip) : null,
};
}),
);
blocks = withTtl
.filter((b) => b.ttlMs > 0)
.sort((a, b) => a.ttlMs - b.ttlMs);
} catch {
redisOk = false;
}
}
const stored = persistedRows;
const cloudflareConfigured = cloudflareEnabled();
const cloudflareBlocks: CloudflareBlockView[] = [];
if (cloudflareConfigured) {
await sweepExpiredCloudflareBlocks();
cloudflareBlocks.push(...(await listCloudflareBlocks()));
}
const lastVerify = await getLastCloudflareVerify();
const crowdsecConfigured = crowdsecEnabled();
const lastCrowdsecVerify = await getLastCrowdsecVerify();
const crowdsecUsage = redisOk ? await getCrowdsecQuotaUsage() : null;
const reportingEnabled = await crowdsecReportEnabled();
const lastReport = await getLastCrowdsecReport();
const crowdsecStats = redisOk ? await getCrowdsecStats(14) : [];
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Gate</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant={effective.enabled ? "default" : "secondary"}>
{effective.enabled ? "Enabled" : "Disabled"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Boot default: {defaults.enabled ? "on" : "off"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rates</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{effective.api.limit}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/min API
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Pages {effective.pages.limit} · Auth {effective.auth.limit} ·
Global {effective.global.limit}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cloudflare</CardTitle>
<Cloud className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant={cloudflare ? "default" : "secondary"}>
{cloudflare ? "Detected" : "Not detected"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
IP source: {sourceHeader}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CrowdSec</CardTitle>
<Radar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
{crowdsecConfigured ? "Connected" : "Not configured"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
{crowdsecUsage && crowdsecUsage.quota > 0
? `${crowdsecUsage.used.toLocaleString()} / ${crowdsecUsage.quota.toLocaleString()} CTI calls today${crowdsecUsage.exhausted ? " (paused)" : ""}`
: "Community reputation auto-block"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
<Lock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div
className="text-2xl font-bold"
style={{
color: blocks.length ? "var(--destructive)" : undefined,
}}
>
{blocks.length}
</div>
<p className="text-xs text-muted-foreground mt-1">
Temporary DDoS blocks
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Redis</CardTitle>
<BadgeCheck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant={redisOk ? "default" : "destructive"}>
{redisOk ? "Connected" : "Unavailable"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Shared rate-limit state
</p>
</CardContent>
</Card>
</div>
{!cloudflare && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cloud className="h-4 w-4" /> Cloudflare not detected
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
This request did not arrive through Cloudflare. When the site DNS
is proxied (orange cloud), the CMS trusts the real visitor IP from{" "}
<span className="font-mono">CF-Connecting-IP</span>. A direct
client can spoof that header — put the origin behind Cloudflare
and restrict direct access to the origin ports for full DDoS
protection.
</p>
<p className="text-sm text-muted-foreground mt-2">
Your request is keyed as{" "}
<span className="font-mono">{viewerIp}</span> (via{" "}
<span className="font-mono">{sourceHeader}</span>).
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4" /> Anti-DDoS settings
</CardTitle>
</CardHeader>
<CardContent>
<form action={saveAntiddosSettings} className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="enabled"
value="1"
defaultChecked={effective.enabled}
/>
Enable the app-layer anti-DDoS gate (production only)
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="cfa_auto_block"
value="1"
defaultChecked={effective.cloudflareAutoBlock}
/>
Automatically create Cloudflare edge blocks when an IP hits the
block threshold
</label>
<p className="text-xs text-muted-foreground -mt-2">
Requires <span className="font-mono">CLOUDFLARE_API_TOKEN</span>{" "}
and <span className="font-mono">CLOUDFLARE_ZONE_ID</span> in the
environment. Blocks are only created for traffic that provably
transits Cloudflare, and expire together with the host-level
block.
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="cs_auto_block"
value="1"
defaultChecked={effective.crowdsecAutoBlock}
/>
Automatically block IPs flagged as malicious by the CrowdSec
community
</label>
<p className="text-xs text-muted-foreground -mt-2">
Requires <span className="font-mono">CROWDSEC_API_KEY</span> in
the environment. When a repeat offender has a bad community
reputation it is hard-blocked immediately (no need to cross the
local violation threshold). IPs carrying CrowdSec false-positive
tags are never blocked.
</p>
<div className="flex flex-wrap items-center gap-4">
<label className="block">
<span className="text-xs font-medium">
Minimum reputation score (0–5)
</span>
<input
name="cs_block_score"
type="number"
min={0}
max={5}
defaultValue={effective.crowdsecBlockScore}
className="w-24 mt-1"
/>
<span className="text-xs text-muted-foreground ml-2">
4–5 = malicious (CrowdSec scale)
</span>
</label>
<label className="block">
<span className="text-xs font-medium">
Block duration (sec)
</span>
<input
name="cs_block_ttl_sec"
type="number"
defaultValue={effective.crowdsecBlockTtlSeconds}
className="w-32 mt-1"
/>
</label>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{(
[
["pages", "Pages", "pages_limit", "pages_window_sec"],
["api", "API", "api_limit", "api_window_sec"],
["auth", "Auth", "auth_limit", "auth_window_sec"],
] as const
).map(([category, label, limitName, windowName]) => (
<div key={category} className="rounded-md border p-3">
<p className="font-semibold text-sm mb-2">{label}</p>
<div className="flex items-center gap-2">
<input
name={limitName}
type="number"
defaultValue={effective[category].limit}
className="w-24"
/>
<span className="text-xs text-muted-foreground">
req/min
</span>
<input
name={windowName}
type="number"
defaultValue={effective[category].windowSeconds}
className="w-20"
/>
<span className="text-xs text-muted-foreground">
sec window
</span>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium">
Global valve (req/min)
</span>
<div className="flex items-center gap-2 mt-1">
<input
name="global_limit"
type="number"
defaultValue={effective.global.limit}
className="w-24"
/>
<input
name="global_window_sec"
type="number"
defaultValue={effective.global.windowSeconds}
className="w-20"
/>
<span className="text-xs text-muted-foreground">
sec window
</span>
</div>
</label>
<label className="block">
<span className="text-xs font-medium">
Global halt short-circuit (ms)
</span>
<input
name="global_halt_ms"
type="number"
defaultValue={effective.globalHaltMs}
className="w-32 mt-1"
/>
</label>
<label className="block">
<span className="text-xs font-medium">
Violation window (sec)
</span>
<input
name="violation_window_sec"
type="number"
defaultValue={effective.violationWindowSeconds}
className="w-32 mt-1"
/>
</label>
<label className="block">
<span className="text-xs font-medium">
Violations before block
</span>
<input
name="max_violations"
type="number"
defaultValue={effective.maxViolations}
className="w-32 mt-1"
/>
</label>
</div>
<div>
<label className="block">
<span className="text-xs font-medium">
Escalation tiers (min:ttlSeconds, comma separated)
</span>
<input
name="block_tiers"
defaultValue={effective.blockTiers
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
.join(",")}
className="w-full mt-1 font-mono"
/>
</label>
<p className="text-xs text-muted-foreground mt-1">
Boot default:{" "}
{defaults.blockTiers
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
.join(", ")}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" variant="default">
Save settings
</Button>
</div>
</form>
<form action={resetAntiddosSettings} className="mt-4">
<Button type="submit" variant="outline">
Reset to boot defaults (env)
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-4 w-4" /> Blocked IPs ({blocks.length})
</CardTitle>
</CardHeader>
<CardContent>
{blocks.length === 0 ? (
<p className="text-sm text-muted-foreground">
No IPs are currently rate-limited into a temporary block.
</p>
) : (
<div className="space-y-2">
{blocks.map((b) => {
const behaviorLabel =
b.meta && b.meta.behaviors.length > 0
? b.meta.behaviors.join(", ")
: null;
return (
<div
key={b.ip}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-2 text-sm"
>
<span className="font-mono">{b.ip}</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{b.source === "crowdsec" ? (
<Badge variant="default">CrowdSec</Badge>
) : (
<Badge variant="secondary">Gate</Badge>
)}
TTL {seconds(b.ttlMs)} · violations {b.count}
{b.meta && (
<span
className="max-w-xs truncate"
title={`${b.meta.reputation ?? "unknown"} · score ${b.meta.score} · ${b.meta.category}${behaviorLabel ? ` · ${behaviorLabel}` : ""}`}
>
{b.meta.reputation ?? "unknown"} · score{" "}
{b.meta.score} · {b.meta.category}
{behaviorLabel ? ` · ${behaviorLabel}` : ""}
</span>
)}
</span>
<form action={unbanAntiddosIp}>
<input type="hidden" name="ip" value={b.ip} />
<Button type="submit" size="sm" variant="outline">
Unban
</Button>
</form>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cloud className="h-4 w-4" /> Cloudflare edge blocks
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={cloudflareConfigured ? "default" : "secondary"}>
{cloudflareConfigured ? "API configured" : "API not configured"}
</Badge>
{!cloudflareConfigured && (
<p className="text-xs text-muted-foreground">
Set <span className="font-mono">CLOUDFLARE_API_TOKEN</span> and{" "}
<span className="font-mono">CLOUDFLARE_ZONE_ID</span> to enable
automatic edge blocking via the Cloudflare API.
</p>
)}
<form action={verifyCloudflareConfiguration}>
<Button
type="submit"
size="sm"
variant="outline"
disabled={!cloudflareConfigured}
>
Verify connection
</Button>
</form>
</div>
{lastVerify && cloudflareConfigured && (
<p className="text-xs">
<Badge variant={lastVerify.ok ? "default" : "destructive"}>
{lastVerify.ok ? "Reachable" : "Failed"}
</Badge>
<span className="ml-2 text-muted-foreground">
{lastVerify.ok
? `Zone ${lastVerify.zoneName ?? lastVerify.zoneId ?? ""} — verified ${new Date(lastVerify.at).toLocaleString()}`
: lastVerify.message}
</span>
</p>
)}
{cloudflareConfigured && cloudflareBlocks.length === 0 ? (
<p className="text-sm text-muted-foreground">
No automatic Cloudflare blocks are active. When the gate blocks a
repeat offender behind Cloudflare, an IP Access Rule is created
here automatically.
</p>
) : (
cloudflareConfigured && (
<div className="space-y-2">
{cloudflareBlocks.map((b) => (
<div
key={b.ip}
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
>
<span className="font-mono">{b.ip}</span>
<span className="text-xs text-muted-foreground">
{b.category} · {seconds(b.remainingSeconds * 1000)} left
</span>
<form action={removeCloudflareRule}>
<input type="hidden" name="ip" value={b.ip} />
<Button type="submit" size="sm" variant="outline">
Remove rule
</Button>
</form>
</div>
))}
<p className="text-xs text-muted-foreground">
Expired rules are swept automatically every 30s.
</p>
</div>
)
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" /> CrowdSec reputation API
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
{crowdsecConfigured ? "API configured" : "API not configured"}
</Badge>
{!crowdsecConfigured && (
<p className="text-xs text-muted-foreground">
Set <span className="font-mono">CROWDSEC_API_KEY</span> to
enable community-reputation auto-blocks. When a repeat offender
is flagged as malicious by the CrowdSec community it is
hard-blocked immediately without waiting for the local violation
threshold.
</p>
)}
<form action={verifyCrowdsecConfiguration}>
<Button
type="submit"
size="sm"
variant="outline"
disabled={!crowdsecConfigured}
>
Verify connection
</Button>
</form>
</div>
{lastCrowdsecVerify && crowdsecConfigured && (
<p className="text-xs">
<Badge
variant={lastCrowdsecVerify.ok ? "default" : "destructive"}
>
{lastCrowdsecVerify.ok ? "Reachable" : "Failed"}
</Badge>
<span className="ml-2 text-muted-foreground">
{lastCrowdsecVerify.ok
? `CTI endpoint verified ${new Date(lastCrowdsecVerify.at).toLocaleString()}`
: lastCrowdsecVerify.message}
</span>
</p>
)}
{crowdsecConfigured && (
<p className="text-sm text-muted-foreground">
Verdicts are looked up lazily for IPs that already triggered a
rate bucket (never on the per-request hot path), cached for an
hour, and blocked IPs show a{" "}
<Badge variant="default">CrowdSec</Badge> badge in the list above
with the community reasoning (reputation, score, behaviors).
</p>
)}
{crowdsecUsage && (
<div className="rounded-md border p-3">
<p className="text-xs font-medium mb-1">
Reputation lookups today
</p>
{crowdsecUsage.quota > 0 ? (
<>
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(100, (crowdsecUsage.used / crowdsecUsage.quota) * 100)}%`,
background: crowdsecUsage.exhausted
? "var(--color-destructive)"
: crowdsecUsage.used >= crowdsecUsage.quota * 0.8
? "var(--admin-accent)"
: "var(--color-primary)",
}}
/>
</div>
<span
className={`text-xs ${crowdsecUsage.exhausted ? "text-destructive" : "text-muted-foreground"}`}
>
{crowdsecUsage.used.toLocaleString()} /{" "}
{crowdsecUsage.quota.toLocaleString()}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{crowdsecUsage.exhausted
? "Quota spent for today — reputation lookups are paused until tomorrow (admin via CROWDSEC_CTI_DAILY_QUOTA)."
: "Visible in the env via CROWDSEC_CTI_DAILY_QUOTA (0 = unlimited). Lookups pause at the ceiling to protect the plan."}
</p>
</>
) : (
<p className="text-xs text-muted-foreground">
Tracking disabled (CROWDSEC_CTI_DAILY_QUOTA = 0 / unlimited).
</p>
)}
</div>
)}
{crowdsecStats.length > 0 && (
<div className="rounded-md border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium">
Daily activity (last {crowdsecStats.length} days)
</p>
<a
href="/admin/alerts"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Ops alert history →
</a>
</div>
<div className="mt-2 grid gap-4 sm:grid-cols-3">
<BarSparkline values={crowdsecStats.map((row) => row.blocks)} />
<div className="col-span-2 flex flex-wrap items-center gap-1.5">
{Object.entries(
mergeBreakdowns(crowdsecStats, "categories"),
).map(([category, count]) => (
<Badge key={category} variant="secondary">
{category} · {count.toLocaleString()}
</Badge>
))}
{Object.entries(
mergeBreakdowns(crowdsecStats, "reputations"),
).map(([reputation, count]) => (
<Badge
key={reputation}
variant={
reputation === "malicious" ? "destructive" : "secondary"
}
>
{reputation} · {count.toLocaleString()}
</Badge>
))}
</div>
</div>
<div className="mt-3 max-h-40 overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground">
<th className="pb-1 pr-2 font-medium">Date</th>
<th className="pb-1 pr-2 font-medium text-right">
Lookups
</th>
<th className="pb-1 pr-2 font-medium text-right">
Blocks
</th>
<th className="pb-1 pr-2 font-medium text-right">
Reports
</th>
<th className="pb-1 font-medium text-right">Failures</th>
</tr>
</thead>
<tbody>
{crowdsecStats.map((row) => (
<tr key={row.date} className="border-t">
<td className="py-1 pr-2 text-muted-foreground">
{row.date === new Date().toISOString().slice(0, 10)
? "Today"
: row.date.slice(5)}
</td>
<td className="py-1 pr-2 text-right">
{row.lookups.toLocaleString()}
</td>
<td className="py-1 pr-2 text-right">
{row.blocks.toLocaleString()}
</td>
<td className="py-1 pr-2 text-right">
{row.reports.toLocaleString()}
</td>
<td className="py-1 text-right">
{row.reportFailures > 0 ? (
<span className="text-destructive">
{row.reportFailures.toLocaleString()}
</span>
) : (
"–"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="rounded-md border p-3">
<p className="text-xs font-medium mb-1">Community signal push</p>
<div className="flex flex-wrap items-center gap-3">
<Badge variant={reportingEnabled ? "default" : "secondary"}>
{reportingEnabled ? "Enabled" : "Off"}
</Badge>
{!reportingEnabled && (
<p className="text-xs text-muted-foreground">
Set{" "}
<span className="font-mono">
CROWDSEC_REPORT_ENABLED=true
</span>{" "}
to share blocked IPs back into the CrowdSec community
blocklist. Watcher credentials are auto-generated and
persisted in Redis.
</p>
)}
{reportingEnabled && (
<p className="text-xs text-muted-foreground">
Blocked IPs are pushed to the Central API (deduped per IP) so
the community blocklist protects other members too.
</p>
)}
<form action={verifyCrowdsecReportingConfiguration}>
<Button
type="submit"
size="sm"
variant="outline"
disabled={!reportingEnabled}
>
Verify channel
</Button>
</form>
</div>
{lastReport && (
<p className="text-xs mt-2">
<Badge variant={lastReport.ok ? "default" : "destructive"}>
{lastReport.ok ? "Push healthy" : "Push failed"}
</Badge>
<span className="ml-2 text-muted-foreground">
{lastReport.ok
? `Last signal accepted ${new Date(lastReport.at).toLocaleString()}`
: `${lastReport.message ?? "unknown"} (${new Date(lastReport.at).toLocaleString()})`}
</span>
</p>
)}
</div>
</CardContent>
</Card>
{stored.size === 0 && (
<p className="text-xs text-muted-foreground">
Persisted site settings: none yet — the form values above reflect the
current effective configuration.
</p>
)}
</div>
);
}