import "server-only"; import { env } from "@/env"; import { bumpCrowdsecBreakdownStat, bumpCrowdsecStat, } from "@/lib/crowdsec-stats"; import { logger } from "@/lib/logger"; import { redis } from "@/lib/redis"; import { UNKNOWN_CLIENT_IP } from "./client-ip"; export interface CrowdsecLocalDecision { origin?: string; scope?: string; type?: string; value?: string; duration?: string | null; } export interface CrowdsecLocalBlockResult { blocked: boolean; retryAfterSeconds: number; } const DEFAULT_LAPI_URL = "http://127.0.0.1:18080"; const REQUEST_TIMEOUT_MS = 500; const NEGATIVE_CACHE_TTL_MS = 2_000; const DECISION_CACHE_TTL_MS = 300_000; const DECISION_RETRY_MAX_SECONDS = 300; const FALLBACK_RETRY_SECONDS = 60; const BACKOFF_MS = 5_000; const AUTH_BACKOFF_MS = 300_000; const MEMORY_CACHE_MAX = 5_000; const CACHE_PREFIX = "crowdsec:local:"; const BACKOFF_KEY = "crowdsec:local:backoff-until"; const DURATION_TOKEN = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g; export function parseCrowdsecDecisionDuration( value: string | null | undefined, ): number { if (!value) return 0; let total = 0; for (const match of value.matchAll(DURATION_TOKEN)) { const amount = Number(match[1]); if (!Number.isFinite(amount)) continue; const unit = match[2]; if (unit === "h") total += amount * 3_600; else if (unit === "m") total += amount * 60; else if (unit === "s") total += amount; else if (unit === "ms") total += amount / 1_000; else if (unit === "us" || unit === "µs") total += amount / 1_000_000; else if (unit === "ns") total += amount / 1_000_000_000; } return total; } export function isBlockingCrowdsecDecision( decision: CrowdsecLocalDecision | null | undefined, ): boolean { if (!decision) return false; const type = decision.type?.toLowerCase(); const scope = decision.scope?.toLowerCase(); if ((type !== "ban" && type !== "captcha") || !decision.value) return false; return scope === "ip" || scope === "range"; } function localEnabled(): boolean { const flag: unknown = env.CROWDSEC_LOCAL_ENABLED; return flag === true || flag === "true" || flag === "1"; } function localConfig(): { url: string; apiKey: string; timeoutMs: number; } { return { url: (env.CROWDSEC_LAPI_URL || DEFAULT_LAPI_URL).replace(/\/+$/, ""), apiKey: String(env.CROWDSEC_LAPI_API_KEY ?? "").trim(), timeoutMs: Number(env.CROWDSEC_LAPI_TIMEOUT_MS) > 0 ? Number(env.CROWDSEC_LAPI_TIMEOUT_MS) : REQUEST_TIMEOUT_MS, }; } interface CacheEntry { blocked: boolean; retryAfterSeconds: number; until: number; } const memoryCache = new Map(); let backoffUntil = 0; let authBackoffWarned = false; async function rememberCache( ip: string, entry: CacheEntry, ttlMs: number, ): Promise { memoryCache.delete(ip); memoryCache.set(ip, entry); while (memoryCache.size > MEMORY_CACHE_MAX) { const oldest = memoryCache.keys().next(); if (oldest.done) break; memoryCache.delete(oldest.value); } if (!redis) return; try { await redis.set( `${CACHE_PREFIX}block:${ip}`, JSON.stringify(entry), "EX", Math.max(1, Math.ceil(ttlMs / 1_000)), ); } catch { // Cache is best-effort — a miss only costs one extra LAPI call. } } async function readCache(ip: string): Promise { const memory = memoryCache.get(ip); if (memory && memory.until > Date.now()) return memory; if (redis) { try { const raw = await redis.get(`${CACHE_PREFIX}block:${ip}`); if (raw) { const parsed = JSON.parse(raw) as CacheEntry; if (parsed.until > Date.now()) return parsed; } } catch { // Redis hiccup — an extra local LAPI call is the only cost. } } return null; } async function getBackoffUntil(): Promise { if (Date.now() < backoffUntil) return backoffUntil; if (redis) { try { const raw = await redis.get(BACKOFF_KEY); const shared = Number(raw ?? 0); if (Number.isFinite(shared) && shared > backoffUntil) { backoffUntil = shared; } } catch { // Redis hiccup — the local view is enough. } } return backoffUntil; } async function setBackoff(ms: number): Promise { const until = Date.now() + ms; backoffUntil = until; if (redis) { try { await redis.set( BACKOFF_KEY, String(until), "EX", Math.ceil(ms / 1_000) + 1, ); } catch { // Local view still protects this instance. } } } function decisionRetrySeconds(decision: CrowdsecLocalDecision | null): number { const parsed = decision ? parseCrowdsecDecisionDuration(decision.duration) : 0; if (parsed <= 0) return FALLBACK_RETRY_SECONDS; return Math.min(Math.max(1, Math.floor(parsed)), DECISION_RETRY_MAX_SECONDS); } async function queryLocalDecision( baseUrl: string, apiKey: string, timeoutMs: number, ip: string, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch( `${baseUrl}/v1/decisions?ip=${encodeURIComponent(ip)}`, { headers: { "X-Api-Key": apiKey, Accept: "application/json", }, signal: controller.signal, cache: "no-store", }, ); if (response.status === 401 || response.status === 403) { await setBackoff(AUTH_BACKOFF_MS); if (!authBackoffWarned) { authBackoffWarned = true; logger.warn( "[crowdsec-local] LAPI rejected the bouncer key — local decisions paused for 5 minutes", { status: response.status }, ); } return null; } if (!response.ok) { await setBackoff(BACKOFF_MS); logger.warn( "[crowdsec-local] LAPI decision request failed — failing open", { ip, status: response.status }, ); return null; } const body = (await response.json()) as unknown; if (!Array.isArray(body)) return null; return body.find(isBlockingCrowdsecDecision) ?? null; } catch (error) { await setBackoff(BACKOFF_MS); logger.warn( "[crowdsec-local] LAPI decision request errored — failing open", { ip, error: error instanceof Error ? error.message : String(error), }, ); return null; } finally { clearTimeout(timer); } } export async function checkCrowdsecLocalBlock( ip: string, ): Promise { if (!localEnabled()) return { blocked: false, retryAfterSeconds: 0 }; const config = localConfig(); if (!config.apiKey) return { blocked: false, retryAfterSeconds: 0 }; if (!ip || ip === UNKNOWN_CLIENT_IP) { return { blocked: false, retryAfterSeconds: 0 }; } if (Date.now() < (await getBackoffUntil())) { return { blocked: false, retryAfterSeconds: 0 }; } const cached = await readCache(ip); if (cached && cached.until > Date.now()) { return { blocked: cached.blocked, retryAfterSeconds: cached.blocked ? cached.retryAfterSeconds : 0, }; } const decision = await queryLocalDecision( config.url, config.apiKey, config.timeoutMs, ip, ); if (decision) { const retryAfterSeconds = decisionRetrySeconds(decision); await rememberCache( ip, { blocked: true, retryAfterSeconds, until: Date.now() + DECISION_CACHE_TTL_MS, }, DECISION_CACHE_TTL_MS, ); void bumpCrowdsecStat("blocks"); void bumpCrowdsecBreakdownStat("category", "local"); logger.info("[crowdsec-local] IP blocked by a local CrowdSec decision", { ip, type: decision.type, retryAfterSeconds, }); return { blocked: true, retryAfterSeconds }; } await rememberCache( ip, { blocked: false, retryAfterSeconds: 0, until: Date.now() + NEGATIVE_CACHE_TTL_MS, }, NEGATIVE_CACHE_TTL_MS, ); return { blocked: false, retryAfterSeconds: 0 }; } export function resetCrowdsecLocalCache(): void { memoryCache.clear(); backoffUntil = 0; authBackoffWarned = false; }