feat(security): give back to CrowdSec and harden the CTI budget
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m36s
CI / tests-ui (push) Successful in 2m22s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s

- Bound the in-process verdict cache (FIFO eviction at 2000 entries) so a
  flood of distinct bucket-tripping IPs cannot grow it without limit.
- Record block metadata (reputation, score, behaviors, category, TTL) in
  antiddos:block:meta:{ip}, surfaced as the reason in the admin block list;
  unban now also clears the metadata and report locks.
- Track daily CTI enrichment usage in Redis (crowdsec:usage:{date}); warn
  once at 80% and pause lookups until tomorrow at CROWDSEC_CTI_DAILY_QUOTA
  (default 10000, 0 = unlimited) so a via-spread DDoS cannot burn the plan.
- Add opt-in signal push to the CrowdSec community (CAPI watcher): stable
  auto-generated 48-char machine_id/password pair persisted in Redis (or via
  env), one-time registration, cached JWT login, optional Console enrollment,
  and POST /v3/signals with a ban decision, deduped per IP. Never throws and
  reports last status to the admin panel with a verify action.
- Admin page: quota usage bar, reporting status/verify channel, and CrowdSec
  block reasons in the active-blocks list.
This commit is contained in:
openhands committed 2026-09-23 14:24:44 +02:00
1 parent ee25545b7f
commit 5e4fc9ab59
8 files changed
+1323 -37

No files matched your search

+155 -30
View File
@@ -15,6 +15,7 @@ import {
unbanAntiddosIp,
verifyCloudflareConfiguration,
verifyCrowdsecConfiguration,
verifyCrowdsecReportingConfiguration,
} from "@/actions/admin-antiddos";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -34,9 +35,16 @@ import {
} 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 { db, WebsiteSetting } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redis } from "@/lib/redis";
@@ -76,6 +84,7 @@ export default async function AdminAntiDdosPage() {
ttlMs: number;
count: number;
source: "gate" | "crowdsec";
meta: CrowdsecBlockMeta | null;
}[] = [];
let redisOk = false;
const rateStore = redis;
@@ -100,15 +109,19 @@ export default async function AdminAntiDdosPage() {
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: key.replace("antiddos:block:", ""),
ip,
ttlMs: ttlMs > 0 ? ttlMs : 0,
count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 0,
count: violationCounts.get(ip) ?? 0,
// The gate writes "1"; "crowdsec" marks a community-reputation block.
source:
value === CROWDSEC_BLOCK_SOURCE
? ("crowdsec" as const)
: ("gate" as const),
source,
// Why CrowdSec blocked this IP, when the meta was recorded.
meta: source === "crowdsec" ? await getCrowdsecBlockMeta(ip) : null,
};
}),
);
@@ -131,6 +144,9 @@ export default async function AdminAntiDdosPage() {
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();
return (
<div className="space-y-6">
@@ -195,7 +211,9 @@ export default async function AdminAntiDdosPage() {
{crowdsecConfigured ? "Connected" : "Not configured"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Community reputation auto-block
{crowdsecUsage && crowdsecUsage.quota > 0
? `${crowdsecUsage.used.toLocaleString()} / ${crowdsecUsage.quota.toLocaleString()} CTI calls today${crowdsecUsage.exhausted ? " (paused)" : ""}`
: "Community reputation auto-block"}
</p>
</CardContent>
</Card>
@@ -485,28 +503,44 @@ export default async function AdminAntiDdosPage() {
</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="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}
</span>
<form action={unbanAntiddosIp}>
<input type="hidden" name="ip" value={b.ip} />
<Button type="submit" size="sm" variant="outline">
Unban
</Button>
</form>
</div>
))}
{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>
@@ -642,9 +676,100 @@ export default async function AdminAntiDdosPage() {
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.
<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>
)}
<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>