Beyond parity — the web-feasible versions of the "host-only" items plus extras AtomCMS doesn't have: - App-level abuse/DDoS guard (src/lib/services/abuse-guard.ts): counts requests per IP and auto-adds flooders to website_ip_blacklist (enforced by the access guard) + fires ddosDetected(). OFF by default, tunable via settings. The iptables layer stays host-only; this is the real app-tier mitigation. Access guard now also enforces the IP blacklist (cached). - PWA: a themeable web manifest (src/app/manifest.ts) + a service worker (public/sw.js, cache-first assets / network-first pages) registered after hydration — the hotel is now installable. - /api/health: DB + emulator(RCON) + runtime status probe. - /developers: a public API documentation page covering every REST endpoint with its method, path and auth requirement. - jobs-worker: daily emulator JAR backup (runs host-side in the worker, like AtomCMS's backup command) — copies + prunes; no-ops unless EMULATOR_JAR_PATH + EMULATOR_BACKUP_DIR are set. Verified live (prod, amx_test): /api/health ok, manifest + sw served, docs page renders, normal pages unaffected by the guard. tsc 0, vitest 49/49, next build 0.
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
import { ddosDetected } from "@/lib/services/alert";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
/**
|
|
* App-level abuse / DDoS guard — the web-tier-feasible half of AtomCMS's DDoS
|
|
* protection. It can't touch iptables (that's a host-only optimisation), but it
|
|
* DOES the actual mitigation a CMS needs: count requests per IP and, when one
|
|
* floods past the threshold, add it to website_ip_blacklist (which the access
|
|
* guard then enforces) and fire the existing ddosDetected() alert.
|
|
*
|
|
* OFF by default; staff enable + tune it via website_settings:
|
|
* abuse_guard_enabled ("1"), abuse_guard_threshold (req, default 200),
|
|
* abuse_guard_window_seconds (default 10).
|
|
*/
|
|
type Bucket = { count: number; resetAt: number };
|
|
const buckets = new Map<string, Bucket>();
|
|
const recentlyBlocked = new Set<string>();
|
|
|
|
let blacklist = new Set<string>();
|
|
let blacklistLoadedAt = 0;
|
|
const BLACKLIST_TTL = 30_000;
|
|
|
|
function isPrivate(ip: string): boolean {
|
|
return (
|
|
!ip ||
|
|
ip === "0.0.0.0" ||
|
|
ip === "::1" ||
|
|
ip.startsWith("127.") ||
|
|
ip.startsWith("10.") ||
|
|
ip.startsWith("192.168.")
|
|
);
|
|
}
|
|
|
|
/** Cached blacklist lookup (refreshed every 30s — no DB hit per request). */
|
|
export async function isIpBlacklisted(ip: string): Promise<boolean> {
|
|
if (isPrivate(ip)) return false;
|
|
const now = Date.now();
|
|
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
|
|
try {
|
|
const rows = await prisma.websiteIpBlacklist.findMany({ select: { ipAddress: true } });
|
|
blacklist = new Set(rows.map((r) => r.ipAddress));
|
|
blacklistLoadedAt = now;
|
|
} catch {
|
|
/* keep stale set on DB error */
|
|
}
|
|
}
|
|
return blacklist.has(ip);
|
|
}
|
|
|
|
/** Count a request; auto-blacklist + alert the IP if it floods (when enabled). */
|
|
export async function recordRequest(ip: string): Promise<void> {
|
|
if (isPrivate(ip)) return;
|
|
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
|
|
|
const limit = Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
|
|
const windowMs =
|
|
(Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
|
|
|
|
const now = Date.now();
|
|
if (buckets.size > 10_000) {
|
|
for (const [k, v] of buckets) if (now >= v.resetAt) buckets.delete(k);
|
|
}
|
|
|
|
const b = buckets.get(ip);
|
|
if (!b || now >= b.resetAt) {
|
|
buckets.set(ip, { count: 1, resetAt: now + windowMs });
|
|
return;
|
|
}
|
|
b.count += 1;
|
|
|
|
if (b.count >= limit && !recentlyBlocked.has(ip)) {
|
|
recentlyBlocked.add(ip);
|
|
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
|
|
try {
|
|
await prisma.websiteIpBlacklist.create({
|
|
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
|
});
|
|
blacklistLoadedAt = 0; // force a refresh so the block takes effect at once
|
|
await ddosDetected(ip, b.count);
|
|
} catch {
|
|
/* ignore — alert/blacklist best-effort */
|
|
}
|
|
}
|
|
}
|