From deeeaf0042f8cf01b0bef5999ce8d728593a3592 Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Sat, 11 Jul 2026 11:50:27 -0500 Subject: [PATCH] Security page, admin quick controls, and whitelist reload self-healing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- dashboard/src/app/(panel)/page.tsx | 13 + dashboard/src/app/(panel)/players/page.tsx | 66 ++++- dashboard/src/app/(panel)/security/page.tsx | 251 ++++++++++++++++++++ dashboard/src/app/(panel)/settings/page.tsx | 12 + dashboard/src/app/api/admin/route.ts | 72 ++++++ dashboard/src/app/api/security/route.ts | 69 ++++++ dashboard/src/app/api/settings/route.ts | 4 + dashboard/src/components/quickcontrols.tsx | 114 +++++++++ dashboard/src/components/shell.tsx | 2 + dashboard/src/server/authflow.ts | 21 ++ dashboard/src/server/logwatch.ts | 31 ++- dashboard/src/server/mc.ts | 136 +++++++++++ dashboard/src/server/rcon.ts | 21 +- 13 files changed, 809 insertions(+), 3 deletions(-) create mode 100644 dashboard/src/app/(panel)/security/page.tsx create mode 100644 dashboard/src/app/api/admin/route.ts create mode 100644 dashboard/src/app/api/security/route.ts create mode 100644 dashboard/src/components/quickcontrols.tsx diff --git a/dashboard/src/app/(panel)/page.tsx b/dashboard/src/app/(panel)/page.tsx index d5e4894..86e26dd 100644 --- a/dashboard/src/app/(panel)/page.tsx +++ b/dashboard/src/app/(panel)/page.tsx @@ -6,6 +6,7 @@ import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react" import { api, useApi, useSSE, toast } from "@/lib/api"; import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui"; import { Sparkline, UsageBar, type Point } from "@/components/charts"; +import { QuickControls } from "@/components/quickcontrols"; import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format"; type ServerData = { @@ -50,6 +51,15 @@ const ACTIVITY_LABELS: Record )} + {/* admin quick controls */} + + {/* world + disk */}
diff --git a/dashboard/src/app/(panel)/players/page.tsx b/dashboard/src/app/(panel)/players/page.tsx index 695837a..cb7d236 100644 --- a/dashboard/src/app/(panel)/players/page.tsx +++ b/dashboard/src/app/(panel)/players/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Users, UserX, Wifi } from "lucide-react"; +import { Users, UserX, Wifi, Crown, Gavel } from "lucide-react"; import { api, useApi, useSSE, toast } from "@/lib/api"; import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui"; import { timeAgo, fmtDateTime } from "@/lib/format"; @@ -31,6 +31,33 @@ export default function PlayersPage() { const { data: joins, refresh: refreshJoins } = useApi("/api/activity?limit=30", 20000); const [detail, setDetail] = useState(null); const [kicking, setKicking] = useState(null); + const [adminBusy, setAdminBusy] = useState(null); + + const adminAction = async (label: string, body: Record, message: string) => { + setAdminBusy(label); + try { + await api("/api/admin", { body }); + toast(message, "success"); + } catch (err) { + toast((err as Error).message, "error"); + } finally { + setAdminBusy(null); + } + }; + + const banPlayer = async (name: string) => { + setAdminBusy("ban"); + try { + await api("/api/security", { body: { op: "ban-player", name, reason: "Banned by the admin" } }); + toast(`${name} banned`, "warn"); + setDetail(null); + void refresh(); + } catch (err) { + toast((err as Error).message, "error"); + } finally { + setAdminBusy(null); + } + }; useSSE("/api/stream/events", { "mc-event": () => { @@ -193,6 +220,43 @@ export default function PlayersPage() {
UUID: {detail.uuid}
Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}
+ + {/* admin actions */} +
+ {detail.isOnline && ( + <> + {(["survival", "creative", "spectator"] as const).map((mode) => ( + + ))} + + + )} + + + + +
)} diff --git a/dashboard/src/app/(panel)/security/page.tsx b/dashboard/src/app/(panel)/security/page.tsx new file mode 100644 index 0000000..020cc3e --- /dev/null +++ b/dashboard/src/app/(panel)/security/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState } from "react"; +import { ShieldAlert, Ban, Undo2, WifiOff, DoorClosed } from "lucide-react"; +import { api, useApi, useSSE, toast } from "@/lib/api"; +import { Card, Button, Badge, Input, Modal, EmptyState } from "@/components/ui"; +import { timeAgo } from "@/lib/format"; + +type BlockedRow = { player: string; ip: string | null; attempts: number; last_ts: number; first_ts: number }; +type BanEntry = { name?: string; ip?: string; created?: string; reason?: string; source?: string }; +type SecurityData = { blocked: BlockedRow[]; bans: { players: BanEntry[]; ips: BanEntry[] } }; + +export default function SecurityPage() { + const { data, refresh } = useApi("/api/security", 15000); + const [busy, setBusy] = useState(null); + const [banIpTarget, setBanIpTarget] = useState(null); + const [manualBan, setManualBan] = useState({ name: "", ip: "", reason: "" }); + const [lockdownOpen, setLockdownOpen] = useState(false); + + useSSE("/api/stream/events", { + "mc-event": (e) => { + if ((e as { type: string }).type === "blocked") void refresh(); + }, + }); + + const run = async (label: string, body: Record, message: string) => { + setBusy(label); + try { + await api("/api/security", { body }); + toast(message, "success"); + void refresh(); + } catch (err) { + toast((err as Error).message, "error"); + } finally { + setBusy(null); + } + }; + + const blocked = data?.blocked ?? []; + const bannedIps = new Set((data?.bans.ips ?? []).map((b) => b.ip)); + + return ( +
+
+
+

Security

+

+ Blocked join attempts, bans, and emergency controls. The whitelist already rejects everyone you + haven't approved — bans add a second wall and cut connection noise. +

+
+ +
+ + {/* blocked attempts */} + + {blocked.length === 0 ? ( + } + title="No blocked attempts recorded" + hint="When someone who isn't approved tries to join (usually internet scanner bots), they show up here with their IP." + /> + ) : ( + + + + + + + + + + + + {blocked.map((b) => ( + + + + + + + + ))} + +
Username triedFrom IPAttemptsLast seenAction
{b.player}{b.ip ?? "unknown"}{b.attempts}{timeAgo(b.last_ts)} + {b.ip && + (bannedIps.has(b.ip) ? ( + IP banned + ) : ( + + ))} +
+ )} +
+ +
+ {/* banned players */} + + {(data?.bans.players ?? []).length === 0 ? ( +

Nobody is banned.

+ ) : ( +
    + {data!.bans.players.map((b) => ( +
  • + + {b.name} + {b.reason && {b.reason}} + + + +
  • + ))} +
+ )} +
+ setManualBan({ ...manualBan, name: e.target.value })} + className="flex-1 font-mono" + maxLength={16} + /> + +
+
+ + {/* banned IPs */} + + {(data?.bans.ips ?? []).length === 0 ? ( +

No IP bans.

+ ) : ( +
    + {data!.bans.ips.map((b) => ( +
  • + + {b.ip} + {b.reason && {b.reason}} + + + +
  • + ))} +
+ )} +
+ setManualBan({ ...manualBan, ip: e.target.value })} + className="flex-1 font-mono" + /> + +
+
+
+ +

+ Tip: scanner bots find servers by sweeping the default port. Changing MC_PORT in + Configuration (e.g. to 25599) makes most of this noise disappear — friends then connect with{" "} + ip:port. +

+ + {/* ban IP confirm */} + setBanIpTarget(null)} title={`Ban IP ${banIpTarget}?`}> +

+ Every connection from this address will be rejected before login. Careful: if this is a friend behind + the same network as others, they all get blocked. +

+
+ + +
+
+ + {/* lockdown confirm */} + setLockdownOpen(false)} title="Kick everyone?"> +

+ Disconnects every online player immediately. The whitelist keeps un-approved people out as always; + approved players can rejoin right away unless you also revoke them. +

+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/settings/page.tsx b/dashboard/src/app/(panel)/settings/page.tsx index 0c77431..9e0c09e 100644 --- a/dashboard/src/app/(panel)/settings/page.tsx +++ b/dashboard/src/app/(panel)/settings/page.tsx @@ -9,8 +9,10 @@ import { fmtBytes } from "@/lib/format"; type Settings = { registrationWindowMinutes: number; notifyJoins: boolean; + notifyBlocked: boolean; discordEnabled: boolean; discordJoins: boolean; + discordBlocked: boolean; discordWebhookUrl: string; }; @@ -156,6 +158,11 @@ export default function SettingsPage() { onChange={(v) => setValues((s) => (s ? { ...s, notifyJoins: v } : s))} label="Notify on player joins/leaves" /> + setValues((s) => (s ? { ...s, notifyBlocked: v } : s))} + label="Notify on blocked join attempts (bots/strangers)" + />
@@ -184,6 +191,11 @@ export default function SettingsPage() { onChange={(v) => setValues((s) => (s ? { ...s, discordJoins: v } : s))} label="Include joins/leaves" /> + setValues((s) => (s ? { ...s, discordBlocked: v } : s))} + label="Include blocked join attempts" + /> + + + + setBroadcast(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && broadcast.trim()) { + void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent"); + setBroadcast(""); + } + }} + placeholder="Broadcast a message to everyone…" + className="flex-1" + maxLength={256} + /> + + + + + ); +} diff --git a/dashboard/src/components/shell.tsx b/dashboard/src/components/shell.tsx index 6b24467..0830195 100644 --- a/dashboard/src/components/shell.tsx +++ b/dashboard/src/components/shell.tsx @@ -8,6 +8,7 @@ import { TerminalSquare, Users, ShieldCheck, + ShieldAlert, Activity, Package, Archive, @@ -28,6 +29,7 @@ const NAV = [ { href: "/console", label: "Console", icon: TerminalSquare }, { href: "/players", label: "Players", icon: Users }, { href: "/access", label: "Access & Auth", icon: ShieldCheck }, + { href: "/security", label: "Security", icon: ShieldAlert }, { href: "/performance", label: "Performance", icon: Activity }, { href: "/mods", label: "Mods", icon: Package }, { href: "/backups", label: "Backups", icon: Archive }, diff --git a/dashboard/src/server/authflow.ts b/dashboard/src/server/authflow.ts index 4990fb4..0383af6 100644 --- a/dashboard/src/server/authflow.ts +++ b/dashboard/src/server/authflow.ts @@ -8,6 +8,7 @@ import { whitelistAdd, whitelistRemove, whitelistList, + whitelistLive, kickPlayer, easyauthList, easyauthRemove, @@ -174,6 +175,26 @@ async function reconcile() { return; } + // Self-heal: make sure the server's LIVE whitelist actually contains every + // pending player. A `whitelist reload` can get lost (stale RCON connection, + // server restart mid-approval) — without this check the player sits at + // "You are not white-listed" while their window burns down. + try { + const live = new Set((await whitelistLive()).map((n) => n.toLowerCase())); + const missing = pending.filter((p) => !live.has(p.username.toLowerCase())); + if (missing.length > 0) { + for (const p of missing) await whitelistAdd(p.username); // rewrites file + reloads + // They couldn't have joined yet — give the windows back the lost time. + db() + .prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'") + .run(); + logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", ")); + return; + } + } catch { + // whitelist list unavailable — fall through and try again next tick + } + const now = Date.now(); for (const p of pending) { if (registered.has(p.username.toLowerCase())) { diff --git a/dashboard/src/server/logwatch.ts b/dashboard/src/server/logwatch.ts index 6878085..ca4dc5e 100644 --- a/dashboard/src/server/logwatch.ts +++ b/dashboard/src/server/logwatch.ts @@ -24,6 +24,19 @@ function messageOf(line: string): string { return m ? m[1] : line; } +// Bots hammer the port — notify at most once per name+IP per 10 minutes. +// (Every attempt is still recorded in the activity feed.) +const blockedNotifyAt = new Map(); + +function shouldNotifyBlocked(key: string): boolean { + const now = Date.now(); + const last = blockedNotifyAt.get(key) ?? 0; + if (now - last < 10 * 60 * 1000) return false; + blockedNotifyAt.set(key, now); + if (blockedNotifyAt.size > 500) blockedNotifyAt.clear(); + return true; +} + function parseEvent(line: string, backfill: boolean) { const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, ""); @@ -54,7 +67,23 @@ function parseEvent(line: string, backfill: boolean) { }; let m; - if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) joined the game/))) { + if ( + (m = msg.match( + /^Disconnecting ([A-Za-z0-9_]{3,16}) \(\/([0-9a-fA-F.:]+):\d+\): (You are not white-listed[^!]*!?|.*banned.*)/i + )) + ) { + // Somebody the panel never approved knocked on the door (usually an + // internet scanner bot). Record it so the admin can see and IP-ban them. + if (!backfill) { + logActivity("blocked", m[1], m[2]); + bus().emit("mc-event", { ts: Date.now(), type: "blocked", player: m[1], detail: m[2] }); + if (getBoolSetting("notify_blocked", true) && shouldNotifyBlocked(`${m[1]}@${m[2]}`)) { + notify("warn", `Blocked join attempt: ${m[1]}`, `From ${m[2]} — ${m[3]}`, { + discord: getBoolSetting("discord_blocked", true), + }); + } + } + } else if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) joined the game/))) { emit("join", m[1]); } else if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) left the game/))) { emit("leave", m[1]); diff --git a/dashboard/src/server/mc.ts b/dashboard/src/server/mc.ts index b5125fb..5471dd9 100644 --- a/dashboard/src/server/mc.ts +++ b/dashboard/src/server/mc.ts @@ -125,6 +125,142 @@ export async function whitelistList(): Promise { } } +/** + * 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 { + 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 { + 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 { + assertValidUsername(name); + return stripColors(await rcon().exec(`pardon ${name}`)); +} + +export async function banIp(ip: string, reason = "Banned by the admin"): Promise { + assertValidIp(ip); + return stripColors(await rcon().exec(`ban-ip ${ip} ${sanitizeReason(reason)}`)); +} + +export async function pardonIp(ip: string): Promise { + 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 { + 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 { + assertValidUsername(name); + return stripColors(await rcon().exec(`${grant ? "op" : "deop"} ${name}`)); +} + +export async function setTime(value: "day" | "night" | "noon" | "midnight"): Promise { + 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 { + if (!["clear", "rain", "thunder"].includes(value)) throw new Error("Invalid weather"); + await rcon().exec(`weather ${value}`); +} + +export async function setDifficulty(value: string): Promise { + if (!DIFFICULTIES.has(value)) throw new Error("Invalid difficulty"); + return stripColors(await rcon().exec(`difficulty ${value}`)); +} + +export async function getGamerules(): Promise> { + const out: Record = {}; + 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 { + 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 { + 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. */ diff --git a/dashboard/src/server/rcon.ts b/dashboard/src/server/rcon.ts index 7d77e1b..2ff83f9 100644 --- a/dashboard/src/server/rcon.ts +++ b/dashboard/src/server/rcon.ts @@ -137,7 +137,7 @@ class RconClient { if (next) next(); } - exec(command: string): Promise { + private execOnce(command: string): Promise { return new Promise((resolve, reject) => { const run = async () => { try { @@ -155,6 +155,9 @@ class RconClient { reject, hardTimer: setTimeout(() => { this.pending = null; + // A timeout usually means the connection went stale (server + // restarted, long idle). Drop it so the next attempt reconnects. + this.teardown(); reject(new RconError("RCON command timeout", "timeout")); this.drain(); }, 10_000), @@ -165,6 +168,22 @@ class RconClient { else void run(); }); } + + /** + * Run a command, retrying once on a fresh connection if the first attempt + * fails. A panel that sits idle overnight often holds a dead TCP socket + * (server restart, container recreate) — the first write then times out or + * errors even though the server is fine. + */ + async exec(command: string): Promise { + try { + return await this.execOnce(command); + } catch (err) { + if (!(err instanceof RconError) || err.kind === "auth") throw err; + this.teardown(); + return this.execOnce(command); + } + } } const g = globalThis as unknown as { __craftdeckRcon?: RconClient };