Restore source files from Google Drive backup

This commit is contained in:
Aryan Singh committed 2026-10-27 12:34:01 -05:00
1 parent dff2b5a9c4
commit 328525b37f
27 files changed
+975 -106

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -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 \
+169
View File
@@ -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}`
);
+6 -1
View File
@@ -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>;
} }
+36 -30
View File
@@ -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,34 +117,36 @@ export default function OverviewPage() {
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>} {running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
</p> </p>
</div> </div>
<div className="flex gap-2"> {!publicMode && (
<Button <div className="flex gap-2">
variant="primary" <Button
size="sm" variant="primary"
onClick={() => power("start")} size="sm"
busy={busyAction === "start"} onClick={() => power("start")}
disabled={running || !server?.dockerAvailable} busy={busyAction === "start"}
> disabled={running || !server?.dockerAvailable}
<Play size={13} /> Start >
</Button> <Play size={13} /> Start
<Button </Button>
size="sm" <Button
onClick={() => power("restart")} size="sm"
busy={busyAction === "restart"} onClick={() => power("restart")}
disabled={!running} busy={busyAction === "restart"}
> disabled={!running}
<RotateCw size={13} /> Restart >
</Button> <RotateCw size={13} /> Restart
<Button </Button>
variant="danger" <Button
size="sm" variant="danger"
onClick={() => power("stop")} size="sm"
busy={busyAction === "stop"} onClick={() => power("stop")}
disabled={!running} busy={busyAction === "stop"}
> disabled={!running}
<Square size={13} /> Stop >
</Button> <Square size={13} /> Stop
</div> </Button>
</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={
<Button size="sm" onClick={runHealthReport} busy={busyReport}> publicMode ? undefined : (
<Stethoscope size={13} /> Full health report <Button size="sm" onClick={runHealthReport} busy={busyReport}>
</Button> <Stethoscope size={13} /> Full health report
</Button>
)
} }
> >
<div className="flex flex-wrap items-start gap-4"> <div className="flex flex-wrap items-start gap-4">
+7 -3
View File
@@ -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
+15 -3
View File
@@ -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);
}); });
} }
+33 -2
View File
@@ -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 }>;
+11 -2
View File
@@ -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,
+38 -3
View File
@@ -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 {
+39 -9
View File
@@ -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()),
+27 -6
View File
@@ -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();
+43 -14
View File
@@ -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">
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span> {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>
</>
)}
</div> </div>
</aside> </aside>
@@ -148,17 +167,25 @@ export function Shell({ children }: { children: React.ReactNode }) {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NotificationBell bump={unreadBump} /> {publicMode ? (
<button <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">
onClick={logout} <Eye size={12} /> Read-only public mirror
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink" </span>
> ) : (
<LogOut size={13} /> Log out <>
</button> <NotificationBell bump={unreadBump} />
<button
onClick={logout}
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
>
<LogOut size={13} /> Log out
</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 -1
View File
@@ -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();
} }
} }
+14 -4
View File
@@ -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 };
} }
+26
View File
@@ -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);
}
+47
View File
@@ -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 -2
View File
@@ -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
+26 -9
View File
@@ -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;
+4 -9
View File
@@ -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;
+4
View File
@@ -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" : ""),
+26
View File
@@ -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);
}
}
+153
View File
@@ -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();
}
+4 -5
View File
@@ -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");
+58
View File
@@ -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
+174
View File
@@ -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.