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

+13
View File
@@ -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<string, { label: string; color: "good" | "warn" |
power: { label: "power", color: "muted" },
console: { label: "console", color: "muted" },
world_reset: { label: "world reset", color: "crit" },
blocked: { label: "blocked", color: "crit" },
ban: { label: "banned", color: "crit" },
ban_ip: { label: "IP banned", color: "crit" },
pardon: { label: "unbanned", color: "info" },
pardon_ip: { label: "IP unbanned", color: "info" },
kick_all: { label: "kicked all", color: "warn" },
admin: { label: "admin", color: "info" },
whitelist_heal: { label: "whitelist re-synced", color: "warn" },
password_reset: { label: "password reset", color: "info" },
};
export default function OverviewPage() {
@@ -218,6 +228,9 @@ export default function OverviewPage() {
</Card>
)}
{/* admin quick controls */}
<QuickControls running={running} />
{/* world + disk */}
<Card title="World & storage">
<div className="space-y-3">
+65 -1
View File
@@ -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<ActivityRow[]>("/api/activity?limit=30", 20000);
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState<string | null>(null);
const adminAction = async (label: string, body: Record<string, unknown>, 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() {
<div>UUID: <code>{detail.uuid}</code></div>
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
</div>
{/* admin actions */}
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
{detail.isOnline && (
<>
{(["survival", "creative", "spectator"] as const).map((mode) => (
<Button
key={mode}
size="sm"
busy={adminBusy === `gm-${mode}`}
onClick={() => void adminAction(`gm-${mode}`, { op: "gamemode", name: detail.name, mode }, `${detail.name} → ${mode}`)}
>
{mode}
</Button>
))}
<span className="mx-1 h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
busy={adminBusy === "op"}
onClick={() => void adminAction("op", { op: "op", name: detail.name, grant: true }, `${detail.name} is now an operator`)}
>
<Crown size={12} /> Op
</Button>
<Button
size="sm"
busy={adminBusy === "deop"}
onClick={() => void adminAction("deop", { op: "op", name: detail.name, grant: false }, `${detail.name} de-opped`)}
>
De-op
</Button>
<span className="mx-1 h-4 w-px bg-border" />
<Button size="sm" variant="danger" busy={adminBusy === "ban"} onClick={() => void banPlayer(detail.name)}>
<Gavel size={12} /> Ban
</Button>
</div>
</div>
)}
</Modal>
+251
View File
@@ -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<SecurityData>("/api/security", 15000);
const [busy, setBusy] = useState<string | null>(null);
const [banIpTarget, setBanIpTarget] = useState<string | null>(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<string, unknown>, 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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Security</h1>
<p className="mt-0.5 text-xs text-ink2">
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.
</p>
</div>
<Button variant="danger" onClick={() => setLockdownOpen(true)}>
<DoorClosed size={14} /> Kick everyone
</Button>
</div>
{/* blocked attempts */}
<Card title={`Blocked join attempts — last 7 days (${blocked.length})`} pad={false}>
{blocked.length === 0 ? (
<EmptyState
icon={<ShieldAlert size={28} />}
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."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Username tried</th>
<th className="px-4 py-2 font-medium">From IP</th>
<th className="px-4 py-2 font-medium">Attempts</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{blocked.map((b) => (
<tr key={`${b.player}-${b.ip}`} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono font-medium">{b.player}</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{b.ip ?? "unknown"}</td>
<td className="px-4 py-2.5 text-ink2">{b.attempts}</td>
<td className="px-4 py-2.5 text-ink2">{timeAgo(b.last_ts)}</td>
<td className="px-4 py-2.5 text-right">
{b.ip &&
(bannedIps.has(b.ip) ? (
<Badge color="crit">IP banned</Badge>
) : (
<Button size="sm" variant="danger" onClick={() => setBanIpTarget(b.ip)}>
<WifiOff size={12} /> Ban IP
</Button>
))}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{/* banned players */}
<Card title={`Banned players (${data?.bans.players.length ?? 0})`}>
{(data?.bans.players ?? []).length === 0 ? (
<p className="py-4 text-center text-xs text-muted">Nobody is banned.</p>
) : (
<ul className="space-y-1.5">
{data!.bans.players.map((b) => (
<li key={b.name} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2 text-sm">
<Ban size={13} className="text-crit" />
<span className="font-mono font-medium">{b.name}</span>
{b.reason && <span className="truncate text-[11px] text-muted">{b.reason}</span>}
<span className="ml-auto">
<Button
size="sm"
busy={busy === `pardon-${b.name}`}
onClick={() =>
void run(`pardon-${b.name}`, { op: "pardon-player", name: b.name }, `${b.name} unbanned`)
}
>
<Undo2 size={12} /> Unban
</Button>
</span>
</li>
))}
</ul>
)}
<div className="mt-3 flex gap-2 border-t border-border pt-3">
<Input
placeholder="Username to ban"
value={manualBan.name}
onChange={(e) => setManualBan({ ...manualBan, name: e.target.value })}
className="flex-1 font-mono"
maxLength={16}
/>
<Button
variant="danger"
size="sm"
disabled={!manualBan.name.trim()}
busy={busy === "manual-ban"}
onClick={() =>
void run(
"manual-ban",
{ op: "ban-player", name: manualBan.name.trim(), reason: manualBan.reason || undefined },
`${manualBan.name.trim()} banned`
)
}
>
<Ban size={12} /> Ban
</Button>
</div>
</Card>
{/* banned IPs */}
<Card title={`Banned IPs (${data?.bans.ips.length ?? 0})`}>
{(data?.bans.ips ?? []).length === 0 ? (
<p className="py-4 text-center text-xs text-muted">No IP bans.</p>
) : (
<ul className="space-y-1.5">
{data!.bans.ips.map((b) => (
<li key={b.ip} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2 text-sm">
<WifiOff size={13} className="text-crit" />
<span className="font-mono font-medium">{b.ip}</span>
{b.reason && <span className="truncate text-[11px] text-muted">{b.reason}</span>}
<span className="ml-auto">
<Button
size="sm"
busy={busy === `pardon-ip-${b.ip}`}
onClick={() =>
void run(`pardon-ip-${b.ip}`, { op: "pardon-ip", ip: b.ip }, `${b.ip} unbanned`)
}
>
<Undo2 size={12} /> Unban
</Button>
</span>
</li>
))}
</ul>
)}
<div className="mt-3 flex gap-2 border-t border-border pt-3">
<Input
placeholder="IP address to ban"
value={manualBan.ip}
onChange={(e) => setManualBan({ ...manualBan, ip: e.target.value })}
className="flex-1 font-mono"
/>
<Button
variant="danger"
size="sm"
disabled={!manualBan.ip.trim()}
busy={busy === "manual-ban-ip"}
onClick={() =>
void run("manual-ban-ip", { op: "ban-ip", ip: manualBan.ip.trim() }, `${manualBan.ip.trim()} banned`)
}
>
<WifiOff size={12} /> Ban IP
</Button>
</div>
</Card>
</div>
<p className="text-[11px] leading-relaxed text-muted">
Tip: scanner bots find servers by sweeping the default port. Changing <code>MC_PORT</code> in
Configuration (e.g. to 25599) makes most of this noise disappear — friends then connect with{" "}
<code>ip:port</code>.
</p>
{/* ban IP confirm */}
<Modal open={banIpTarget !== null} onClose={() => setBanIpTarget(null)} title={`Ban IP ${banIpTarget}?`}>
<p className="text-sm text-ink2">
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.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setBanIpTarget(null)}>Cancel</Button>
<Button
variant="danger"
busy={busy === "ban-ip-modal"}
onClick={() => {
const ip = banIpTarget!;
setBanIpTarget(null);
void run("ban-ip-modal", { op: "ban-ip", ip, reason: "Blocked join attempts" }, `${ip} banned`);
}}
>
Ban this IP
</Button>
</div>
</Modal>
{/* lockdown confirm */}
<Modal open={lockdownOpen} onClose={() => setLockdownOpen(false)} title="Kick everyone?">
<p className="text-sm text-ink2">
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.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setLockdownOpen(false)}>Cancel</Button>
<Button
variant="danger"
busy={busy === "kick-all"}
onClick={() => {
setLockdownOpen(false);
void run("kick-all", { op: "kick-all" }, "Everyone kicked");
}}
>
Kick all players
</Button>
</div>
</Modal>
</div>
);
}
@@ -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"
/>
<Toggle
checked={values?.notifyBlocked ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, notifyBlocked: v } : s))}
label="Notify on blocked join attempts (bots/strangers)"
/>
</div>
</Card>
@@ -184,6 +191,11 @@ export default function SettingsPage() {
onChange={(v) => setValues((s) => (s ? { ...s, discordJoins: v } : s))}
label="Include joins/leaves"
/>
<Toggle
checked={values?.discordBlocked ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, discordBlocked: v } : s))}
label="Include blocked join attempts"
/>
<Button
size="sm"
onClick={async () => {
+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)) {
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import { Sun, Moon, CloudSun, CloudRain, Megaphone } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Select, Toggle } from "@/components/ui";
const GAMERULE_LABELS: Record<string, string> = {
keepInventory: "Keep inventory on death",
mobGriefing: "Mob griefing (creeper damage…)",
doDaylightCycle: "Day/night cycle",
doWeatherCycle: "Weather cycle",
pvp: "PvP",
};
/** One-click admin controls; shown on the Overview when the server runs. */
export function QuickControls({ running }: { running: boolean }) {
const { data, refresh } = useApi<{ gamerules: Record<string, boolean> }>("/api/admin", 0);
const [busy, setBusy] = useState<string | null>(null);
const [broadcast, setBroadcast] = useState("");
const [difficulty, setDifficulty] = useState("");
const run = async (label: string, body: Record<string, unknown>, message?: string) => {
setBusy(label);
try {
await api("/api/admin", { body });
if (message) toast(message, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
if (!running) return null;
return (
<Card title="Quick controls">
<div className="space-y-4">
{/* time & weather */}
<div className="flex flex-wrap gap-1.5">
<Button size="sm" busy={busy === "day"} onClick={() => void run("day", { op: "time", value: "day" }, "Time set to day")}>
<Sun size={12} /> Day
</Button>
<Button size="sm" busy={busy === "night"} onClick={() => void run("night", { op: "time", value: "night" }, "Time set to night")}>
<Moon size={12} /> Night
</Button>
<Button size="sm" busy={busy === "clear"} onClick={() => void run("clear", { op: "weather", value: "clear" }, "Weather cleared")}>
<CloudSun size={12} /> Clear
</Button>
<Button size="sm" busy={busy === "rain"} onClick={() => void run("rain", { op: "weather", value: "rain" }, "Let it rain")}>
<CloudRain size={12} /> Rain
</Button>
<Select
value={difficulty}
onChange={(v) => {
setDifficulty(v);
if (v) void run("difficulty", { op: "difficulty", value: v }, `Difficulty: ${v}`);
}}
options={[
{ value: "", label: "difficulty…" },
{ value: "peaceful", label: "peaceful" },
{ value: "easy", label: "easy" },
{ value: "normal", label: "normal" },
{ value: "hard", label: "hard" },
]}
/>
</div>
{/* gamerules */}
<div className="grid grid-cols-1 gap-2 border-t border-border pt-3 sm:grid-cols-2">
{Object.entries(GAMERULE_LABELS).map(([rule, label]) => (
<Toggle
key={rule}
checked={data?.gamerules[rule] ?? false}
onChange={(v) => void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`)}
label={label}
/>
))}
</div>
{/* broadcast */}
<div className="flex gap-2 border-t border-border pt-3">
<Input
value={broadcast}
onChange={(e) => 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}
/>
<Button
size="sm"
variant="primary"
disabled={!broadcast.trim()}
busy={busy === "broadcast"}
onClick={() => {
void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent");
setBroadcast("");
}}
>
<Megaphone size={12} /> Send
</Button>
</div>
</div>
</Card>
);
}
+2
View File
@@ -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 },
+21
View File
@@ -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())) {
+30 -1
View File
@@ -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<string, number>();
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]);
+136
View File
@@ -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. */
+20 -1
View File
@@ -137,7 +137,7 @@ class RconClient {
if (next) next();
}
exec(command: string): Promise<string> {
private execOnce(command: string): Promise<string> {
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<string> {
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 };