Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
305 lines
7.6 KiB
TypeScript
305 lines
7.6 KiB
TypeScript
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<string, CacheEntry>();
|
|
|
|
let backoffUntil = 0;
|
|
let authBackoffWarned = false;
|
|
|
|
async function rememberCache(
|
|
ip: string,
|
|
entry: CacheEntry,
|
|
ttlMs: number,
|
|
): Promise<void> {
|
|
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<CacheEntry | null> {
|
|
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<number> {
|
|
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<void> {
|
|
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<CrowdsecLocalDecision | null> {
|
|
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<CrowdsecLocalBlockResult> {
|
|
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;
|
|
}
|