diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore index daf787e..5e620fc 100644 --- a/dashboard/.dockerignore +++ b/dashboard/.dockerignore @@ -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 diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index 94221c5..bece815 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -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 \ diff --git a/dashboard/scripts/export-feed.mjs b/dashboard/scripts/export-feed.mjs new file mode 100755 index 0000000..d37e146 --- /dev/null +++ b/dashboard/scripts/export-feed.mjs @@ -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}` +); diff --git a/dashboard/src/app/(panel)/layout.tsx b/dashboard/src/app/(panel)/layout.tsx index 4ce0025..1012499 100644 --- a/dashboard/src/app/(panel)/layout.tsx +++ b/dashboard/src/app/(panel)/layout.tsx @@ -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 {children}; + const publicMode = process.env.PUBLIC_READONLY === "1"; + return {children}; } diff --git a/dashboard/src/app/(panel)/page.tsx b/dashboard/src/app/(panel)/page.tsx index 68959f4..681f9f3 100644 --- a/dashboard/src/app/(panel)/page.tsx +++ b/dashboard/src/app/(panel)/page.tsx @@ -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("/api/server", 10000); const { data: metricRows, refresh: refreshMetrics } = useApi("/api/metrics?range=1h", 30000); const { data: activity, refresh: refreshActivity } = useApi("/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(null); const [, forceTick] = useState(0); @@ -113,34 +117,36 @@ export default function OverviewPage() { {running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}}

-
- - - -
+ {!publicMode && ( +
+ + + +
+ )} {!server?.dockerAvailable && server && ( @@ -230,7 +236,7 @@ export default function OverviewPage() { )} {/* admin quick controls */} - + {!publicMode && } {/* world + disk */} diff --git a/dashboard/src/app/(panel)/performance/page.tsx b/dashboard/src/app/(panel)/performance/page.tsx index 8b47c65..a0eaddc 100644 --- a/dashboard/src/app/(panel)/performance/page.tsx +++ b/dashboard/src/app/(panel)/performance/page.tsx @@ -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(`/api/metrics?range=${range}`, 30000); const { data: lag, refresh: refreshLag } = useApi("/api/lag", 30000); @@ -116,9 +118,11 @@ export default function PerformancePage() { - Full health report - + publicMode ? undefined : ( + + ) } >
diff --git a/dashboard/src/app/(panel)/players/page.tsx b/dashboard/src/app/(panel)/players/page.tsx index d838e08..260c852 100644 --- a/dashboard/src/app/(panel)/players/page.tsx +++ b/dashboard/src/app/(panel)/players/page.tsx @@ -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("/api/players", 15000); const { data: joins, refresh: refreshJoins } = useApi("/api/activity?limit=30", 20000); const [detail, setDetail] = useState(null); @@ -129,7 +131,7 @@ export default function PlayersPage() { {players.map((p) => ( setDetail(p)} > @@ -149,7 +151,7 @@ export default function PlayersPage() { {p.isOnline ? online now : timeAgo(p.lastSeenAt)} - {p.isOnline && ( + {!publicMode && p.isOnline && (
+ )} {/* creative admin favors (online players only) */} - {detail.isOnline && ( + {!publicMode && detail.isOnline && (
- Signed in as {me?.username ?? "…"} + {publicMode ? ( + + Read-only public mirror + + ) : ( + <> + Signed in as {me?.username ?? "…"} + + )}
@@ -148,17 +167,25 @@ export function Shell({ children }: { children: React.ReactNode }) { )}
- - + {publicMode ? ( + + Read-only public mirror + + ) : ( + <> + + + + )}
- {me?.mustChange && pathname !== "/settings" && ( + {!publicMode && me?.mustChange && pathname !== "/settings" && (
You are using the initial admin password —{" "} @@ -168,7 +195,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
)} -
{children}
+
+ {children} +
{/* refresh server summary when the tab regains focus */} diff --git a/dashboard/src/instrumentation.ts b/dashboard/src/instrumentation.ts index fc845ae..fbcf50e 100644 --- a/dashboard/src/instrumentation.ts +++ b/dashboard/src/instrumentation.ts @@ -1,6 +1,6 @@ export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { boot } = await import("./server/boot"); - boot(); + await boot(); } } diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index a059dd1..fe9cbd0 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -33,14 +33,20 @@ export async function api( return payload.data as T; } -/** Poll a GET endpoint; refresh() for manual refetch. */ -export function useApi(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(path: string, intervalMs = 0, opts: { enabled?: boolean } = {}) { + const enabled = opts.enabled ?? true; const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const alive = useRef(true); const refresh = useCallback(async () => { + if (!enabled) return; try { const d = await api(path); if (!alive.current) return; @@ -52,9 +58,13 @@ export function useApi(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(path: string, intervalMs = 0) { alive.current = false; if (timer) clearInterval(timer); }; - }, [refresh, intervalMs]); + }, [refresh, intervalMs, enabled]); return { data, error, loading, refresh }; } diff --git a/dashboard/src/lib/publicmode.tsx b/dashboard/src/lib/publicmode.tsx new file mode 100644 index 0000000..20449a0 --- /dev/null +++ b/dashboard/src/lib/publicmode.tsx @@ -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 {children}; +} + +export function usePublicMode(): boolean { + return useContext(PublicModeContext); +} diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index 43f670f..2d3bc88 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -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(); diff --git a/dashboard/src/server/api.ts b/dashboard/src/server/api.ts index 649470a..ed5f3e6 100644 --- a/dashboard/src/server/api.ts +++ b/dashboard/src/server/api.ts @@ -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 diff --git a/dashboard/src/server/boot.ts b/dashboard/src/server/boot.ts index 5ce4430..2549415 100644 --- a/dashboard/src/server/boot.ts +++ b/dashboard/src/server/boot.ts @@ -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; diff --git a/dashboard/src/server/docker.ts b/dashboard/src/server/docker.ts index a83477f..60c4cc1 100644 --- a/dashboard/src/server/docker.ts +++ b/dashboard/src/server/docker.ts @@ -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; diff --git a/dashboard/src/server/env.ts b/dashboard/src/server/env.ts index e7558f1..68f8f75 100644 --- a/dashboard/src/server/env.ts +++ b/dashboard/src/server/env.ts @@ -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" : ""), diff --git a/dashboard/src/server/errors.ts b/dashboard/src/server/errors.ts new file mode 100644 index 0000000..f28536c --- /dev/null +++ b/dashboard/src/server/errors.ts @@ -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); + } +} diff --git a/dashboard/src/server/feed.ts b/dashboard/src/server/feed.ts new file mode 100644 index 0000000..dd9b056 --- /dev/null +++ b/dashboard/src/server/feed.ts @@ -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 | null { + const db = feedDb(); + if (!db) return null; + try { + return ( + (db.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get() as + | Record + | 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 | 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(); +} diff --git a/dashboard/src/server/rcon.ts b/dashboard/src/server/rcon.ts index 2ff83f9..909ca94 100644 --- a/dashboard/src/server/rcon.ts +++ b/dashboard/src/server/rcon.ts @@ -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"); diff --git a/docker-compose.yml b/docker-compose.yml index 4e59f67..b4b8bbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/public-mode.md b/docs/public-mode.md new file mode 100644 index 0000000..5664be4 --- /dev/null +++ b/docs/public-mode.md @@ -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://: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.