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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user