feat(security): mirror anti-DDoS blocks to Cloudflare edge via API
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- gate creates a zone IP Access Rule (block) for proxied offenders that hit the block threshold, deduped until the tiered block expires - cloudflare-api lib: verified endpoints, create/delete/verify/list helpers, Redis-backed tracking + 30s TTL sweep (instrumentation worker + admin render) - runtime toggle cloudflareAutoBlock in antiddos config; boot default CLOUDFLARE_AUTO_BLOCK_ENABLED - admin panel: Cloudflare edge-blocks card with verify + remove-rule actions; unban also lifts the edge block - credentials live in env only (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID)
This commit is contained in:
1 parent
f0c27eb815
commit
4479753160
9 files changed
+1157
-1
No files matched your search
@@ -2,9 +2,11 @@ import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
removeCloudflareRule,
|
||||
resetAntiddosSettings,
|
||||
saveAntiddosSettings,
|
||||
unbanAntiddosIp,
|
||||
verifyCloudflareConfiguration,
|
||||
} from "@/actions/admin-antiddos";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,6 +17,13 @@ import {
|
||||
} 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 { db, WebsiteSetting } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -87,6 +96,14 @@ export default async function AdminAntiDdosPage() {
|
||||
|
||||
const stored = persistedRows;
|
||||
|
||||
const cloudflareConfigured = cloudflareEnabled();
|
||||
const cloudflareBlocks: CloudflareBlockView[] = [];
|
||||
if (cloudflareConfigured) {
|
||||
await sweepExpiredCloudflareBlocks();
|
||||
cloudflareBlocks.push(...(await listCloudflareBlocks()));
|
||||
}
|
||||
const lastVerify = await getLastCloudflareVerify();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
@@ -219,6 +236,24 @@ export default async function AdminAntiDdosPage() {
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
@@ -382,6 +417,84 @@ export default async function AdminAntiDdosPage() {
|
||||
</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>
|
||||
|
||||
{stored.size === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Persisted site settings: none yet — the form values above reflect the
|
||||
|
||||
Reference in new issue
Block a user