Replace Shield Access List (5,000 IP limit) with Bunny Database (libSQL) + Edge Script middleware to support CAPI community blocklists (tens of thousands of IPs). Bouncer now uses CrowdSec streaming API for incremental sync with periodic full resync every 6 hours. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
92 lines
2.6 KiB
TypeScript
92 lines
2.6 KiB
TypeScript
import { createClient } from "@libsql/client/web";
|
|
import * as BunnySDK from "@bunny.net/edgescript-sdk";
|
|
import process from "node:process";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Database client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const db = createClient({
|
|
url: process.env.DB_URL!,
|
|
authToken: process.env.DB_TOKEN!,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory cache (per edge node, 60s TTL)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CacheEntry {
|
|
blocked: boolean;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry>();
|
|
const CACHE_TTL_MS = 60_000;
|
|
const CACHE_MAX_SIZE = 50_000;
|
|
|
|
function cacheGet(ip: string): boolean | null {
|
|
const entry = cache.get(ip);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt <= Date.now()) {
|
|
cache.delete(ip);
|
|
return null;
|
|
}
|
|
return entry.blocked;
|
|
}
|
|
|
|
function cacheSet(ip: string, blocked: boolean): void {
|
|
if (cache.size >= CACHE_MAX_SIZE) {
|
|
// Evict expired entries first
|
|
const now = Date.now();
|
|
for (const [key, val] of cache) {
|
|
if (val.expiresAt <= now) cache.delete(key);
|
|
}
|
|
// If still too large, clear entirely
|
|
if (cache.size >= CACHE_MAX_SIZE) cache.clear();
|
|
}
|
|
cache.set(ip, { blocked, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Blocklist lookup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function isBlocked(ip: string): Promise<boolean> {
|
|
const cached = cacheGet(ip);
|
|
if (cached !== null) return cached;
|
|
|
|
try {
|
|
const result = await db.execute({
|
|
sql: "SELECT 1 FROM blocklist WHERE ip = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
|
|
args: [ip],
|
|
});
|
|
const blocked = result.rows.length > 0;
|
|
cacheSet(ip, blocked);
|
|
return blocked;
|
|
} catch (err) {
|
|
// Fail-open: on DB error, allow the request through
|
|
console.error("Blocklist lookup failed, allowing request:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Middleware
|
|
// ---------------------------------------------------------------------------
|
|
|
|
BunnySDK.net.http
|
|
.servePullZone()
|
|
.onOriginRequest(async (ctx) => {
|
|
const ip = ctx.request.headers.get("X-Real-Ip");
|
|
|
|
if (!ip) {
|
|
return ctx.request;
|
|
}
|
|
|
|
if (await isBlocked(ip)) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
return ctx.request;
|
|
});
|