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:
-
Middleware allowlist (
dashboard/src/proxy.ts) — default-deny. When the flag is set, every path returns404unless it is on a tiny GET-only allowlist. Non-GET methods are refused before anything else, so everyPOST/PUT/PATCH/DELETEroute (console, power, files, mods, config, auth…) is unreachable.404(not401/403) is used so the public deployment never even hints an admin app exists. -
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 — seedashboard/src/server/boot.tsand the allowed route handlers). All reads come from the feed file atFEED_PATHviadashboard/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-textdetailcolumn is dropped, so IP addresses that appear onconnect/blockedrows 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— readsdata/dashboard/craftdeck.db(ro) → writesdata/feed/feed.dbeveryFEED_INTERVAL_SECONDS(default 30).dashboard-public—PUBLIC_READONLY=1, mounts onlydata/feed(ro). Nodocker.sock, no./:/project, noRCON_PASSWORD, noPANEL_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.