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:
Simo committed 2026-06-29 18:39:23 +02:00
1 parent 4dfe698009
commit 54ec99de6d
11 files changed
+702 -1

No files matched your search

+12 -1
View File
@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -21,14 +22,24 @@ function isExempt(path: string): boolean {
export async function enforceSiteAccess(): Promise<void> {
const h = await headers();
const path = h.get("x-pathname") ?? "/";
const ip =
h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
// Abuse/DDoS guard: count this request and block flooding IPs (no-op unless
// enabled in settings). Best-effort — never let it throw past the guard.
void recordRequest(ip).catch(() => {});
if (isExempt(path)) return;
let target: string | null = null;
try {
// App-level IP blacklist (auto-populated by the abuse guard + /admin/ip).
if (await isIpBlacklisted(ip)) target = "/banned";
const session = await auth();
const rank = session?.user?.rank ?? 0;
if (await siteSettings.getBool("maintenance_enabled", false)) {
if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
if (rank < minLogin) target = "/maintenance";
}
+85
View File
@@ -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 */
}
}
}