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
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:
1 parent
ee25545b7f
commit
5e4fc9ab59
8 files changed
+1323
-37
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user