Files
minecraft/docs/public-mode.md
T

9.6 KiB

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).

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):

# 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

# 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.