Compare commits
1
Commits
No files matched your search
@@ -2,3 +2,6 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
# Local dev fixtures — MUST NOT ship in images. dev-data/app/craftdeck.db
|
||||||
|
# contains a real scrypt admin password hash (see docs/public-mode.md).
|
||||||
|
dev-data
|
||||||
@@ -46,6 +46,9 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/public ./public
|
||||||
|
# Feed exporter for the public read-only mirror (uses only Node built-ins;
|
||||||
|
# run via the feed-export sidecar in docker-compose.yml).
|
||||||
|
COPY --from=build /app/scripts ./scripts
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
|||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* Feed exporter for the CraftDeck public read-only mirror.
|
||||||
|
*
|
||||||
|
* Reads the live panel database (READ-ONLY) and writes a small, sanitised
|
||||||
|
* SQLite "feed" file that the PUBLIC_READONLY dashboard serves from. The feed
|
||||||
|
* contains ONLY:
|
||||||
|
*
|
||||||
|
* metrics – the sampler time-series (last 7 days)
|
||||||
|
* activity – join/leave events only, as (id, ts, type, player); the
|
||||||
|
* free-text `detail` column (which can hold IPs on connect/
|
||||||
|
* blocked rows) is deliberately dropped
|
||||||
|
* feed_meta – one small status JSON blob (running, online names, counts)
|
||||||
|
*
|
||||||
|
* It NEVER copies panel_users, approved_players, settings, notifications,
|
||||||
|
* schedules or ping_samples, so none of those can leak to the public app.
|
||||||
|
*
|
||||||
|
* The file is written to a temp path and atomically renamed into place, so
|
||||||
|
* readers never observe a half-written feed. No external dependencies — only
|
||||||
|
* the built-in node:sqlite module.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* SOURCE_DB path to the live panel DB (default: dev-data/app/craftdeck.db)
|
||||||
|
* FEED_PATH path to write the feed to (default: dev-data/feed/feed.db)
|
||||||
|
* MC_VERSION optional version string for the status blob
|
||||||
|
* SERVER_TYPE optional server type string for the status blob
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SOURCE_DB = process.env.SOURCE_DB || "dev-data/app/craftdeck.db";
|
||||||
|
const FEED_PATH = process.env.FEED_PATH || "dev-data/feed/feed.db";
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
console.log(`[export-feed] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(SOURCE_DB)) {
|
||||||
|
console.error(`[export-feed] source DB not found: ${SOURCE_DB}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = new DatabaseSync(SOURCE_DB, { readOnly: true });
|
||||||
|
|
||||||
|
// ── derive current status from the panel DB (no Docker/RCON) ──────────────
|
||||||
|
const now = Date.now();
|
||||||
|
const latest =
|
||||||
|
/** @type {any} */ (src.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get()) || null;
|
||||||
|
|
||||||
|
// The sampler writes a row every 10s; a fresh row with cpu present means the
|
||||||
|
// mc container was up at sample time.
|
||||||
|
const fresh = latest && now - Number(latest.ts) < 60_000;
|
||||||
|
const running = Boolean(fresh && latest.cpu != null);
|
||||||
|
|
||||||
|
// Reconstruct who is currently online by replaying join/leave since the most
|
||||||
|
// recent server start (a restart clears everyone).
|
||||||
|
let onlineNames = [];
|
||||||
|
if (running) {
|
||||||
|
const lastStart = /** @type {any} */ (
|
||||||
|
src
|
||||||
|
.prepare("SELECT MAX(ts) AS ts FROM activity WHERE type IN ('server_online')")
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
const sinceTs = lastStart && lastStart.ts ? Number(lastStart.ts) : now - 24 * 60 * 60 * 1000;
|
||||||
|
const events = /** @type {any[]} */ (
|
||||||
|
src
|
||||||
|
.prepare(
|
||||||
|
"SELECT ts, type, player FROM activity WHERE ts >= ? AND type IN ('join','leave') ORDER BY ts ASC"
|
||||||
|
)
|
||||||
|
.all(sinceTs)
|
||||||
|
);
|
||||||
|
const set = new Set();
|
||||||
|
for (const e of events) {
|
||||||
|
if (!e.player) continue;
|
||||||
|
if (e.type === "join") set.add(e.player);
|
||||||
|
else set.delete(e.player);
|
||||||
|
}
|
||||||
|
onlineNames = [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
const playersOnline = running ? Number(latest?.players ?? onlineNames.length) || onlineNames.length : 0;
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
generatedAt: now,
|
||||||
|
running,
|
||||||
|
playersOnline,
|
||||||
|
playersMax: 0,
|
||||||
|
playerNames: onlineNames,
|
||||||
|
version: process.env.MC_VERSION || null,
|
||||||
|
serverType: process.env.SERVER_TYPE || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── build the feed in a temp file, then atomically swap it in ─────────────
|
||||||
|
fs.mkdirSync(path.dirname(FEED_PATH), { recursive: true });
|
||||||
|
const tmp = `${FEED_PATH}.tmp-${process.pid}`;
|
||||||
|
for (const p of [tmp, `${tmp}-wal`, `${tmp}-shm`, `${tmp}-journal`]) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(p);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new DatabaseSync(tmp);
|
||||||
|
out.exec("PRAGMA journal_mode = DELETE");
|
||||||
|
out.exec(`
|
||||||
|
CREATE TABLE metrics (
|
||||||
|
ts INTEGER PRIMARY KEY,
|
||||||
|
cpu REAL, mem_used INTEGER, mem_limit INTEGER,
|
||||||
|
net_rx INTEGER, net_tx INTEGER,
|
||||||
|
tps REAL, mspt REAL, players INTEGER,
|
||||||
|
disk_used INTEGER, disk_free INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE activity (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
player TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_activity_ts ON activity(ts DESC);
|
||||||
|
CREATE TABLE feed_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const since = now - WEEK_MS;
|
||||||
|
|
||||||
|
const metricRows = /** @type {any[]} */ (
|
||||||
|
src.prepare("SELECT * FROM metrics WHERE ts > ? ORDER BY ts ASC").all(since)
|
||||||
|
);
|
||||||
|
const insMetric = out.prepare(
|
||||||
|
`INSERT INTO metrics(ts, cpu, mem_used, mem_limit, net_rx, net_tx, tps, mspt, players, disk_used, disk_free)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const m of metricRows) {
|
||||||
|
insMetric.run(
|
||||||
|
m.ts, m.cpu, m.mem_used, m.mem_limit, m.net_rx, m.net_tx,
|
||||||
|
m.tps, m.mspt, m.players, m.disk_used, m.disk_free
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only join/leave — the `detail` column is intentionally not carried over.
|
||||||
|
const activityRows = /** @type {any[]} */ (
|
||||||
|
src
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, ts, type, player FROM activity WHERE ts > ? AND type IN ('join','leave') ORDER BY ts ASC"
|
||||||
|
)
|
||||||
|
.all(since)
|
||||||
|
);
|
||||||
|
const insActivity = out.prepare("INSERT INTO activity(id, ts, type, player) VALUES(?, ?, ?, ?)");
|
||||||
|
for (const a of activityRows) insActivity.run(a.id, a.ts, a.type, a.player);
|
||||||
|
|
||||||
|
out.prepare("INSERT INTO feed_meta(key, value) VALUES('status', ?)").run(JSON.stringify(status));
|
||||||
|
|
||||||
|
out.close();
|
||||||
|
src.close();
|
||||||
|
|
||||||
|
// Atomic replace (same filesystem). Clean up any leftover side files.
|
||||||
|
for (const p of [`${FEED_PATH}-wal`, `${FEED_PATH}-shm`, `${FEED_PATH}-journal`]) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(p);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
fs.renameSync(tmp, FEED_PATH);
|
||||||
|
|
||||||
|
log(
|
||||||
|
`wrote ${FEED_PATH}: ${metricRows.length} metrics, ${activityRows.length} join/leave, ` +
|
||||||
|
`running=${running}, online=${playersOnline}`
|
||||||
|
);
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Shell } from "@/components/shell";
|
import { Shell } from "@/components/shell";
|
||||||
|
|
||||||
|
// Resolve PUBLIC_READONLY at request time (not build time) so the same image
|
||||||
|
// serves either mode depending on its runtime environment.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <Shell>{children}</Shell>;
|
const publicMode = process.env.PUBLIC_READONLY === "1";
|
||||||
|
return <Shell publicMode={publicMode}>{children}</Shell>;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
|
|||||||
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
||||||
import { QuickControls } from "@/components/quickcontrols";
|
import { QuickControls } from "@/components/quickcontrols";
|
||||||
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type ServerData = {
|
type ServerData = {
|
||||||
dockerAvailable: boolean;
|
dockerAvailable: boolean;
|
||||||
@@ -64,12 +65,15 @@ const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" |
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function OverviewPage() {
|
export default function OverviewPage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||||
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||||
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
|
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
|
||||||
|
// /api/approved is admin-only (404 in public mode) — don't request it.
|
||||||
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
|
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
|
||||||
"/api/approved",
|
"/api/approved",
|
||||||
15000
|
15000,
|
||||||
|
{ enabled: !publicMode }
|
||||||
);
|
);
|
||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [, forceTick] = useState(0);
|
const [, forceTick] = useState(0);
|
||||||
@@ -113,6 +117,7 @@ export default function OverviewPage() {
|
|||||||
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!publicMode && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -141,6 +146,7 @@ export default function OverviewPage() {
|
|||||||
<Square size={13} /> Stop
|
<Square size={13} /> Stop
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!server?.dockerAvailable && server && (
|
{!server?.dockerAvailable && server && (
|
||||||
@@ -230,7 +236,7 @@ export default function OverviewPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* admin quick controls */}
|
{/* admin quick controls */}
|
||||||
<QuickControls running={running} />
|
{!publicMode && <QuickControls running={running} />}
|
||||||
|
|
||||||
{/* world + disk */}
|
{/* world + disk */}
|
||||||
<Card title="World & storage">
|
<Card title="World & storage">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api, useApi, toast } from "@/lib/api";
|
|||||||
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
|
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
|
||||||
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
||||||
import { fmtBytes } from "@/lib/format";
|
import { fmtBytes } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type MetricRow = {
|
type MetricRow = {
|
||||||
bucket: number;
|
bucket: number;
|
||||||
@@ -66,6 +67,7 @@ const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PerformancePage() {
|
export default function PerformancePage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const [range, setRange] = useState("1h");
|
const [range, setRange] = useState("1h");
|
||||||
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
||||||
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
||||||
@@ -116,9 +118,11 @@ export default function PerformancePage() {
|
|||||||
<Card
|
<Card
|
||||||
title="Lag diagnosis"
|
title="Lag diagnosis"
|
||||||
actions={
|
actions={
|
||||||
|
publicMode ? undefined : (
|
||||||
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||||
<Stethoscope size={13} /> Full health report
|
<Stethoscope size={13} /> Full health report
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start gap-4">
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Users, UserX, Wifi, Crown, Gavel, HeartPulse, MapPin, Gift } from "luci
|
|||||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
||||||
import { timeAgo, fmtDateTime } from "@/lib/format";
|
import { timeAgo, fmtDateTime } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type PlayerRow = {
|
type PlayerRow = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +28,7 @@ type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
|
|||||||
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||||
|
|
||||||
export default function PlayersPage() {
|
export default function PlayersPage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
||||||
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
||||||
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
||||||
@@ -129,7 +131,7 @@ export default function PlayersPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{players.map((p) => (
|
{players.map((p) => (
|
||||||
<tr
|
<tr
|
||||||
key={p.uuid}
|
key={p.uuid || p.name}
|
||||||
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
||||||
onClick={() => setDetail(p)}
|
onClick={() => setDetail(p)}
|
||||||
>
|
>
|
||||||
@@ -149,7 +151,7 @@ export default function PlayersPage() {
|
|||||||
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
{p.isOnline && (
|
{!publicMode && p.isOnline && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -226,6 +228,7 @@ export default function PlayersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* admin actions */}
|
{/* admin actions */}
|
||||||
|
{!publicMode && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||||
{detail.isOnline && (
|
{detail.isOnline && (
|
||||||
<>
|
<>
|
||||||
@@ -261,9 +264,10 @@ export default function PlayersPage() {
|
|||||||
<Gavel size={12} /> Ban
|
<Gavel size={12} /> Ban
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* creative admin favors (online players only) */}
|
{/* creative admin favors (online players only) */}
|
||||||
{detail.isOnline && (
|
{!publicMode && detail.isOnline && (
|
||||||
<div className="mt-3 space-y-2 border-t border-border pt-3">
|
<div className="mt-3 space-y-2 border-t border-border pt-3">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/server/db";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { env } from "@/server/env";
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// The feed only ever contains join/leave rows, so the public mirror is
|
||||||
|
// naturally limited to that; the live DB serves the full activity log.
|
||||||
|
async function source(): Promise<DatabaseSync | null> {
|
||||||
|
if (env.publicReadonly) return (await import("@/server/feed")).feedDb();
|
||||||
|
return (await import("@/server/db")).db();
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const database = await source();
|
||||||
|
if (!database) return ok([]);
|
||||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||||
const type = req.nextUrl.searchParams.get("type");
|
const type = req.nextUrl.searchParams.get("type");
|
||||||
const rows = type
|
const rows = type
|
||||||
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
? database
|
||||||
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
.prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?")
|
||||||
|
.all(type, limit)
|
||||||
|
: database.prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||||
return ok(rows);
|
return ok(rows);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { db } from "@/server/db";
|
import { env } from "@/server/env";
|
||||||
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -9,8 +8,40 @@ export const dynamic = "force-dynamic";
|
|||||||
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
||||||
* a verdict the admin can read at a glance.
|
* a verdict the admin can read at a glance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public mirror: only the server-side tick health (from the feed's metrics)
|
||||||
|
* is derived. There are no per-player ping samples in the feed, so the
|
||||||
|
* connection section is intentionally empty — no RCON, no ping_samples.
|
||||||
|
*/
|
||||||
|
async function publicLag() {
|
||||||
|
const { feedDb } = await import("@/server/feed");
|
||||||
|
const database = feedDb();
|
||||||
|
const recent = database
|
||||||
|
? (database
|
||||||
|
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||||
|
.all(Date.now() - 3 * 60 * 1000) as Array<{
|
||||||
|
tps: number | null;
|
||||||
|
mspt: number | null;
|
||||||
|
cpu: number | null;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||||
|
const tps = avg(recent.map((r) => r.tps).filter((v): v is number => v !== null));
|
||||||
|
const mspt = avg(recent.map((r) => r.mspt).filter((v): v is number => v !== null));
|
||||||
|
const cpu = avg(recent.map((r) => r.cpu).filter((v): v is number => v !== null));
|
||||||
|
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
||||||
|
const verdict = tps === null && mspt === null ? "no-data" : serverHealthy ? "all-good" : "server-lagging";
|
||||||
|
return ok({ tps, mspt, cpu, serverHealthy, verdict, players: [] });
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicLag);
|
||||||
|
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const { db } = await import("@/server/db");
|
||||||
|
const { sparkPings, listOnlinePlayers } = await import("@/server/mc");
|
||||||
|
|
||||||
const recent = db()
|
const recent = db()
|
||||||
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||||
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
|
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/server/db";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { env } from "@/server/env";
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -11,12 +12,20 @@ const RANGES: Record<string, { ms: number; bucketMs: number }> = {
|
|||||||
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Read from the exported feed in public mode, the live panel DB otherwise.
|
||||||
|
async function source(): Promise<DatabaseSync | null> {
|
||||||
|
if (env.publicReadonly) return (await import("@/server/feed")).feedDb();
|
||||||
|
return (await import("@/server/db")).db();
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const database = await source();
|
||||||
|
if (!database) return ok([]);
|
||||||
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||||
const since = Date.now() - range.ms;
|
const since = Date.now() - range.ms;
|
||||||
// Bucket-average so long ranges stay light on the wire.
|
// Bucket-average so long ranges stay light on the wire.
|
||||||
const rows = db()
|
const rows = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT (ts / ?) * ? AS bucket,
|
`SELECT (ts / ?) * ? AS bucket,
|
||||||
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
import { listOnlinePlayers } from "@/server/mc";
|
import { env } from "@/server/env";
|
||||||
import { readAllPlayerStats } from "@/server/playerstats";
|
|
||||||
import { db } from "@/server/db";
|
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public mirror: only the online names + counts come out of the feed. No
|
||||||
|
* per-player NBT stats, ping history, or auth status is ever exposed, and
|
||||||
|
* neither RCON nor the world folder is touched.
|
||||||
|
*/
|
||||||
|
async function publicPlayers() {
|
||||||
|
const { feedStatus } = await import("@/server/feed");
|
||||||
|
const status = feedStatus();
|
||||||
|
const online = status?.playerNames ?? [];
|
||||||
|
return ok({
|
||||||
|
online,
|
||||||
|
max: status?.playersMax ?? 0,
|
||||||
|
players: online.map((name) => ({
|
||||||
|
name,
|
||||||
|
uuid: "",
|
||||||
|
playTimeHours: 0,
|
||||||
|
deaths: 0,
|
||||||
|
mobKills: 0,
|
||||||
|
playerKills: 0,
|
||||||
|
distanceKm: 0,
|
||||||
|
jumps: 0,
|
||||||
|
damageDealt: 0,
|
||||||
|
blocksMined: 0,
|
||||||
|
isOnline: true,
|
||||||
|
ping: null,
|
||||||
|
authStatus: null,
|
||||||
|
lastSeenAt: null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicPlayers);
|
||||||
|
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const { listOnlinePlayers } = await import("@/server/mc");
|
||||||
|
const { readAllPlayerStats } = await import("@/server/playerstats");
|
||||||
|
const { db } = await import("@/server/db");
|
||||||
|
|
||||||
let online: string[] = [];
|
let online: string[] = [];
|
||||||
let max = 0;
|
let max = 0;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { inspectContainer, containerEnv } from "@/server/docker";
|
|
||||||
import { listOnlinePlayers } from "@/server/mc";
|
|
||||||
import { db } from "@/server/db";
|
|
||||||
import { env } from "@/server/env";
|
import { env } from "@/server/env";
|
||||||
import { getWorldSizeBytes } from "@/server/sampler";
|
|
||||||
import { readWorldInfo } from "@/server/playerstats";
|
|
||||||
import { readEnvFile } from "@/server/envfile";
|
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,20 +6,56 @@ export const dynamic = "force-dynamic";
|
|||||||
// BlueMap detection (in-network probe of the mc container), cached 60 s.
|
// BlueMap detection (in-network probe of the mc container), cached 60 s.
|
||||||
let mapCheck: { at: number; available: boolean } = { at: 0, available: false };
|
let mapCheck: { at: number; available: boolean } = { at: 0, available: false };
|
||||||
|
|
||||||
async function bluemapAvailable(running: boolean): Promise<boolean> {
|
async function bluemapAvailable(mcContainer: string, running: boolean): Promise<boolean> {
|
||||||
if (!running) return false;
|
if (!running) return false;
|
||||||
if (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
if (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
||||||
let available = false;
|
let available = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://${env.mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) });
|
const res = await fetch(`http://${mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) });
|
||||||
available = res.ok;
|
available = res.ok;
|
||||||
} catch {}
|
} catch {}
|
||||||
mapCheck = { at: Date.now(), available };
|
mapCheck = { at: Date.now(), available };
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public mirror: status comes entirely from the exported feed. No Docker,
|
||||||
|
* no RCON, no world-folder access — none of those modules are imported.
|
||||||
|
*/
|
||||||
|
async function publicStatus() {
|
||||||
|
const { feedStatus, feedLatestMetrics } = await import("@/server/feed");
|
||||||
|
const status = feedStatus();
|
||||||
|
const latest = feedLatestMetrics();
|
||||||
|
const running = status?.running ?? false;
|
||||||
|
return ok({
|
||||||
|
dockerAvailable: true,
|
||||||
|
container: { exists: true, running, status: running ? "running" : "stopped" },
|
||||||
|
players: {
|
||||||
|
online: status?.playersOnline ?? 0,
|
||||||
|
max: status?.playersMax ?? 0,
|
||||||
|
names: status?.playerNames ?? [],
|
||||||
|
},
|
||||||
|
version: status?.version ?? null,
|
||||||
|
serverType: status?.serverType ?? null,
|
||||||
|
metrics: latest,
|
||||||
|
pendingApprovals: 0,
|
||||||
|
map: { available: false, port: 8100 },
|
||||||
|
world: { seed: null, sizeBytes: null, dimensions: [], levelName: null },
|
||||||
|
uptimeMs: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicStatus);
|
||||||
|
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const { inspectContainer, containerEnv } = await import("@/server/docker");
|
||||||
|
const { listOnlinePlayers } = await import("@/server/mc");
|
||||||
|
const { getWorldSizeBytes } = await import("@/server/sampler");
|
||||||
|
const { readWorldInfo } = await import("@/server/playerstats");
|
||||||
|
const { readEnvFile } = await import("@/server/envfile");
|
||||||
|
const { db } = await import("@/server/db");
|
||||||
|
|
||||||
let container;
|
let container;
|
||||||
let dockerAvailable = true;
|
let dockerAvailable = true;
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +96,7 @@ export async function GET() {
|
|||||||
metrics: latest ?? null,
|
metrics: latest ?? null,
|
||||||
pendingApprovals: pendingCount,
|
pendingApprovals: pendingCount,
|
||||||
map: {
|
map: {
|
||||||
available: await bluemapAvailable(container.running),
|
available: await bluemapAvailable(env.mcContainer, container.running),
|
||||||
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
||||||
},
|
},
|
||||||
world: readWorldInfo(getWorldSizeBytes()),
|
world: readWorldInfo(getWorldSizeBytes()),
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { bus } from "@/server/bus";
|
import { bus } from "@/server/bus";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
* Multiplexed live event stream (SSE) for the UI.
|
||||||
* parsed game events, and new notifications.
|
*
|
||||||
|
* Admin mode fans out sampler metrics ticks, parsed game events, and new
|
||||||
|
* notifications. In PUBLIC_READONLY mode the stream is restricted to
|
||||||
|
* "metrics" and join/leave "mc-event" frames only — notifications (which
|
||||||
|
* carry admin-facing titles/bodies) and any other game-event types are never
|
||||||
|
* forwarded. The feed watcher only ever emits those two kinds anyway, but
|
||||||
|
* the filter below is the enforced boundary.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
const isPublic = env.publicReadonly;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -22,11 +30,24 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onMetrics = (m: unknown) => send("metrics", m);
|
const onMetrics = (m: unknown) => send("metrics", m);
|
||||||
const onEvent = (e: unknown) => send("mc-event", e);
|
const onEvent = (e: unknown) => {
|
||||||
const onNotification = (n: unknown) => send("notification", n);
|
if (isPublic) {
|
||||||
|
// Only join/leave may leave the public stream — drop connect (has
|
||||||
|
// IPs), blocked, register-hint, server_* and anything else.
|
||||||
|
const type = (e as { type?: string } | null)?.type;
|
||||||
|
if (type !== "join" && type !== "leave") return;
|
||||||
|
send("mc-event", { ts: (e as { ts?: number }).ts, type, player: (e as { player?: string }).player });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send("mc-event", e);
|
||||||
|
};
|
||||||
bus().on("metrics", onMetrics);
|
bus().on("metrics", onMetrics);
|
||||||
bus().on("mc-event", onEvent);
|
bus().on("mc-event", onEvent);
|
||||||
bus().on("notification", onNotification);
|
|
||||||
|
// Notifications are admin-only content; never subscribe in public mode.
|
||||||
|
const onNotification = isPublic ? null : (n: unknown) => send("notification", n);
|
||||||
|
if (onNotification) bus().on("notification", onNotification);
|
||||||
|
|
||||||
const keepalive = setInterval(() => {
|
const keepalive = setInterval(() => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +60,7 @@ export async function GET(req: NextRequest) {
|
|||||||
closed = true;
|
closed = true;
|
||||||
bus().off("metrics", onMetrics);
|
bus().off("metrics", onMetrics);
|
||||||
bus().off("mc-event", onEvent);
|
bus().off("mc-event", onEvent);
|
||||||
bus().off("notification", onNotification);
|
if (onNotification) bus().off("notification", onNotification);
|
||||||
clearInterval(keepalive);
|
clearInterval(keepalive);
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
import { Badge, Toaster } from "@/components/ui";
|
import { Badge, Toaster } from "@/components/ui";
|
||||||
import { timeAgo } from "@/lib/format";
|
import { timeAgo } from "@/lib/format";
|
||||||
|
import { PublicModeProvider } from "@/lib/publicmode";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: "/", label: "Overview", icon: Gauge },
|
{ href: "/", label: "Overview", icon: Gauge },
|
||||||
@@ -39,6 +41,10 @@ const NAV = [
|
|||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The only surfaces the public mirror exposes (must mirror the middleware
|
||||||
|
// allowlist in src/proxy.ts).
|
||||||
|
const PUBLIC_NAV_HREFS = new Set(["/", "/players", "/performance"]);
|
||||||
|
|
||||||
type ServerSummary = {
|
type ServerSummary = {
|
||||||
container: { running: boolean; status: string };
|
container: { running: boolean; status: string };
|
||||||
players: { online: number; max: number };
|
players: { online: number; max: number };
|
||||||
@@ -54,14 +60,19 @@ type NotificationRow = {
|
|||||||
read: number;
|
read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Shell({ children }: { children: React.ReactNode }) {
|
export function Shell({ children, publicMode = false }: { children: React.ReactNode; publicMode?: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
||||||
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
|
// /api/auth/me is blocked (404) in public mode — don't request it.
|
||||||
|
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me", 0, {
|
||||||
|
enabled: !publicMode,
|
||||||
|
});
|
||||||
const [running, setRunning] = useState<boolean | null>(null);
|
const [running, setRunning] = useState<boolean | null>(null);
|
||||||
const [players, setPlayers] = useState<number | null>(null);
|
const [players, setPlayers] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const nav = publicMode ? NAV.filter((n) => PUBLIC_NAV_HREFS.has(n.href)) : NAV;
|
||||||
|
|
||||||
useSSE("/api/stream/events", {
|
useSSE("/api/stream/events", {
|
||||||
metrics: (m) => {
|
metrics: (m) => {
|
||||||
const d = m as { running: boolean; players: number | null };
|
const d = m as { running: boolean; players: number | null };
|
||||||
@@ -98,11 +109,11 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold leading-none">CraftDeck</div>
|
<div className="text-sm font-bold leading-none">CraftDeck</div>
|
||||||
<div className="mt-0.5 text-[10px] text-muted">server panel</div>
|
<div className="mt-0.5 text-[10px] text-muted">{publicMode ? "public mirror" : "server panel"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||||
{NAV.map((item) => {
|
{nav.map((item) => {
|
||||||
const active = pathname === item.href;
|
const active = pathname === item.href;
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +136,15 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-border p-3 text-[11px] text-muted">
|
<div className="border-t border-border p-3 text-[11px] text-muted">
|
||||||
|
{publicMode ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-ink2">
|
||||||
|
<Eye size={12} /> Read-only public mirror
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -148,6 +167,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{publicMode ? (
|
||||||
|
<span className="flex items-center gap-1.5 rounded-full border border-accent-deep/40 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent">
|
||||||
|
<Eye size={12} /> Read-only public mirror
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<NotificationBell bump={unreadBump} />
|
<NotificationBell bump={unreadBump} />
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -155,10 +180,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<LogOut size={13} /> Log out
|
<LogOut size={13} /> Log out
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{me?.mustChange && pathname !== "/settings" && (
|
{!publicMode && me?.mustChange && pathname !== "/settings" && (
|
||||||
<div className="border-b border-warn/30 bg-warn/10 px-6 py-2 text-xs text-warn">
|
<div className="border-b border-warn/30 bg-warn/10 px-6 py-2 text-xs text-warn">
|
||||||
You are using the initial admin password —{" "}
|
You are using the initial admin password —{" "}
|
||||||
<Link href="/settings" className="underline">
|
<Link href="/settings" className="underline">
|
||||||
@@ -168,7 +195,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="flex-1 p-6">{children}</main>
|
<main className="flex-1 p-6">
|
||||||
|
<PublicModeProvider value={publicMode}>{children}</PublicModeProvider>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
{/* refresh server summary when the tab regains focus */}
|
{/* refresh server summary when the tab regains focus */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { boot } = await import("./server/boot");
|
const { boot } = await import("./server/boot");
|
||||||
boot();
|
await boot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,14 +33,20 @@ export async function api<T = unknown>(
|
|||||||
return payload.data as T;
|
return payload.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poll a GET endpoint; refresh() for manual refetch. */
|
/**
|
||||||
export function useApi<T>(path: string, intervalMs = 0) {
|
* Poll a GET endpoint; refresh() for manual refetch. Pass { enabled: false }
|
||||||
|
* to skip fetching entirely (e.g. an endpoint that 404s in public mode) —
|
||||||
|
* data stays null and no request is made.
|
||||||
|
*/
|
||||||
|
export function useApi<T>(path: string, intervalMs = 0, opts: { enabled?: boolean } = {}) {
|
||||||
|
const enabled = opts.enabled ?? true;
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const alive = useRef(true);
|
const alive = useRef(true);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
try {
|
try {
|
||||||
const d = await api<T>(path);
|
const d = await api<T>(path);
|
||||||
if (!alive.current) return;
|
if (!alive.current) return;
|
||||||
@@ -52,9 +58,13 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setLoading(false);
|
if (alive.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [path]);
|
}, [path, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
alive.current = true;
|
alive.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -63,7 +73,7 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
|||||||
alive.current = false;
|
alive.current = false;
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [refresh, intervalMs]);
|
}, [refresh, intervalMs, enabled]);
|
||||||
|
|
||||||
return { data, error, loading, refresh };
|
return { data, error, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the panel is running as an anonymous read-only public mirror.
|
||||||
|
* The value is resolved on the server (from PUBLIC_READONLY) in the panel
|
||||||
|
* layout and handed to the client tree here, so components can hide admin
|
||||||
|
* controls. This is a UX affordance only — the real enforcement is the
|
||||||
|
* middleware allowlist in src/proxy.ts, which 404s every admin surface.
|
||||||
|
*/
|
||||||
|
const PublicModeContext = createContext(false);
|
||||||
|
|
||||||
|
export function PublicModeProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <PublicModeContext.Provider value={value}>{children}</PublicModeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublicMode(): boolean {
|
||||||
|
return useContext(PublicModeContext);
|
||||||
|
}
|
||||||
@@ -3,7 +3,54 @@ import { SESSION_COOKIE, verifySessionToken } from "@/server/session";
|
|||||||
|
|
||||||
const PUBLIC_PATHS = new Set(["/login", "/api/auth/login", "/api/health"]);
|
const PUBLIC_PATHS = new Set(["/login", "/api/auth/login", "/api/health"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUBLIC_READONLY allowlist. When the flag is set the panel is an anonymous,
|
||||||
|
* read-only mirror: there is no login and nothing can mutate the server, so
|
||||||
|
* we default-deny (404) every path and explicitly permit only the handful of
|
||||||
|
* GET surfaces that read from the exported feed.
|
||||||
|
*
|
||||||
|
* Deny-by-404 (not 401/403) so the public deployment never even hints that
|
||||||
|
* an admin surface exists.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ALLOWED_PAGES = new Set(["/", "/players", "/performance"]);
|
||||||
|
const PUBLIC_ALLOWED_APIS = new Set([
|
||||||
|
"/api/health", // liveness probe
|
||||||
|
"/api/server", // overview status (from feed)
|
||||||
|
"/api/metrics", // time-series history (from feed)
|
||||||
|
"/api/activity", // join/leave feed (from feed)
|
||||||
|
"/api/players", // online names + counts (from feed)
|
||||||
|
"/api/lag", // performance verdict (from feed)
|
||||||
|
"/api/stream/events", // SSE: metrics + join/leave only (from feed)
|
||||||
|
]);
|
||||||
|
|
||||||
|
function publicReadonly(): boolean {
|
||||||
|
return process.env.PUBLIC_READONLY === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(): NextResponse {
|
||||||
|
// A bare 404 — no body that could reveal the admin app underneath.
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePublic(req: NextRequest): NextResponse {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Only GET/HEAD can ever be served; every mutation surface is denied.
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") return notFound();
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return PUBLIC_ALLOWED_APIS.has(pathname) ? NextResponse.next() : notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Router RSC navigations reuse the page pathname, so matching the
|
||||||
|
// pathname covers both full loads and client transitions.
|
||||||
|
return PUBLIC_ALLOWED_PAGES.has(pathname) ? NextResponse.next() : notFound();
|
||||||
|
}
|
||||||
|
|
||||||
export async function proxy(req: NextRequest) {
|
export async function proxy(req: NextRequest) {
|
||||||
|
if (publicReadonly()) return handlePublic(req);
|
||||||
|
|
||||||
|
// ── Normal LAN admin mode (unchanged) ────────────────────────────────
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DockerError } from "./docker";
|
import { DockerError, RconError } from "./errors";
|
||||||
import { RconError } from "./rcon";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small helpers for route handlers. Authentication is enforced globally in
|
* Small helpers for route handlers. Authentication is enforced globally in
|
||||||
|
|||||||
@@ -1,30 +1,45 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { db } from "./db";
|
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { hashPassword, generatePassword } from "./passwords";
|
|
||||||
import { startLogWatcher } from "./logwatch";
|
|
||||||
import { startSampler } from "./sampler";
|
|
||||||
import { startAuthWatcher } from "./authflow";
|
|
||||||
import { startScheduler } from "./scheduler";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time process bootstrap, invoked from instrumentation.ts when the
|
* One-time process bootstrap, invoked from instrumentation.ts when the
|
||||||
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
||||||
|
*
|
||||||
|
* The admin subsystems (sampler, log watcher, auth watcher, scheduler) all
|
||||||
|
* talk to Docker/RCON, so in PUBLIC_READONLY mode they are never imported —
|
||||||
|
* the dynamic imports below keep docker.ts / rcon.ts / mc.ts / sampler.ts
|
||||||
|
* out of the public deployment's module graph entirely. The public mirror
|
||||||
|
* only starts the feed watcher, which reads the exported SQLite file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
||||||
|
|
||||||
export function boot() {
|
export async function boot() {
|
||||||
if (g.__craftdeckBooted) return;
|
if (g.__craftdeckBooted) return;
|
||||||
g.__craftdeckBooted = true;
|
g.__craftdeckBooted = true;
|
||||||
|
|
||||||
|
if (env.publicReadonly) {
|
||||||
|
const { startFeedWatcher } = await import("./feed");
|
||||||
|
startFeedWatcher();
|
||||||
|
console.log(`[craftdeck] PUBLIC read-only mirror started (feed: ${env.feedPath})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
seedAdminUser();
|
await seedAdminUser();
|
||||||
|
|
||||||
|
const [{ startLogWatcher }, { startSampler }, { startAuthWatcher }, { startScheduler }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("./logwatch"),
|
||||||
|
import("./sampler"),
|
||||||
|
import("./authflow"),
|
||||||
|
import("./scheduler"),
|
||||||
|
]);
|
||||||
startLogWatcher();
|
startLogWatcher();
|
||||||
startSampler();
|
startSampler();
|
||||||
startAuthWatcher();
|
startAuthWatcher();
|
||||||
@@ -33,7 +48,9 @@ export function boot() {
|
|||||||
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedAdminUser() {
|
async function seedAdminUser() {
|
||||||
|
const { db } = await import("./db");
|
||||||
|
const { hashPassword, generatePassword } = await import("./passwords");
|
||||||
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
||||||
if (count > 0) return;
|
if (count > 0) return;
|
||||||
let password = env.initialAdminPassword;
|
let password = env.initialAdminPassword;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { DockerError } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||||
@@ -7,15 +8,9 @@ import { env } from "./env";
|
|||||||
* unavailable" / "container missing" states instead of crashing.
|
* unavailable" / "container missing" states instead of crashing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class DockerError extends Error {
|
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||||
constructor(
|
// that read-only routes can recognise it without importing this module.
|
||||||
message: string,
|
export { DockerError };
|
||||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
|
||||||
public statusCode?: number
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReqOptions = {
|
type ReqOptions = {
|
||||||
method?: string;
|
method?: string;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ function str(key: string, fallback: string): string {
|
|||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
dev,
|
dev,
|
||||||
|
// Public read-only mirror mode (see docs/public-mode.md).
|
||||||
|
publicReadonly: process.env.PUBLIC_READONLY === "1",
|
||||||
|
feedPath: path.resolve(str("FEED_PATH", dev ? "./dev-data/feed/feed.db" : "/feed/feed.db")),
|
||||||
|
|
||||||
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Infrastructure error types, factored into a dependency-free leaf module.
|
||||||
|
*
|
||||||
|
* These live apart from docker.ts / rcon.ts on purpose: server/api.ts (which
|
||||||
|
* every route imports for ok()/handle()) needs to *recognise* these errors,
|
||||||
|
* but must not drag the Docker-socket or RCON client code into the import
|
||||||
|
* graph of read-only routes. In PUBLIC_READONLY mode nothing may reach
|
||||||
|
* docker.ts or rcon.ts, so those modules are only pulled in by the specific
|
||||||
|
* admin code paths that actually talk to the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class DockerError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||||
|
public statusCode?: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RconError extends Error {
|
||||||
|
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { bus } from "./bus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public read-only data source. In PUBLIC_READONLY mode every byte the app
|
||||||
|
* serves comes from a single pre-exported SQLite file (the "feed") written
|
||||||
|
* by scripts/export-feed.mjs on the LAN box. The feed contains ONLY:
|
||||||
|
*
|
||||||
|
* metrics – the sampler time-series (7-day window)
|
||||||
|
* activity – join/leave events, (ts, type, player) — no detail column,
|
||||||
|
* so connect/blocked rows with IP addresses can never appear
|
||||||
|
* feed_meta – a small status JSON (running, player names, version…)
|
||||||
|
*
|
||||||
|
* panel_users, approved_players, settings, notifications, schedules and
|
||||||
|
* ping_samples are never exported, so they cannot leak from here.
|
||||||
|
*
|
||||||
|
* The exporter replaces the file atomically (write tmp + rename), so we
|
||||||
|
* stat() before each access and reopen when the inode/mtime changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FeedStatus = {
|
||||||
|
generatedAt: number;
|
||||||
|
running: boolean;
|
||||||
|
playersOnline: number;
|
||||||
|
playersMax: number;
|
||||||
|
playerNames: string[];
|
||||||
|
version: string | null;
|
||||||
|
serverType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedHandle = { db: DatabaseSync; ino: number; mtimeMs: number };
|
||||||
|
|
||||||
|
const g = globalThis as unknown as {
|
||||||
|
__craftdeckFeed?: FeedHandle | null;
|
||||||
|
__craftdeckFeedWatch?: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function feedDb(): DatabaseSync | null {
|
||||||
|
let st: fs.Stats;
|
||||||
|
try {
|
||||||
|
st = fs.statSync(env.feedPath);
|
||||||
|
} catch {
|
||||||
|
// feed not written yet — every reader degrades to "no data"
|
||||||
|
if (g.__craftdeckFeed) {
|
||||||
|
try {
|
||||||
|
g.__craftdeckFeed.db.close();
|
||||||
|
} catch {}
|
||||||
|
g.__craftdeckFeed = null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cur = g.__craftdeckFeed;
|
||||||
|
if (cur && cur.ino === st.ino && cur.mtimeMs === st.mtimeMs) return cur.db;
|
||||||
|
if (cur) {
|
||||||
|
try {
|
||||||
|
cur.db.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const db = new DatabaseSync(env.feedPath, { readOnly: true });
|
||||||
|
g.__craftdeckFeed = { db, ino: st.ino, mtimeMs: st.mtimeMs };
|
||||||
|
return db;
|
||||||
|
} catch {
|
||||||
|
g.__craftdeckFeed = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedStatus(): FeedStatus | null {
|
||||||
|
const db = feedDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT value FROM feed_meta WHERE key = 'status'").get() as
|
||||||
|
| { value: string }
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const s = JSON.parse(row.value) as FeedStatus;
|
||||||
|
// A stale feed (exporter or LAN box down) must not present a live server.
|
||||||
|
if (Date.now() - s.generatedAt > 5 * 60 * 1000) {
|
||||||
|
return { ...s, running: false, playersOnline: 0, playerNames: [] };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedLatestMetrics(): Record<string, number | null> | null {
|
||||||
|
const db = feedDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
(db.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get() as
|
||||||
|
| Record<string, number | null>
|
||||||
|
| undefined) ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls the feed and re-emits fresh rows on the in-process bus, so the SSE
|
||||||
|
* endpoint keeps pushing live-ish updates (bounded by the export interval)
|
||||||
|
* without any RCON/Docker access. Only "metrics" and join/leave "mc-event"
|
||||||
|
* events are ever emitted here — there is nothing else in the feed.
|
||||||
|
*/
|
||||||
|
export function startFeedWatcher() {
|
||||||
|
if (g.__craftdeckFeedWatch) return;
|
||||||
|
let lastMetricsTs = Date.now();
|
||||||
|
let lastActivityTs = Date.now();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const db = feedDb();
|
||||||
|
if (!db) return;
|
||||||
|
const status = feedStatus();
|
||||||
|
try {
|
||||||
|
const m = db
|
||||||
|
.prepare("SELECT * FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 1")
|
||||||
|
.get(lastMetricsTs) as Record<string, number | null> | undefined;
|
||||||
|
if (m) {
|
||||||
|
lastMetricsTs = Number(m.ts);
|
||||||
|
bus().emit("metrics", {
|
||||||
|
ts: m.ts,
|
||||||
|
running: status?.running ?? false,
|
||||||
|
cpu: m.cpu,
|
||||||
|
memUsed: m.mem_used,
|
||||||
|
memLimit: m.mem_limit,
|
||||||
|
tps: m.tps,
|
||||||
|
mspt: m.mspt,
|
||||||
|
players: m.players,
|
||||||
|
diskFree: m.disk_free,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const events = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT ts, type, player FROM activity WHERE ts > ? AND type IN ('join','leave') ORDER BY ts ASC LIMIT 50"
|
||||||
|
)
|
||||||
|
.all(lastActivityTs) as Array<{ ts: number; type: string; player: string | null }>;
|
||||||
|
for (const e of events) {
|
||||||
|
lastActivityTs = e.ts;
|
||||||
|
bus().emit("mc-event", { ts: e.ts, type: e.type, player: e.player ?? undefined });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// torn read while the exporter swaps the file — next tick recovers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
g.__craftdeckFeedWatch = setInterval(tick, 10_000);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { RconError } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||||
@@ -11,11 +12,9 @@ import { env } from "./env";
|
|||||||
const TYPE_AUTH = 3;
|
const TYPE_AUTH = 3;
|
||||||
const TYPE_COMMAND = 2;
|
const TYPE_COMMAND = 2;
|
||||||
|
|
||||||
export class RconError extends Error {
|
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
// that read-only routes can recognise it without importing this module.
|
||||||
super(message);
|
export { RconError };
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePacket(id: number, type: number, body: string): Buffer {
|
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||||
const bodyBuf = Buffer.from(body, "utf8");
|
const bodyBuf = Buffer.from(body, "utf8");
|
||||||
|
|||||||
@@ -117,3 +117,61 @@ services:
|
|||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
depends_on:
|
depends_on:
|
||||||
- mc
|
- mc
|
||||||
|
|
||||||
|
# ── Public READ-ONLY mirror (portfolio deployment) ───────────────────────
|
||||||
|
# Opt-in via the "public" profile: `docker compose --profile public up -d`.
|
||||||
|
# This service is safe to expose to the internet. It serves ONLY the
|
||||||
|
# exported feed and CANNOT touch the server: no Docker socket, no project
|
||||||
|
# dir, no RCON password, no .env secrets. See docs/public-mode.md.
|
||||||
|
dashboard-public:
|
||||||
|
build: ./dashboard
|
||||||
|
image: minecraft-dashboard:local
|
||||||
|
container_name: mc-dashboard-public
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["public"]
|
||||||
|
environment:
|
||||||
|
PUBLIC_READONLY: "1"
|
||||||
|
FEED_PATH: /feed/feed.db
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
ports:
|
||||||
|
- "${DASHBOARD_PUBLIC_PORT:-8090}:8080"
|
||||||
|
volumes:
|
||||||
|
# Feed only, read-only. Deliberately NO docker.sock / project / secrets.
|
||||||
|
- ./data/feed:/feed:ro
|
||||||
|
mem_limit: 256m
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
depends_on:
|
||||||
|
- feed-export
|
||||||
|
|
||||||
|
# ── Feed exporter sidecar ────────────────────────────────────────────────
|
||||||
|
# Snapshots the LAN panel DB (read-only) into the public feed on an interval.
|
||||||
|
# Reads only the panel DB; writes only the feed volume. No server access.
|
||||||
|
feed-export:
|
||||||
|
build: ./dashboard
|
||||||
|
image: minecraft-dashboard:local
|
||||||
|
container_name: mc-feed-export
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["public"]
|
||||||
|
working_dir: /app
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- 'while true; do node scripts/export-feed.mjs || echo "export failed"; sleep ${FEED_INTERVAL_SECONDS:-30}; done'
|
||||||
|
environment:
|
||||||
|
SOURCE_DB: /app-data/craftdeck.db
|
||||||
|
FEED_PATH: /feed/feed.db
|
||||||
|
MC_VERSION: ${MC_VERSION:-}
|
||||||
|
SERVER_TYPE: ${SERVER_TYPE:-}
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
volumes:
|
||||||
|
- ./data/dashboard:/app-data:ro
|
||||||
|
- ./data/feed:/feed
|
||||||
|
mem_limit: 128m
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "test", "-f", "/feed/feed.db"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Public read-only mirror (`PUBLIC_READONLY`)
|
||||||
|
|
||||||
|
CraftDeck is an **admin** panel: it mounts the Docker socket and the project
|
||||||
|
directory, runs console commands, edits files, and manages mods. It must only
|
||||||
|
ever be reachable on a trusted LAN/VPN (see [networking.md](networking.md)).
|
||||||
|
|
||||||
|
`PUBLIC_READONLY=1` turns the **same image** into a locked-down, anonymous,
|
||||||
|
read-only mirror that is safe to expose to the internet — a live "status page"
|
||||||
|
for a portfolio. It cannot touch the server: no login, no Docker socket, no
|
||||||
|
RCON, no project files, no secrets. It serves a pre-exported **feed** and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
LAN box (trusted) Public host (untrusted)
|
||||||
|
┌───────────────────────────┐ ┌────────────────────────────┐
|
||||||
|
│ dashboard (admin) │ data/dashboard │ feed-export (sidecar) │
|
||||||
|
│ Docker socket + RCON │ craftdeck.db │ reads DB read-only │
|
||||||
|
│ writes craftdeck.db ─────┼──────ro────────▶│ writes feed.db │
|
||||||
|
└───────────────────────────┘ │ │ data/feed │
|
||||||
|
│ ▼ │
|
||||||
|
│ dashboard-public │
|
||||||
|
│ PUBLIC_READONLY=1 │
|
||||||
|
│ serves feed.db only │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Two independent guards keep it safe:
|
||||||
|
|
||||||
|
1. **Middleware allowlist** (`dashboard/src/proxy.ts`) — default-deny. When the
|
||||||
|
flag is set, **every** path returns `404` unless it is on a tiny GET-only
|
||||||
|
allowlist. Non-GET methods are refused before anything else, so every
|
||||||
|
`POST/PUT/PATCH/DELETE` route (console, power, files, mods, config, auth…)
|
||||||
|
is unreachable. `404` (not `401/403`) is used so the public deployment
|
||||||
|
never even hints an admin app exists.
|
||||||
|
|
||||||
|
2. **Data isolation** — in public mode the sampler, log watcher, auth watcher,
|
||||||
|
scheduler, RCON client and Docker client are **never imported** (they are
|
||||||
|
loaded via dynamic `import()` only in the admin code paths — see
|
||||||
|
`dashboard/src/server/boot.ts` and the allowed route handlers). All reads
|
||||||
|
come from the feed file at `FEED_PATH` via `dashboard/src/server/feed.ts`.
|
||||||
|
|
||||||
|
### The allowlist (enforced in `src/proxy.ts`)
|
||||||
|
|
||||||
|
| Surface | Path | Source in public mode |
|
||||||
|
|---|---|---|
|
||||||
|
| Overview page | `GET /` | feed |
|
||||||
|
| Players page | `GET /players` | feed |
|
||||||
|
| Performance page | `GET /performance` | feed |
|
||||||
|
| Health probe | `GET /api/health` | static |
|
||||||
|
| Server status | `GET /api/server` | feed status blob |
|
||||||
|
| Metrics history | `GET /api/metrics` | feed `metrics` table |
|
||||||
|
| Activity | `GET /api/activity` | feed `activity` (join/leave only) |
|
||||||
|
| Players | `GET /api/players` | feed status (names + counts only) |
|
||||||
|
| Lag / tick health | `GET /api/lag` | feed `metrics` (no per-player ping) |
|
||||||
|
| Live stream (SSE) | `GET /api/stream/events` | feed (metrics + join/leave frames only) |
|
||||||
|
|
||||||
|
**Everything else → 404**, explicitly including: `/console`, `/access`,
|
||||||
|
`/security`, `/mods`, `/backups`, `/files`, `/config`, `/schedules`,
|
||||||
|
`/settings`, `/login`; all of `/api/auth/*`, `/api/admin`, `/api/approved`,
|
||||||
|
`/api/notifications`, `/api/console`, `/api/server/power`, `/api/world`,
|
||||||
|
`/api/mods*`, `/api/files*`, `/api/backups*`, `/api/config*`, `/api/schedules`,
|
||||||
|
`/api/security`, `/api/settings*`, `/api/lag/healthreport`, and
|
||||||
|
**`/api/stream/logs`** (the raw console stream). Public mode has **no login** —
|
||||||
|
it is anonymous read-only, so there is no session to protect.
|
||||||
|
|
||||||
|
## The feed
|
||||||
|
|
||||||
|
`dashboard/scripts/export-feed.mjs` reads the live panel DB **read-only** and
|
||||||
|
writes a separate SQLite file containing **only**:
|
||||||
|
|
||||||
|
- `metrics` — the sampler time-series (last 7 days)
|
||||||
|
- `activity` — **join/leave events only**, as `(id, ts, type, player)`. The
|
||||||
|
free-text `detail` column is dropped, so IP addresses that appear on
|
||||||
|
`connect`/`blocked` rows can never reach the feed.
|
||||||
|
- `feed_meta` — one small status JSON (running, online names, counts, version)
|
||||||
|
|
||||||
|
It **never** copies `panel_users`, `approved_players`, `settings`,
|
||||||
|
`notifications`, `schedules`, or `ping_samples`. The file is written to a temp
|
||||||
|
path and atomically renamed, so readers never see a half-written feed; the
|
||||||
|
reader hot-reloads when the file's mtime changes. A status blob older than 5
|
||||||
|
minutes is treated as "server offline" so a dead exporter can't show a stale
|
||||||
|
"online".
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
### Option A — compose sidecars (on the LAN box)
|
||||||
|
|
||||||
|
The `public` profile adds two services (excluded from the normal `up`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# normal admin stack (unchanged)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# add the public mirror + its feed exporter
|
||||||
|
docker compose --profile public up -d
|
||||||
|
|
||||||
|
# the mirror is now on http://<host>:8090 (override with DASHBOARD_PUBLIC_PORT)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `feed-export` — reads `data/dashboard/craftdeck.db` (ro) → writes
|
||||||
|
`data/feed/feed.db` every `FEED_INTERVAL_SECONDS` (default 30).
|
||||||
|
- `dashboard-public` — `PUBLIC_READONLY=1`, mounts **only** `data/feed` (ro).
|
||||||
|
No `docker.sock`, no `./:/project`, no `RCON_PASSWORD`, no
|
||||||
|
`PANEL_SESSION_SECRET`.
|
||||||
|
|
||||||
|
To actually expose it, run `dashboard-public` on a separate untrusted host and
|
||||||
|
ship `data/feed/feed.db` to it (rsync/scp/object storage). Put TLS + the
|
||||||
|
`Secure` cookie concerns aside — there is no cookie — but do front it with a
|
||||||
|
reverse proxy for HTTPS.
|
||||||
|
|
||||||
|
### Option B — cron on the LAN box
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# refresh the public feed every minute
|
||||||
|
* * * * * cd /path/to/minecraft/dashboard && \
|
||||||
|
SOURCE_DB=../data/dashboard/craftdeck.db FEED_PATH=../data/feed/feed.db \
|
||||||
|
MC_VERSION=26.2 SERVER_TYPE=FABRIC node scripts/export-feed.mjs >/dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the public dashboard container/image anywhere with
|
||||||
|
`PUBLIC_READONLY=1` and `FEED_PATH` pointing at the copied `feed.db`.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Var | Where | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUBLIC_READONLY` | dashboard-public | `1` enables read-only mirror mode |
|
||||||
|
| `FEED_PATH` | dashboard-public + exporter | path to `feed.db` |
|
||||||
|
| `SOURCE_DB` | exporter | path to the live panel DB (read-only) |
|
||||||
|
| `MC_VERSION`, `SERVER_TYPE` | exporter (optional) | shown on the status blob |
|
||||||
|
| `FEED_INTERVAL_SECONDS` | feed-export sidecar | export cadence (default 30) |
|
||||||
|
| `DASHBOARD_PUBLIC_PORT` | compose | host port for the mirror (default 8090) |
|
||||||
|
|
||||||
|
## Security checklist (verified)
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | No `POST/PUT/PATCH/DELETE` reaches any handler in public mode | **PASS** — `proxy.ts` refuses every non-GET before routing (`method !== "GET" && !== "HEAD" → 404`) |
|
||||||
|
| 2 | Admin pages/APIs 404 (console, files, mods, backups, config, schedules, settings, access, security, admin, approved, notifications, world, power) | **PASS** — default-deny allowlist; curl matrix below |
|
||||||
|
| 3 | Auth routes 404; no login in public mode | **PASS** — `GET /api/auth/me`, `POST /api/auth/login`, `GET /login` all 404 |
|
||||||
|
| 4 | Raw console SSE (`/api/stream/logs`) unreachable | **PASS** — not on the allowlist → 404 |
|
||||||
|
| 5 | `/api/stream/events` emits **only** `metrics` + join/leave; never notifications or console lines | **PASS** — notification listener not registered in public mode; `mc-event` filtered to `join`/`leave`; `detail`/IP stripped |
|
||||||
|
| 6 | RCON / Docker / sampler modules never imported in public mode | **PASS** — grep shows no static `server/{docker,rcon,mc,sampler}` import in `boot.ts`, `api.ts`, or any allowed route; error classes live in `server/errors.ts` |
|
||||||
|
| 7 | Feed excludes `panel_users`, `approved_players`, `settings`, `notifications`, `schedules`, `ping_samples`, and the activity `detail`/IP column | **PASS** — feed has only `metrics`, `activity(id,ts,type,player)`, `feed_meta`; leak scan for hashes/IPs/notification text found nothing |
|
||||||
|
| 8 | `dashboard-public` has no docker.sock / project dir / RCON or session secret | **PASS** — `docker compose --profile public config` shows only `data/feed:ro` |
|
||||||
|
| 9 | Admin mode unchanged when the flag is unset | **PASS** — auth still enforced (`/api/server`→401, `/console`→307→login); full panel services boot |
|
||||||
|
| 10 | Committed `dev-data` cannot ship in an image | **PASS** — `dashboard/.dockerignore` excludes `dev-data` |
|
||||||
|
|
||||||
|
### curl verification (booted with `PUBLIC_READONLY=1` + a generated feed)
|
||||||
|
|
||||||
|
```
|
||||||
|
ALLOWED (200): / /players /performance
|
||||||
|
/api/health /api/metrics /api/server /api/players /api/activity /api/lag
|
||||||
|
BLOCKED (404): /console /settings /login
|
||||||
|
GET /api/auth/me · POST /api/auth/login · GET /api/notifications
|
||||||
|
GET /api/admin · POST /api/console · POST /api/server/power
|
||||||
|
GET /api/stream/logs · GET /api/world · POST /api/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
A leak scan across every allowed API response found no password hashes, IP
|
||||||
|
addresses, or notification content. With a fresh feed the mirror served live
|
||||||
|
data (`/api/server` running=true, 2 players; `/api/metrics` 360 buckets;
|
||||||
|
`/api/lag` verdict `all-good`).
|
||||||
|
|
||||||
|
## Note on the committed dev database
|
||||||
|
|
||||||
|
`dashboard/dev-data/app/craftdeck.db` is git-ignored (root `.gitignore` →
|
||||||
|
`dashboard/dev-data/`) and its `panel_users` table **does contain a real
|
||||||
|
scrypt password hash** for the `admin` user (`must_change=1`). It is a local
|
||||||
|
dev seed, not a production credential, but it is a hash nonetheless — it must
|
||||||
|
never end up in a built image. `dashboard/.dockerignore` now excludes
|
||||||
|
`dev-data`, and the public mirror never mounts it. Do not commit this file.
|
||||||
Reference in new issue
Block a user