feat(security): Cloudflare-aware IP trust and admin-tunable anti-DDoS
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
- resolveClientIp: trust cf-connecting-ip only behind cf-ray/cdn-loop, use nginx x-real-ip otherwise (anti-spoof) - antiddos-config: Redis-backed live config (antiddos:config) with 30s cache, 13 ANTI_DDOS_* env vars - ddos-guard: consume tunable rates/tiers via getAntiddosConfig - admin panel at /admin/devops/antiddos (save/reset/unban actions, PERMS.SETTINGS_VIEW) - register new admin page in housekeeping migration matrix (146 -> 147)
This commit is contained in:
1 parent
fd4d0fa1cb
commit
f0c27eb815
17 files changed
+1151
-63
No files matched your search
@@ -0,0 +1,393 @@
|
||||
import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
resetAntiddosSettings,
|
||||
saveAntiddosSettings,
|
||||
unbanAntiddosIp,
|
||||
} 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 { 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`;
|
||||
}
|
||||
|
||||
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 }[] = [];
|
||||
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 = await rateStore.pttl(key);
|
||||
return {
|
||||
ip: key.replace("antiddos:block:", ""),
|
||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||
count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
blocks = withTtl
|
||||
.filter((b) => b.ttlMs > 0)
|
||||
.sort((a, b) => a.ttlMs - b.ttlMs);
|
||||
} catch {
|
||||
redisOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stored = persistedRows;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<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">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>
|
||||
|
||||
<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) => (
|
||||
<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">
|
||||
TTL {seconds(b.ttlMs)} · violations {b.count}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export default async function DevOpsPage() {
|
||||
<Link href="/admin/devops/deliveries" className="btn btn-outline">
|
||||
{deliveries("title")}
|
||||
</Link>
|
||||
<Link href="/admin/devops/antiddos" className="btn btn-outline">
|
||||
Anti-DDoS protection
|
||||
</Link>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
Reference in new issue
Block a user