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

- 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:
openhands committed 2026-09-22 23:27:15 +02:00
1 parent f0c27eb815
commit 4479753160
9 files changed
+1157 -1

No files matched your search

+113
View File
@@ -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