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
@@ -125,6 +125,142 @@ export async function whitelistList(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's *live* whitelist (what it actually enforces), as opposed to
|
||||
* the file. Used by the auth watcher to detect a missed `whitelist reload`
|
||||
* and heal it.
|
||||
*/
|
||||
export async function whitelistLive(): Promise<string[]> {
|
||||
const out = stripColors(await rcon().exec("whitelist list"));
|
||||
const colon = out.indexOf(":");
|
||||
if (colon < 0) return [];
|
||||
return out
|
||||
.slice(colon + 1)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => NAME_RE.test(s));
|
||||
}
|
||||
|
||||
export { reloadWhitelist };
|
||||
|
||||
// ── bans ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Ban/pardon go through the vanilla commands: for players who have joined
|
||||
// before (the realistic case) the server already knows their offline UUID
|
||||
// via usercache. IP bans take raw addresses and always work.
|
||||
|
||||
export type BanEntry = { name?: string; ip?: string; created?: string; reason?: string; source?: string };
|
||||
|
||||
function readBanFile(file: string): BanEntry[] {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(env.mcDataDir, file), "utf8"));
|
||||
return Array.isArray(data) ? (data as BanEntry[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listBans(): { players: BanEntry[]; ips: BanEntry[] } {
|
||||
return {
|
||||
players: readBanFile("banned-players.json"),
|
||||
ips: readBanFile("banned-ips.json"),
|
||||
};
|
||||
}
|
||||
|
||||
const IP_RE = /^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-fA-F:]+$/;
|
||||
|
||||
function assertValidIp(ip: string) {
|
||||
if (!IP_RE.test(ip)) throw new Error(`Invalid IP address: ${ip}`);
|
||||
}
|
||||
|
||||
function sanitizeReason(reason: string): string {
|
||||
return reason.replace(/[\r\n]/g, " ").slice(0, 120);
|
||||
}
|
||||
|
||||
export async function banPlayer(name: string, reason = "Banned by the admin"): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
const out = stripColors(await rcon().exec(`ban ${name} ${sanitizeReason(reason)}`));
|
||||
try {
|
||||
await kickPlayer(name, reason);
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function pardonPlayer(name: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`pardon ${name}`));
|
||||
}
|
||||
|
||||
export async function banIp(ip: string, reason = "Banned by the admin"): Promise<string> {
|
||||
assertValidIp(ip);
|
||||
return stripColors(await rcon().exec(`ban-ip ${ip} ${sanitizeReason(reason)}`));
|
||||
}
|
||||
|
||||
export async function pardonIp(ip: string): Promise<string> {
|
||||
assertValidIp(ip);
|
||||
return stripColors(await rcon().exec(`pardon-ip ${ip}`));
|
||||
}
|
||||
|
||||
// ── admin controls ──────────────────────────────────────────────────────
|
||||
|
||||
const GAMEMODES = new Set(["survival", "creative", "adventure", "spectator"]);
|
||||
const DIFFICULTIES = new Set(["peaceful", "easy", "normal", "hard"]);
|
||||
/** Gamerules the panel exposes as toggles. */
|
||||
export const TOGGLE_GAMERULES = ["keepInventory", "mobGriefing", "doDaylightCycle", "doWeatherCycle", "pvp"] as const;
|
||||
|
||||
export async function setGamemode(name: string, mode: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
if (!GAMEMODES.has(mode)) throw new Error("Invalid gamemode");
|
||||
return stripColors(await rcon().exec(`gamemode ${mode} ${name}`));
|
||||
}
|
||||
|
||||
export async function opPlayer(name: string, grant: boolean): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`${grant ? "op" : "deop"} ${name}`));
|
||||
}
|
||||
|
||||
export async function setTime(value: "day" | "night" | "noon" | "midnight"): Promise<void> {
|
||||
if (!["day", "night", "noon", "midnight"].includes(value)) throw new Error("Invalid time");
|
||||
await rcon().exec(`time set ${value}`);
|
||||
}
|
||||
|
||||
export async function setWeather(value: "clear" | "rain" | "thunder"): Promise<void> {
|
||||
if (!["clear", "rain", "thunder"].includes(value)) throw new Error("Invalid weather");
|
||||
await rcon().exec(`weather ${value}`);
|
||||
}
|
||||
|
||||
export async function setDifficulty(value: string): Promise<string> {
|
||||
if (!DIFFICULTIES.has(value)) throw new Error("Invalid difficulty");
|
||||
return stripColors(await rcon().exec(`difficulty ${value}`));
|
||||
}
|
||||
|
||||
export async function getGamerules(): Promise<Record<string, boolean>> {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const rule of TOGGLE_GAMERULES) {
|
||||
try {
|
||||
const res = stripColors(await rcon().exec(`gamerule ${rule}`));
|
||||
const m = res.match(/\b(true|false)\b/i);
|
||||
if (m) out[rule] = m[1].toLowerCase() === "true";
|
||||
} catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function setGamerule(rule: string, value: boolean): Promise<string> {
|
||||
if (!(TOGGLE_GAMERULES as readonly string[]).includes(rule)) throw new Error("Gamerule not allowed");
|
||||
return stripColors(await rcon().exec(`gamerule ${rule} ${value}`));
|
||||
}
|
||||
|
||||
export async function kickAll(reason: string): Promise<number> {
|
||||
const { names } = await listOnlinePlayers();
|
||||
for (const name of names) {
|
||||
try {
|
||||
await kickPlayer(name, reason);
|
||||
} catch {}
|
||||
}
|
||||
return names.length;
|
||||
}
|
||||
|
||||
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Registered account names according to EasyAuth. */
|
||||
|
||||
Reference in new issue
Block a user