Security page, admin quick controls, and whitelist reload self-healing

- RCON client retries once on a fresh connection after stale-socket
  timeouts (panel idle overnight held dead sockets, so 'whitelist
  reload' silently failed and approvals needed a server restart)
- Auth watcher verifies pending players against the LIVE whitelist every
  5s and re-writes/reloads if the server missed it, refunding lost
  window time
- Log watcher records blocked join attempts (name + IP) with rate-
  limited notifications — scanner bots are now visible in the panel
- New Security page: blocked-attempt log with one-click IP ban, banned
  players/IPs management, kick-everyone lockdown
- Player detail modal: ban, op/deop, gamemode switching
- Overview quick controls: time/weather/difficulty, gamerule toggles
  (keepInventory, mobGriefing, PvP...), broadcast box
- Settings toggles for blocked-attempt notifications (panel + Discord)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-11 11:50:27 -05:00
1 parent 4fe304c5af
commit deeeaf0042
13 files changed
+809 -3

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { NextRequest } from "next/server";
import {
setGamemode,
opPlayer,
setTime,
setWeather,
setDifficulty,
getGamerules,
setGamerule,
say,
} from "@/server/mc";
import { logActivity } from "@/server/db";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
/** Current values for the quick-controls card. */
export async function GET() {
return handle(async () => {
return ok({ gamerules: await getGamerules() });
});
}
type Body =
| { op: "gamemode"; name: string; mode: string }
| { op: "op"; name: string; grant: boolean }
| { op: "time"; value: "day" | "night" | "noon" | "midnight" }
| { op: "weather"; value: "clear" | "rain" | "thunder" }
| { op: "difficulty"; value: string }
| { op: "gamerule"; rule: string; value: boolean }
| { op: "broadcast"; message: string };
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "gamemode": {
const out = await setGamemode(body.name, body.mode);
logActivity("admin", body.name, `gamemode ${body.mode}`);
return ok({ output: out });
}
case "op": {
const out = await opPlayer(body.name, body.grant);
logActivity("admin", body.name, body.grant ? "op" : "deop");
return ok({ output: out });
}
case "time":
await setTime(body.value);
return ok({});
case "weather":
await setWeather(body.value);
return ok({});
case "difficulty": {
const out = await setDifficulty(body.value);
logActivity("admin", null, `difficulty ${body.value}`);
return ok({ output: out });
}
case "gamerule": {
const out = await setGamerule(body.rule, body.value);
logActivity("admin", null, `gamerule ${body.rule} ${body.value}`);
return ok({ output: out });
}
case "broadcast": {
if (!body.message?.trim()) return fail("Empty message");
await say(body.message.trim().slice(0, 256));
return ok({});
}
default:
return fail("Unknown operation");
}
});
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest } from "next/server";
import { db, logActivity } from "@/server/db";
import { listBans, banPlayer, pardonPlayer, banIp, pardonIp, kickAll } from "@/server/mc";
import { notify } from "@/server/notify";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
const blocked = db()
.prepare(
`SELECT player, detail AS ip, COUNT(*) AS attempts, MAX(ts) AS last_ts, MIN(ts) AS first_ts
FROM activity WHERE type = 'blocked' AND ts > ?
GROUP BY player, detail ORDER BY last_ts DESC LIMIT 100`
)
.all(Date.now() - 7 * 24 * 60 * 60 * 1000);
return ok({ blocked, bans: listBans() });
});
}
type Body =
| { op: "ban-player"; name: string; reason?: string }
| { op: "pardon-player"; name: string }
| { op: "ban-ip"; ip: string; reason?: string }
| { op: "pardon-ip"; ip: string }
| { op: "kick-all"; reason?: string };
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "ban-player": {
if (!body.name) return fail("Name required");
const out = await banPlayer(body.name, body.reason);
logActivity("ban", body.name, body.reason ?? null);
notify("warn", `Banned ${body.name}`, body.reason ?? "", { discord: false });
return ok({ output: out });
}
case "pardon-player": {
if (!body.name) return fail("Name required");
const out = await pardonPlayer(body.name);
logActivity("pardon", body.name);
return ok({ output: out });
}
case "ban-ip": {
if (!body.ip) return fail("IP required");
const out = await banIp(body.ip, body.reason);
logActivity("ban_ip", null, body.ip);
notify("warn", `Banned IP ${body.ip}`, body.reason ?? "", { discord: false });
return ok({ output: out });
}
case "pardon-ip": {
if (!body.ip) return fail("IP required");
const out = await pardonIp(body.ip);
logActivity("pardon_ip", null, body.ip);
return ok({ output: out });
}
case "kick-all": {
const count = await kickAll(body.reason ?? "The server is in lockdown — back soon!");
logActivity("kick_all", null, `${count} player(s)`);
notify("warn", "Kicked everyone", `${count} player(s) disconnected.`, { discord: false });
return ok({ kicked: count });
}
default:
return fail("Unknown operation");
}
});
}
+4
View File
@@ -12,8 +12,10 @@ export async function GET() {
getSetting("registration_window_minutes", String(env.registrationWindowMinutes))
),
notifyJoins: getBoolSetting("notify_joins", true),
notifyBlocked: getBoolSetting("notify_blocked", true),
discordEnabled: getBoolSetting("discord_enabled", true),
discordJoins: getBoolSetting("discord_joins", true),
discordBlocked: getBoolSetting("discord_blocked", true),
discordWebhookUrl: getSetting("discord_webhook_url", env.discordWebhookUrl),
});
});
@@ -28,8 +30,10 @@ export async function POST(req: NextRequest) {
setSetting("registration_window_minutes", String(n));
}
if (body.notifyJoins !== undefined) setSetting("notify_joins", body.notifyJoins ? "1" : "0");
if (body.notifyBlocked !== undefined) setSetting("notify_blocked", body.notifyBlocked ? "1" : "0");
if (body.discordEnabled !== undefined) setSetting("discord_enabled", body.discordEnabled ? "1" : "0");
if (body.discordJoins !== undefined) setSetting("discord_joins", body.discordJoins ? "1" : "0");
if (body.discordBlocked !== undefined) setSetting("discord_blocked", body.discordBlocked ? "1" : "0");
if (body.discordWebhookUrl !== undefined) {
const url = String(body.discordWebhookUrl);
if (url && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) {