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 (
{values.map((v, i) => (
0 ? 6 : 2, (v / max) * 100)}%`,
opacity: v === 0 ? 0.15 : 0.6 + (v / max) * 0.4,
}}
/>
))}
);
}
/** Merge per-day breakdown maps (categories / reputations) into range totals. */
function mergeBreakdowns(
rows: CrowdsecDailyStat[],
kind: keyof Pick
,
): Record {
const totals: Record = {};
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),
]);
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();
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 (
Gate
{effective.enabled ? "Enabled" : "Disabled"}
Boot default: {defaults.enabled ? "on" : "off"}
Rates
{effective.api.limit}
{" "}
/min API
Pages {effective.pages.limit} · Auth {effective.auth.limit} ·
Global {effective.global.limit}
Cloudflare
{cloudflare ? "Detected" : "Not detected"}
IP source: {sourceHeader}
CrowdSec
{crowdsecConfigured ? "Connected" : "Not configured"}
{crowdsecUsage && crowdsecUsage.quota > 0
? `${crowdsecUsage.used.toLocaleString()} / ${crowdsecUsage.quota.toLocaleString()} CTI calls today${crowdsecUsage.exhausted ? " (paused)" : ""}`
: "Community reputation auto-block"}
Active blocks
{blocks.length}
Temporary DDoS blocks
Redis
{redisOk ? "Connected" : "Unavailable"}
Shared rate-limit state
{!cloudflare && (
Cloudflare not detected
This request did not arrive through Cloudflare. When the site DNS
is proxied (orange cloud), the CMS trusts the real visitor IP from{" "}
CF-Connecting-IP. A direct
client can spoof that header — put the origin behind Cloudflare
and restrict direct access to the origin ports for full DDoS
protection.
Your request is keyed as{" "}
{viewerIp} (via{" "}
{sourceHeader}).
)}
Anti-DDoS settings
Blocked IPs ({blocks.length})
{blocks.length === 0 ? (
No IPs are currently rate-limited into a temporary block.
) : (
{blocks.map((b) => {
const behaviorLabel =
b.meta && b.meta.behaviors.length > 0
? b.meta.behaviors.join(", ")
: null;
return (
{b.ip}
{b.source === "crowdsec" ? (
CrowdSec
) : (
Gate
)}
TTL {seconds(b.ttlMs)} · violations {b.count}
{b.meta && (
{b.meta.reputation ?? "unknown"} · score{" "}
{b.meta.score} · {b.meta.category}
{behaviorLabel ? ` · ${behaviorLabel}` : ""}
)}
);
})}
)}
Cloudflare edge blocks
{cloudflareConfigured ? "API configured" : "API not configured"}
{!cloudflareConfigured && (
Set CLOUDFLARE_API_TOKEN and{" "}
CLOUDFLARE_ZONE_ID to enable
automatic edge blocking via the Cloudflare API.
)}
{lastVerify && cloudflareConfigured && (
{lastVerify.ok ? "Reachable" : "Failed"}
{lastVerify.ok
? `Zone ${lastVerify.zoneName ?? lastVerify.zoneId ?? ""} — verified ${new Date(lastVerify.at).toLocaleString()}`
: lastVerify.message}
)}
{cloudflareConfigured && cloudflareBlocks.length === 0 ? (
No automatic Cloudflare blocks are active. When the gate blocks a
repeat offender behind Cloudflare, an IP Access Rule is created
here automatically.
) : (
cloudflareConfigured && (
{cloudflareBlocks.map((b) => (
{b.ip}
{b.category} · {seconds(b.remainingSeconds * 1000)} left
))}
Expired rules are swept automatically every 30s.
)
)}
CrowdSec reputation API
{crowdsecConfigured ? "API configured" : "API not configured"}
{!crowdsecConfigured && (
Set CROWDSEC_API_KEY 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.
)}
{lastCrowdsecVerify && crowdsecConfigured && (
{lastCrowdsecVerify.ok ? "Reachable" : "Failed"}
{lastCrowdsecVerify.ok
? `CTI endpoint verified ${new Date(lastCrowdsecVerify.at).toLocaleString()}`
: lastCrowdsecVerify.message}
)}
{crowdsecConfigured && (
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{" "}
CrowdSec badge in the list above
with the community reasoning (reputation, score, behaviors).
)}
{crowdsecUsage && (
Reputation lookups today
{crowdsecUsage.quota > 0 ? (
<>
= crowdsecUsage.quota * 0.8
? "var(--admin-accent)"
: "var(--color-primary)",
}}
/>
{crowdsecUsage.used.toLocaleString()} /{" "}
{crowdsecUsage.quota.toLocaleString()}
{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."}
>
) : (
Tracking disabled (CROWDSEC_CTI_DAILY_QUOTA = 0 / unlimited).
)}
)}
{crowdsecStats.length > 0 && (
row.blocks)} />
{Object.entries(
mergeBreakdowns(crowdsecStats, "categories"),
).map(([category, count]) => (
{category} · {count.toLocaleString()}
))}
{Object.entries(
mergeBreakdowns(crowdsecStats, "reputations"),
).map(([reputation, count]) => (
{reputation} · {count.toLocaleString()}
))}
| Date |
Lookups
|
Blocks
|
Reports
|
Failures |
{crowdsecStats.map((row) => (
|
{row.date === new Date().toISOString().slice(0, 10)
? "Today"
: row.date.slice(5)}
|
{row.lookups.toLocaleString()}
|
{row.blocks.toLocaleString()}
|
{row.reports.toLocaleString()}
|
{row.reportFailures > 0 ? (
{row.reportFailures.toLocaleString()}
) : (
"–"
)}
|
))}
)}
Community signal push
{reportingEnabled ? "Enabled" : "Off"}
{!reportingEnabled && (
Set{" "}
CROWDSEC_REPORT_ENABLED=true
{" "}
to share blocked IPs back into the CrowdSec community
blocklist. Watcher credentials are auto-generated and
persisted in Redis.
)}
{reportingEnabled && (
Blocked IPs are pushed to the Central API (deduped per IP) so
the community blocklist protects other members too.
)}
{lastReport && (
{lastReport.ok ? "Push healthy" : "Push failed"}
{lastReport.ok
? `Last signal accepted ${new Date(lastReport.at).toLocaleString()}`
: `${lastReport.message ?? "unknown"} (${new Date(lastReport.at).toLocaleString()})`}
)}
{stored.size === 0 && (
Persisted site settings: none yet — the form values above reflect the
current effective configuration.
)}
);
}