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