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
909 lines
29 KiB
TypeScript
909 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|