Restore source files from Google Drive backup
This commit is contained in:
1 parent
dff2b5a9c4
commit
328525b37f
27 files changed
+936
-67
No files matched your search
@@ -2,3 +2,6 @@ node_modules
|
||||
.next
|
||||
Dockerfile
|
||||
.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/static ./.next/static
|
||||
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
|
||||
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";
|
||||
|
||||
// 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 }) {
|
||||
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 { QuickControls } from "@/components/quickcontrols";
|
||||
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
||||
import { usePublicMode } from "@/lib/publicmode";
|
||||
|
||||
type ServerData = {
|
||||
dockerAvailable: boolean;
|
||||
@@ -64,12 +65,15 @@ const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" |
|
||||
};
|
||||
|
||||
export default function OverviewPage() {
|
||||
const publicMode = usePublicMode();
|
||||
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||
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[] }>(
|
||||
"/api/approved",
|
||||
15000
|
||||
15000,
|
||||
{ enabled: !publicMode }
|
||||
);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [, forceTick] = useState(0);
|
||||
@@ -113,6 +117,7 @@ export default function OverviewPage() {
|
||||
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||
</p>
|
||||
</div>
|
||||
{!publicMode && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -141,6 +146,7 @@ export default function OverviewPage() {
|
||||
<Square size={13} /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!server?.dockerAvailable && server && (
|
||||
@@ -230,7 +236,7 @@ export default function OverviewPage() {
|
||||
)}
|
||||
|
||||
{/* admin quick controls */}
|
||||
<QuickControls running={running} />
|
||||
{!publicMode && <QuickControls running={running} />}
|
||||
|
||||
{/* world + disk */}
|
||||
<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 { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
||||
import { fmtBytes } from "@/lib/format";
|
||||
import { usePublicMode } from "@/lib/publicmode";
|
||||
|
||||
type MetricRow = {
|
||||
bucket: number;
|
||||
@@ -66,6 +67,7 @@ const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "
|
||||
};
|
||||
|
||||
export default function PerformancePage() {
|
||||
const publicMode = usePublicMode();
|
||||
const [range, setRange] = useState("1h");
|
||||
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
||||
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
||||
@@ -116,9 +118,11 @@ export default function PerformancePage() {
|
||||
<Card
|
||||
title="Lag diagnosis"
|
||||
actions={
|
||||
publicMode ? undefined : (
|
||||
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||
<Stethoscope size={13} /> Full health report
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<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 { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
||||
import { timeAgo, fmtDateTime } from "@/lib/format";
|
||||
import { usePublicMode } from "@/lib/publicmode";
|
||||
|
||||
type PlayerRow = {
|
||||
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 };
|
||||
|
||||
export default function PlayersPage() {
|
||||
const publicMode = usePublicMode();
|
||||
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
||||
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
||||
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
||||
@@ -129,7 +131,7 @@ export default function PlayersPage() {
|
||||
<tbody>
|
||||
{players.map((p) => (
|
||||
<tr
|
||||
key={p.uuid}
|
||||
key={p.uuid || p.name}
|
||||
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
||||
onClick={() => setDetail(p)}
|
||||
>
|
||||
@@ -149,7 +151,7 @@ export default function PlayersPage() {
|
||||
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{p.isOnline && (
|
||||
{!publicMode && p.isOnline && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@@ -226,6 +228,7 @@ export default function PlayersPage() {
|
||||
</div>
|
||||
|
||||
{/* admin actions */}
|
||||
{!publicMode && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||
{detail.isOnline && (
|
||||
<>
|
||||
@@ -261,9 +264,10 @@ export default function PlayersPage() {
|
||||
<Gavel size={12} /> Ban
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
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";
|
||||
|
||||
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) {
|
||||
return handle(async () => {
|
||||
const database = await source();
|
||||
if (!database) return ok([]);
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
const rows = type
|
||||
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
||||
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||
? database
|
||||
.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);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { db } from "@/server/db";
|
||||
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
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
|
||||
* 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() {
|
||||
if (env.publicReadonly) return handle(publicLag);
|
||||
|
||||
return handle(async () => {
|
||||
const { db } = await import("@/server/db");
|
||||
const { sparkPings, listOnlinePlayers } = await import("@/server/mc");
|
||||
|
||||
const recent = db()
|
||||
.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 }>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return handle(async () => {
|
||||
const database = await source();
|
||||
if (!database) return ok([]);
|
||||
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||
const since = Date.now() - range.ms;
|
||||
// Bucket-average so long ranges stay light on the wire.
|
||||
const rows = db()
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT (ts / ?) * ? AS bucket,
|
||||
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 { readAllPlayerStats } from "@/server/playerstats";
|
||||
import { db } from "@/server/db";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
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() {
|
||||
if (env.publicReadonly) return handle(publicPlayers);
|
||||
|
||||
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 max = 0;
|
||||
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 { getWorldSizeBytes } from "@/server/sampler";
|
||||
import { readWorldInfo } from "@/server/playerstats";
|
||||
import { readEnvFile } from "@/server/envfile";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
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.
|
||||
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 (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
||||
let available = false;
|
||||
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;
|
||||
} catch {}
|
||||
mapCheck = { at: Date.now(), 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() {
|
||||
if (env.publicReadonly) return handle(publicStatus);
|
||||
|
||||
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 dockerAvailable = true;
|
||||
try {
|
||||
@@ -66,7 +96,7 @@ export async function GET() {
|
||||
metrics: latest ?? null,
|
||||
pendingApprovals: pendingCount,
|
||||
map: {
|
||||
available: await bluemapAvailable(container.running),
|
||||
available: await bluemapAvailable(env.mcContainer, container.running),
|
||||
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
||||
},
|
||||
world: readWorldInfo(getWorldSizeBytes()),
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bus } from "@/server/bus";
|
||||
import { env } from "@/server/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
||||
* parsed game events, and new notifications.
|
||||
* Multiplexed live event stream (SSE) for the UI.
|
||||
*
|
||||
* 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) {
|
||||
const encoder = new TextEncoder();
|
||||
const isPublic = env.publicReadonly;
|
||||
let closed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -22,11 +30,24 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
};
|
||||
const onMetrics = (m: unknown) => send("metrics", m);
|
||||
const onEvent = (e: unknown) => send("mc-event", e);
|
||||
const onNotification = (n: unknown) => send("notification", n);
|
||||
const onEvent = (e: unknown) => {
|
||||
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("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(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
@@ -39,7 +60,7 @@ export async function GET(req: NextRequest) {
|
||||
closed = true;
|
||||
bus().off("metrics", onMetrics);
|
||||
bus().off("mc-event", onEvent);
|
||||
bus().off("notification", onNotification);
|
||||
if (onNotification) bus().off("notification", onNotification);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
Bell,
|
||||
LogOut,
|
||||
CircleDot,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||
import { Badge, Toaster } from "@/components/ui";
|
||||
import { timeAgo } from "@/lib/format";
|
||||
import { PublicModeProvider } from "@/lib/publicmode";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Overview", icon: Gauge },
|
||||
@@ -39,6 +41,10 @@ const NAV = [
|
||||
{ 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 = {
|
||||
container: { running: boolean; status: string };
|
||||
players: { online: number; max: number };
|
||||
@@ -54,14 +60,19 @@ type NotificationRow = {
|
||||
read: number;
|
||||
};
|
||||
|
||||
export function Shell({ children }: { children: React.ReactNode }) {
|
||||
export function Shell({ children, publicMode = false }: { children: React.ReactNode; publicMode?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
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 [players, setPlayers] = useState<number | null>(null);
|
||||
|
||||
const nav = publicMode ? NAV.filter((n) => PUBLIC_NAV_HREFS.has(n.href)) : NAV;
|
||||
|
||||
useSSE("/api/stream/events", {
|
||||
metrics: (m) => {
|
||||
const d = m as { running: boolean; players: number | null };
|
||||
@@ -98,11 +109,11 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
</svg>
|
||||
<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>
|
||||
<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 Icon = item.icon;
|
||||
return (
|
||||
@@ -125,7 +136,15 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
})}
|
||||
</nav>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -148,6 +167,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<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} />
|
||||
<button
|
||||
onClick={logout}
|
||||
@@ -155,10 +180,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
<LogOut size={13} /> Log out
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
You are using the initial admin password —{" "}
|
||||
<Link href="/settings" className="underline">
|
||||
@@ -168,7 +195,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<main className="flex-1 p-6">
|
||||
<PublicModeProvider value={publicMode}>{children}</PublicModeProvider>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
{/* refresh server summary when the tab regains focus */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { boot } = await import("./server/boot");
|
||||
boot();
|
||||
await boot();
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,20 @@ export async function api<T = unknown>(
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const d = await api<T>(path);
|
||||
if (!alive.current) return;
|
||||
@@ -52,9 +58,13 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
||||
} finally {
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}, [path]);
|
||||
}, [path, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
alive.current = true;
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
@@ -63,7 +73,7 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
||||
alive.current = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [refresh, intervalMs]);
|
||||
}, [refresh, intervalMs, enabled]);
|
||||
|
||||
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"]);
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (publicReadonly()) return handlePublic(req);
|
||||
|
||||
// ── Normal LAN admin mode (unchanged) ────────────────────────────────
|
||||
const { pathname } = req.nextUrl;
|
||||
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DockerError } from "./docker";
|
||||
import { RconError } from "./rcon";
|
||||
import { DockerError, RconError } from "./errors";
|
||||
|
||||
/**
|
||||
* Small helpers for route handlers. Authentication is enforced globally in
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import { db } from "./db";
|
||||
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
|
||||
* 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 };
|
||||
|
||||
export function boot() {
|
||||
export async function boot() {
|
||||
if (g.__craftdeckBooted) return;
|
||||
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]) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
seedAdminUser();
|
||||
await seedAdminUser();
|
||||
|
||||
const [{ startLogWatcher }, { startSampler }, { startAuthWatcher }, { startScheduler }] =
|
||||
await Promise.all([
|
||||
import("./logwatch"),
|
||||
import("./sampler"),
|
||||
import("./authflow"),
|
||||
import("./scheduler"),
|
||||
]);
|
||||
startLogWatcher();
|
||||
startSampler();
|
||||
startAuthWatcher();
|
||||
@@ -33,7 +48,9 @@ export function boot() {
|
||||
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;
|
||||
if (count > 0) return;
|
||||
let password = env.initialAdminPassword;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "node:http";
|
||||
import { env } from "./env";
|
||||
import { DockerError } from "./errors";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export class DockerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||
// that read-only routes can recognise it without importing this module.
|
||||
export { DockerError };
|
||||
|
||||
type ReqOptions = {
|
||||
method?: string;
|
||||
|
||||
@@ -14,6 +14,10 @@ function str(key: string, fallback: string): string {
|
||||
|
||||
export const env = {
|
||||
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" : ""),
|
||||
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 { env } from "./env";
|
||||
import { RconError } from "./errors";
|
||||
|
||||
/**
|
||||
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||
@@ -11,11 +12,9 @@ import { env } from "./env";
|
||||
const TYPE_AUTH = 3;
|
||||
const TYPE_COMMAND = 2;
|
||||
|
||||
export class RconError extends Error {
|
||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||
// that read-only routes can recognise it without importing this module.
|
||||
export { RconError };
|
||||
|
||||
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||
const bodyBuf = Buffer.from(body, "utf8");
|
||||
|
||||
@@ -117,3 +117,61 @@ services:
|
||||
mem_limit: 512m
|
||||
depends_on:
|
||||
- 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