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.
307 lines
9.4 KiB
TypeScript
307 lines
9.4 KiB
TypeScript
"use server";
|
|
|
|
import { like } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { requirePermission } from "@/lib/admin/guard";
|
|
import {
|
|
type AntiddosBlockTier,
|
|
type AntiddosConfig,
|
|
antiddosConfigToJson,
|
|
antiddosDefaultsFromEnv,
|
|
invalidateAntiddosConfig,
|
|
} from "@/lib/antiddos-config";
|
|
import {
|
|
removeCloudflareBlock,
|
|
setLastCloudflareVerify,
|
|
verifyCloudflareConnection,
|
|
} from "@/lib/cloudflare-api";
|
|
import {
|
|
setLastCrowdsecVerify,
|
|
verifyCrowdsecConnection,
|
|
} from "@/lib/crowdsec-api";
|
|
import {
|
|
setLastCrowdsecReport,
|
|
verifyCrowdsecReporting,
|
|
} from "@/lib/crowdsec-report";
|
|
import { db, WebsiteSetting } from "@/lib/db";
|
|
import { logger } from "@/lib/logger";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { redis } from "@/lib/redis";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
const OVERRIDE_KEY = "antiddos:config";
|
|
|
|
function str(raw: FormDataEntryValue | null): string {
|
|
return typeof raw === "string" ? raw : "";
|
|
}
|
|
|
|
function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
|
const n = Number(str(raw));
|
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
return Math.floor(n);
|
|
}
|
|
|
|
function clampInt(
|
|
raw: FormDataEntryValue | null,
|
|
fallback: number,
|
|
min: number,
|
|
max: number,
|
|
): number {
|
|
const n = Number(str(raw));
|
|
if (!Number.isFinite(n)) return fallback;
|
|
return Math.min(max, Math.max(min, Math.floor(n)));
|
|
}
|
|
|
|
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
|
const tiers: AntiddosBlockTier[] = [];
|
|
for (const part of str(raw).split(",")) {
|
|
const [minRaw, ttlRaw] = part.split(":");
|
|
const min = Number(minRaw);
|
|
const ttl = Number(ttlRaw);
|
|
if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) continue;
|
|
tiers.push({
|
|
minViolations: Math.max(1, Math.floor(min)),
|
|
ttlSeconds: Math.floor(ttl),
|
|
});
|
|
}
|
|
tiers.sort((a, b) => a.minViolations - b.minViolations);
|
|
return tiers;
|
|
}
|
|
|
|
function configFromForm(formData: FormData): AntiddosConfig {
|
|
const defaults = antiddosDefaultsFromEnv();
|
|
const tierRaw = str(formData.get("block_tiers")).trim();
|
|
return {
|
|
enabled: str(formData.get("enabled")) === "1",
|
|
pages: {
|
|
limit: positiveInt(formData.get("pages_limit"), defaults.pages.limit),
|
|
windowSeconds: positiveInt(
|
|
formData.get("pages_window_sec"),
|
|
defaults.pages.windowSeconds,
|
|
),
|
|
},
|
|
api: {
|
|
limit: positiveInt(formData.get("api_limit"), defaults.api.limit),
|
|
windowSeconds: positiveInt(
|
|
formData.get("api_window_sec"),
|
|
defaults.api.windowSeconds,
|
|
),
|
|
},
|
|
auth: {
|
|
limit: positiveInt(formData.get("auth_limit"), defaults.auth.limit),
|
|
windowSeconds: positiveInt(
|
|
formData.get("auth_window_sec"),
|
|
defaults.auth.windowSeconds,
|
|
),
|
|
},
|
|
global: {
|
|
limit: positiveInt(formData.get("global_limit"), defaults.global.limit),
|
|
windowSeconds: positiveInt(
|
|
formData.get("global_window_sec"),
|
|
defaults.global.windowSeconds,
|
|
),
|
|
},
|
|
violationWindowSeconds: positiveInt(
|
|
formData.get("violation_window_sec"),
|
|
defaults.violationWindowSeconds,
|
|
),
|
|
maxViolations: positiveInt(
|
|
formData.get("max_violations"),
|
|
defaults.maxViolations,
|
|
),
|
|
blockTiers:
|
|
tierRaw.length > 0
|
|
? parseTiers(formData.get("block_tiers"))
|
|
: defaults.blockTiers,
|
|
globalHaltMs: positiveInt(
|
|
formData.get("global_halt_ms"),
|
|
defaults.globalHaltMs,
|
|
),
|
|
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
|
crowdsecAutoBlock: str(formData.get("cs_auto_block")) === "1",
|
|
crowdsecBlockScore: clampInt(
|
|
formData.get("cs_block_score"),
|
|
defaults.crowdsecBlockScore,
|
|
0,
|
|
5,
|
|
),
|
|
crowdsecBlockTtlSeconds: positiveInt(
|
|
formData.get("cs_block_ttl_sec"),
|
|
defaults.crowdsecBlockTtlSeconds,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function persistSettings(config: AntiddosConfig): Promise<void> {
|
|
const entries: [string, string][] = [
|
|
["antiddos_enabled", config.enabled ? "1" : "0"],
|
|
["antiddos_pages_limit", String(config.pages.limit)],
|
|
["antiddos_pages_window_sec", String(config.pages.windowSeconds)],
|
|
["antiddos_api_limit", String(config.api.limit)],
|
|
["antiddos_api_window_sec", String(config.api.windowSeconds)],
|
|
["antiddos_auth_limit", String(config.auth.limit)],
|
|
["antiddos_auth_window_sec", String(config.auth.windowSeconds)],
|
|
["antiddos_global_limit", String(config.global.limit)],
|
|
["antiddos_global_window_sec", String(config.global.windowSeconds)],
|
|
["antiddos_violation_window_sec", String(config.violationWindowSeconds)],
|
|
["antiddos_max_violations", String(config.maxViolations)],
|
|
[
|
|
"antiddos_block_tiers",
|
|
config.blockTiers
|
|
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
|
|
.join(","),
|
|
],
|
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
|
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
|
["antiddos_cs_auto_block", config.crowdsecAutoBlock ? "1" : "0"],
|
|
["antiddos_cs_block_score", String(config.crowdsecBlockScore)],
|
|
["antiddos_cs_block_ttl", String(config.crowdsecBlockTtlSeconds)],
|
|
];
|
|
await Promise.all(
|
|
entries.map(([key, value]) =>
|
|
db
|
|
.insert(WebsiteSetting)
|
|
.values({ key, value, comment: "Anti-DDoS protection" })
|
|
.onDuplicateKeyUpdate({ set: { value } }),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save anti-DDoS settings from the admin panel. Persists to site settings
|
|
* (durable across Redis flushes) and pushes the same config to the Redis
|
|
* override the proxy reads, so the change is live within the proxy cache TTL.
|
|
*/
|
|
export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const config = configFromForm(formData);
|
|
|
|
try {
|
|
await persistSettings(config);
|
|
if (redis) {
|
|
await redis.set(OVERRIDE_KEY, antiddosConfigToJson(config));
|
|
}
|
|
invalidateAntiddosConfig();
|
|
siteSettings.reload();
|
|
logger.info("Anti-DDoS settings updated", {
|
|
staff: staff.username,
|
|
enabled: config.enabled,
|
|
});
|
|
} catch (err) {
|
|
logger.error("Failed to save anti-DDoS settings", { err });
|
|
}
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/**
|
|
* Revert to the boot defaults (env) — drop the Redis live override and the
|
|
* DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*.
|
|
*/
|
|
export async function resetAntiddosSettings(): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
|
|
try {
|
|
if (redis) await redis.del(OVERRIDE_KEY);
|
|
// Remove every persisted antiddos_* row.
|
|
await db
|
|
.delete(WebsiteSetting)
|
|
.where(like(WebsiteSetting.key, "antiddos_%"));
|
|
invalidateAntiddosConfig();
|
|
siteSettings.reload();
|
|
logger.info("Anti-DDoS settings reset to defaults", {
|
|
staff: staff.username,
|
|
});
|
|
} catch (err) {
|
|
logger.error("Failed to reset anti-DDoS settings", { err });
|
|
}
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/** Remove a single IP from the temporary DDoS block list. */
|
|
export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const ip = str(formData.get("ip")).trim();
|
|
if (!ip) return;
|
|
|
|
try {
|
|
if (redis) {
|
|
await Promise.all([
|
|
redis.del(`antiddos:block:${ip}`),
|
|
redis.del(`antiddos:block:meta:${ip}`),
|
|
redis.del(`crowdsec:report:${ip}`),
|
|
redis.del(`antiddos:v:${ip}`),
|
|
]);
|
|
}
|
|
// Also lift a matching Cloudflare edge block (best effort).
|
|
const cloudflare = await removeCloudflareBlock(ip);
|
|
logger.info("Anti-DDoS block manually removed", {
|
|
staff: staff.username,
|
|
ip,
|
|
cloudflareCleared: cloudflare.removed,
|
|
});
|
|
} catch (err) {
|
|
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
|
}
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
|
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const ip = str(formData.get("ip")).trim();
|
|
if (!ip) return;
|
|
|
|
const result = await removeCloudflareBlock(ip);
|
|
logger.info(
|
|
result.removed
|
|
? "Cloudflare automatic block removed"
|
|
: "Cloudflare automatic block removal skipped",
|
|
{
|
|
staff: staff.username,
|
|
ip,
|
|
message: result.message,
|
|
},
|
|
);
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/** Test the configured CrowdSec API credentials against the CTI endpoint. */
|
|
export async function verifyCrowdsecConfiguration(): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const status = await verifyCrowdsecConnection();
|
|
await setLastCrowdsecVerify(status);
|
|
logger.info("CrowdSec API configuration verified", {
|
|
staff: staff.username,
|
|
ok: status.ok,
|
|
message: status.message,
|
|
});
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/** Test the CrowdSec signal-push (CAPI watcher) channel. */
|
|
export async function verifyCrowdsecReportingConfiguration(): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const status = await verifyCrowdsecReporting();
|
|
await setLastCrowdsecReport(status);
|
|
logger.info("CrowdSec reporting configuration verified", {
|
|
staff: staff.username,
|
|
ok: status.ok,
|
|
message: status.message,
|
|
});
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|
|
|
|
/** Test the configured Cloudflare API credentials against the zone. */
|
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
|
const status = await verifyCloudflareConnection();
|
|
await setLastCloudflareVerify(status);
|
|
logger.info("Cloudflare API configuration verified", {
|
|
staff: staff.username,
|
|
ok: status.ok,
|
|
zoneName: status.zoneName,
|
|
message: status.message,
|
|
});
|
|
revalidatePath("/admin/devops/antiddos");
|
|
}
|