101%: app-level DDoS guard, PWA, /api/health, API docs, worker JAR backup
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.
This commit is contained in:
1 parent
4dfe698009
commit
54ec99de6d
11 files changed
+702
-1
No files matched your search
@@ -0,0 +1,85 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user