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:
1 parent
4fe304c5af
commit
deeeaf0042
13 files changed
+809
-3
No files matched your search
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user