Compare commits
No files matched your search
+5
-6
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# ── Minecraft ────────────────────────────────────────────────────────────
|
# ── Minecraft ────────────────────────────────────────────────────────────
|
||||||
# Game version. Change and re-run `docker compose up -d mc` to upgrade.
|
# Game version. Change and re-run `docker compose up -d mc` to upgrade.
|
||||||
MC_VERSION=26.3
|
MC_VERSION=26.2
|
||||||
# Loader: FABRIC (default), NEOFORGE, FORGE, VANILLA, PAPER, ...
|
# Loader: FABRIC (default), NEOFORGE, FORGE, VANILLA, PAPER, ...
|
||||||
SERVER_TYPE=FABRIC
|
SERVER_TYPE=FABRIC
|
||||||
# JVM heap for the server. Host has 10 GB → 6G heap / 7g container limit.
|
# JVM heap for the server. Host has 10 GB → 6G heap / 7g container limit.
|
||||||
@@ -25,8 +25,10 @@ MC_SIMULATION_DISTANCE=8
|
|||||||
MC_SEED=
|
MC_SEED=
|
||||||
|
|
||||||
# Server-side mods (Modrinth project slugs, comma-separated).
|
# Server-side mods (Modrinth project slugs, comma-separated).
|
||||||
# Exact version IDs are locked. Review all IDs before upgrading Minecraft.
|
# Auto-resolved to the newest compatible release on every container start.
|
||||||
MODRINTH_PROJECTS=fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB
|
# Append ":beta" to allow beta-typed builds for a project (EasyAuth ships
|
||||||
|
# its 26.x builds as beta on Modrinth).
|
||||||
|
MODRINTH_PROJECTS=fabric-api,easyauth:beta,spark,lithium,ferrite-core
|
||||||
# To run a full modpack instead, set its Modrinth slug (e.g. adrenaline)
|
# To run a full modpack instead, set its Modrinth slug (e.g. adrenaline)
|
||||||
# and see docs/mods-and-modpacks.md before enabling.
|
# and see docs/mods-and-modpacks.md before enabling.
|
||||||
MODRINTH_MODPACK=
|
MODRINTH_MODPACK=
|
||||||
@@ -38,9 +40,6 @@ PANEL_SESSION_SECRET=CHANGE_ME
|
|||||||
# you are forced to change it in the UI afterwards.
|
# you are forced to change it in the UI afterwards.
|
||||||
PANEL_ADMIN_PASSWORD=CHANGE_ME
|
PANEL_ADMIN_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
# Absolute checkout path on the Docker host (setup.sh fills this in).
|
|
||||||
PROJECT_DIR=
|
|
||||||
|
|
||||||
# ── Dashboard ────────────────────────────────────────────────────────────
|
# ── Dashboard ────────────────────────────────────────────────────────────
|
||||||
DASHBOARD_PORT=8080
|
DASHBOARD_PORT=8080
|
||||||
# Minutes an approved player has to /register before approval expires
|
# Minutes an approved player has to /register before approval expires
|
||||||
|
|||||||
@@ -12,7 +12,3 @@ dashboard/next-env.d.ts
|
|||||||
|
|
||||||
# OS noise
|
# OS noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local validation artifacts
|
|
||||||
__pycache__/
|
|
||||||
*.tsbuildinfo
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
# Minecraft 26.3 Fabric Server + CraftDeck Panel
|
# Minecraft 26.2 Fabric Server + CraftDeck Panel
|
||||||
|
|
||||||
For Orion, use the managed deployment in **homelab-infra**, described in [docs/orion.md](docs/orion.md). The root Compose file is the standalone LAN setup; it is not the TCPShield ingress configuration.
|
A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
|
||||||
|
|
||||||
A production-quality, self-hosted Minecraft **Java 26.3** server for friends:
|
|
||||||
|
|
||||||
- **Fabric** loader with invisible server-side performance mods — any vanilla client
|
- **Fabric** loader with invisible server-side performance mods — any vanilla client
|
||||||
(including TLauncher) can join, no client mods needed
|
(including TLauncher) can join, no client mods needed
|
||||||
@@ -15,7 +13,7 @@ A production-quality, self-hosted Minecraft **Java 26.3** server for friends:
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Minecraft | `26.3` (review loader and all mod pins together) |
|
| Minecraft | `26.2` (change one line to upgrade) |
|
||||||
| Loader | Fabric (NeoForge/Forge/vanilla supported via config) |
|
| Loader | Fabric (NeoForge/Forge/vanilla supported via config) |
|
||||||
| Server mods | Fabric API, EasyAuth, spark, Lithium, FerriteCore — auto-updated |
|
| Server mods | Fabric API, EasyAuth, spark, Lithium, FerriteCore — auto-updated |
|
||||||
| Panel | Next.js, port `8080`, LAN/VPN only |
|
| Panel | Next.js, port `8080`, LAN/VPN only |
|
||||||
@@ -78,7 +76,7 @@ they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
|
|||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
├── mc itzg/minecraft-server (Fabric 26.3, offline mode, whitelist enforced,
|
├── mc itzg/minecraft-server (Fabric 26.2, offline mode, whitelist enforced,
|
||||||
│ RCON on the internal network only, mods resolved from Modrinth at start)
|
│ RCON on the internal network only, mods resolved from Modrinth at start)
|
||||||
├── backup itzg/mc-backup (scheduled world tars + retention pruning)
|
├── backup itzg/mc-backup (scheduled world tars + retention pruning)
|
||||||
└── dashboard ./dashboard (CraftDeck: Next.js + node:sqlite; talks to the
|
└── dashboard ./dashboard (CraftDeck: Next.js + node:sqlite; talks to the
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.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
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS deps
|
FROM node:26-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS build
|
FROM node:26-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
|
FROM node:26-slim AS runtime
|
||||||
ARG TARGETARCH=amd64
|
ARG TARGETARCH=amd64
|
||||||
ARG DOCKER_CLI_VERSION=27.5.1
|
ARG DOCKER_CLI_VERSION=27.5.1
|
||||||
ARG COMPOSE_VERSION=v2.32.4
|
ARG COMPOSE_VERSION=v2.32.4
|
||||||
|
|
||||||
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip python3 \
|
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Docker CLI + compose plugin so the panel can recreate containers after
|
# Docker CLI + compose plugin so the panel can recreate containers after
|
||||||
@@ -46,7 +46,9 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/public ./public
|
||||||
COPY scripts/restore_data.py /app/restore_data.py
|
# 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
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
|||||||
Generated
+199
-1277
File diff suppressed because it is too large.
Load diff
@@ -6,9 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start"
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-json": "6.0.2",
|
"@codemirror/lang-json": "6.0.2",
|
||||||
@@ -16,7 +14,7 @@
|
|||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@uiw/react-codemirror": "4.25.11",
|
"@uiw/react-codemirror": "4.25.11",
|
||||||
"lucide-react": "1.24.0",
|
"lucide-react": "1.24.0",
|
||||||
"next": "16.3.7",
|
"next": "16.2.10",
|
||||||
"node-cron": "4.6.0",
|
"node-cron": "4.6.0",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7"
|
||||||
@@ -26,9 +24,8 @@
|
|||||||
"@types/node": "26.1.1",
|
"@types/node": "26.1.1",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"postcss": "8.5.28",
|
"postcss": "8.5.16",
|
||||||
"tailwindcss": "4.3.2",
|
"tailwindcss": "4.3.2",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3"
|
||||||
"vitest": "5.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,116 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate a Minecraft archive and replace data contents while preserving the bind root.
|
|
||||||
|
|
||||||
The caller MUST stop Minecraft and its backup writer before `restore`.
|
|
||||||
Only regular files/directories are accepted. No existing data is deleted.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def members(archive):
|
|
||||||
result = []
|
|
||||||
seen = set()
|
|
||||||
for member in archive.getmembers():
|
|
||||||
name = PurePosixPath(member.name)
|
|
||||||
if name.is_absolute() or ".." in name.parts or "\\" in member.name:
|
|
||||||
raise ValueError("Archive contains an unsafe path")
|
|
||||||
if not (member.isdir() or member.isfile()):
|
|
||||||
raise ValueError("Archive links and special files are not supported")
|
|
||||||
key = str(name)
|
|
||||||
if key in seen:
|
|
||||||
raise ValueError("Archive contains duplicate paths")
|
|
||||||
seen.add(key)
|
|
||||||
result.append(member)
|
|
||||||
if not any(m.isfile() and PurePosixPath(m.name).name == "level.dat" for m in result):
|
|
||||||
raise ValueError("Archive does not contain a Minecraft world (level.dat)")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def verify(archive_path):
|
|
||||||
# Reading each member also verifies the gzip footer, rather than only headers.
|
|
||||||
with tarfile.open(archive_path, "r:gz") as archive:
|
|
||||||
entries = members(archive)
|
|
||||||
for member in entries:
|
|
||||||
if member.isfile():
|
|
||||||
with archive.extractfile(member) as source:
|
|
||||||
while source.read(1024 * 1024):
|
|
||||||
pass
|
|
||||||
import gzip
|
|
||||||
with gzip.open(archive_path, "rb") as source:
|
|
||||||
while source.read(1024 * 1024):
|
|
||||||
pass
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def restore(archive_path, data_path, work_path, lock_file=None):
|
|
||||||
data = Path(data_path).resolve(strict=True)
|
|
||||||
work = Path(work_path).resolve()
|
|
||||||
if data == work or data in work.parents or work in data.parents:
|
|
||||||
raise ValueError("Restore workspace must be separate from the data tree")
|
|
||||||
work.mkdir(parents=True, exist_ok=True)
|
|
||||||
if data.stat().st_dev != work.stat().st_dev:
|
|
||||||
raise ValueError("Data and restore workspace must be on the same filesystem")
|
|
||||||
with (work / ".restore.lock").open("a") as operation_lock:
|
|
||||||
fcntl.flock(operation_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
entries = verify(archive_path)
|
|
||||||
required = sum(m.size for m in entries if m.isfile())
|
|
||||||
if shutil.disk_usage(work).free < required + 64 * 1024 * 1024:
|
|
||||||
raise ValueError("Insufficient free space for a staged restore")
|
|
||||||
run = Path(tempfile.mkdtemp(prefix="restore-", dir=work))
|
|
||||||
staged, previous = run / "staged", run / "previous"
|
|
||||||
staged.mkdir()
|
|
||||||
previous.mkdir()
|
|
||||||
with tarfile.open(archive_path, "r:gz") as archive:
|
|
||||||
archive.extractall(staged, members=members(archive), filter="data")
|
|
||||||
# tarfile's data filter intentionally discards archive ownership.
|
|
||||||
# Give the staged tree to the actual server data owner before activation.
|
|
||||||
owner = data.stat()
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
for child in staged.rglob("*"):
|
|
||||||
os.chown(child, owner.st_uid, owner.st_gid)
|
|
||||||
elif os.geteuid() != owner.st_uid:
|
|
||||||
raise ValueError("Restore must run as root or the server data owner")
|
|
||||||
if lock_file:
|
|
||||||
marker = staged / ".deployment-lock.json"
|
|
||||||
if not marker.is_file() or json.loads(marker.read_text()) != json.loads(Path(lock_file).read_text()):
|
|
||||||
raise ValueError("Backup deployment lock differs. Restore the matching release via the host runbook.")
|
|
||||||
# Moving children keeps the /data bind mount inode alive in existing containers.
|
|
||||||
moved_old, moved_new = [], []
|
|
||||||
try:
|
|
||||||
for child in data.iterdir():
|
|
||||||
child.rename(previous / child.name)
|
|
||||||
moved_old.append(child.name)
|
|
||||||
for child in staged.iterdir():
|
|
||||||
child.rename(data / child.name)
|
|
||||||
moved_new.append(child.name)
|
|
||||||
except BaseException:
|
|
||||||
for name in reversed(moved_new):
|
|
||||||
(data / name).rename(staged / name)
|
|
||||||
for name in reversed(moved_old):
|
|
||||||
(previous / name).rename(data / name)
|
|
||||||
raise
|
|
||||||
return {"previous_data": str(previous), "restored_from": str(Path(archive_path).resolve())}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("action", choices=["verify", "restore"])
|
|
||||||
parser.add_argument("--archive", required=True)
|
|
||||||
parser.add_argument("--data")
|
|
||||||
parser.add_argument("--work")
|
|
||||||
parser.add_argument("--lock-file")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.action == "verify":
|
|
||||||
verify(args.archive)
|
|
||||||
print(json.dumps({"valid": True}))
|
|
||||||
else:
|
|
||||||
if not args.data or not args.work:
|
|
||||||
parser.error("restore requires --data and --work")
|
|
||||||
print(json.dumps(restore(args.archive, args.data, args.work, args.lock_file)))
|
|
||||||
@@ -39,8 +39,6 @@ const SECTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment", 0);
|
|
||||||
const managed = deployment?.managed ?? true;
|
|
||||||
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
|
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
|
||||||
const [values, setValues] = useState<ConfigValues>({});
|
const [values, setValues] = useState<ConfigValues>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -105,10 +103,10 @@ export default function ConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={save} busy={saving} disabled={managed || !dirty}>
|
<Button onClick={save} busy={saving} disabled={!dirty}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
|
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
|
||||||
<Rocket size={14} /> Apply (restarts server)
|
<Rocket size={14} /> Apply (restarts server)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +128,6 @@ export default function ConfigPage() {
|
|||||||
<div key={f.key}>
|
<div key={f.key}>
|
||||||
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
|
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
|
||||||
<Input
|
<Input
|
||||||
disabled={managed}
|
|
||||||
value={values[f.key] ?? ""}
|
value={values[f.key] ?? ""}
|
||||||
onChange={(e) => set(f.key, e.target.value)}
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
className="w-full font-mono"
|
className="w-full font-mono"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
|
|||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { yaml } from "@codemirror/lang-yaml";
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { api, toast, useApi } from "@/lib/api";
|
import { api, toast } from "@/lib/api";
|
||||||
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
|
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
|
||||||
import { fmtBytes, fmtDateTime } from "@/lib/format";
|
import { fmtBytes, fmtDateTime } from "@/lib/format";
|
||||||
|
|
||||||
@@ -32,8 +32,6 @@ function langFor(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
|
||||||
const managed = deployment?.managed ?? true;
|
|
||||||
const [path, setPath] = useState("");
|
const [path, setPath] = useState("");
|
||||||
const [entries, setEntries] = useState<Entry[] | null>(null);
|
const [entries, setEntries] = useState<Entry[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -126,10 +124,10 @@ export default function FilesPage() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
|
<Button size="sm" onClick={() => uploadRef.current?.click()}>
|
||||||
<Upload size={13} /> Upload here
|
<Upload size={13} /> Upload here
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
<Button size="sm" onClick={() => setMkdirOpen(true)}>
|
||||||
<FolderPlus size={13} /> New folder
|
<FolderPlus size={13} /> New folder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +187,6 @@ export default function FilesPage() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={managed}
|
|
||||||
onClick={() => setConfirmDelete(e)}
|
onClick={() => setConfirmDelete(e)}
|
||||||
className="rounded p-1 text-muted hover:text-crit"
|
className="rounded p-1 text-muted hover:text-crit"
|
||||||
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
|
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
|
||||||
@@ -216,7 +213,7 @@ export default function FilesPage() {
|
|||||||
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
|
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={managed || !editing.dirty || editing.truncated}>
|
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
|
||||||
<Save size={13} /> Save
|
<Save size={13} /> Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setEditing(null)}>
|
<Button size="sm" onClick={() => setEditing(null)}>
|
||||||
@@ -226,8 +223,6 @@ export default function FilesPage() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
|
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
editable={!managed}
|
|
||||||
readOnly={managed}
|
|
||||||
value={editing.content}
|
value={editing.content}
|
||||||
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
|
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
|
||||||
extensions={langFor(editing.path)}
|
extensions={langFor(editing.path)}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Shell } from "@/components/shell";
|
import { Shell } from "@/components/shell";
|
||||||
import { ManagedNotice } from "@/components/managed-notice";
|
|
||||||
|
// 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 }) {
|
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <Shell><ManagedNotice />{children}</Shell>;
|
const publicMode = process.env.PUBLIC_READONLY === "1";
|
||||||
|
return <Shell publicMode={publicMode}>{children}</Shell>;
|
||||||
}
|
}
|
||||||
@@ -60,17 +60,15 @@ const FEATURED = [
|
|||||||
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
|
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "fabricproxy-lite",
|
slug: "proxy-protocol-support",
|
||||||
emoji: "🛡",
|
emoji: "🛡",
|
||||||
title: "FabricProxy-Lite",
|
title: "Proxy Protocol Support",
|
||||||
description:
|
description:
|
||||||
"Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
|
"Using TCPShield (or another proxy)? This restores players' real IPs so the Security page and IP bans work. Config is auto-enforced — see docs/tcpshield.md.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ModsPage() {
|
export default function ModsPage() {
|
||||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
|
||||||
const managed = deployment?.managed ?? true;
|
|
||||||
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
@@ -129,10 +127,10 @@ export default function ModsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Mods</h1>
|
<h1 className="text-xl font-bold">Mods</h1>
|
||||||
<p className="mt-0.5 text-xs text-ink2">
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
{managed ? "Mod versions are locked by the reviewed deployment." : "Pinned Modrinth version IDs remain fixed when the server restarts."}
|
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={managed} variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
||||||
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
|
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +150,7 @@ export default function ModsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={already ? "ghost" : "primary"}
|
variant={already ? "ghost" : "primary"}
|
||||||
disabled={managed || already}
|
disabled={already}
|
||||||
busy={busy === `add-${f.slug}`}
|
busy={busy === `add-${f.slug}`}
|
||||||
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
|
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
|
||||||
>
|
>
|
||||||
@@ -172,7 +170,7 @@ export default function ModsPage() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && void search()}
|
onKeyDown={(e) => e.key === "Enter" && void search()}
|
||||||
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.3"}…`}
|
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => void search()} busy={searching}>
|
<Button onClick={() => void search()} busy={searching}>
|
||||||
@@ -210,7 +208,7 @@ export default function ModsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={already ? "ghost" : "primary"}
|
variant={already ? "ghost" : "primary"}
|
||||||
disabled={managed || already}
|
disabled={already}
|
||||||
busy={busy === `add-${h.slug}`}
|
busy={busy === `add-${h.slug}`}
|
||||||
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
||||||
>
|
>
|
||||||
@@ -225,7 +223,8 @@ export default function ModsPage() {
|
|||||||
{/* managed list */}
|
{/* managed list */}
|
||||||
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
||||||
<p className="mb-3 text-[11px] leading-relaxed text-muted">
|
<p className="mb-3 text-[11px] leading-relaxed text-muted">
|
||||||
Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
|
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
|
||||||
|
newest compatible build on every start, so updating Minecraft also updates these automatically.
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{(data?.managed ?? []).map((slug) => (
|
{(data?.managed ?? []).map((slug) => (
|
||||||
@@ -241,7 +240,6 @@ export default function ModsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={managed}
|
|
||||||
busy={busy === `remove-${slug}`}
|
busy={busy === `remove-${slug}`}
|
||||||
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
|
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
|
||||||
>
|
>
|
||||||
@@ -287,7 +285,6 @@ export default function ModsPage() {
|
|||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={managed}
|
|
||||||
busy={busy === `toggle-${m.file}`}
|
busy={busy === `toggle-${m.file}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void manage(
|
void manage(
|
||||||
@@ -303,7 +300,6 @@ export default function ModsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={managed}
|
|
||||||
busy={busy === `delete-${m.file}`}
|
busy={busy === `delete-${m.file}`}
|
||||||
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
|
|||||||
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
||||||
import { QuickControls } from "@/components/quickcontrols";
|
import { QuickControls } from "@/components/quickcontrols";
|
||||||
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type ServerData = {
|
type ServerData = {
|
||||||
dockerAvailable: boolean;
|
dockerAvailable: boolean;
|
||||||
@@ -64,12 +65,15 @@ const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" |
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function OverviewPage() {
|
export default function OverviewPage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||||
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||||
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
|
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[] }>(
|
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
|
||||||
"/api/approved",
|
"/api/approved",
|
||||||
15000
|
15000,
|
||||||
|
{ enabled: !publicMode }
|
||||||
);
|
);
|
||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [, forceTick] = useState(0);
|
const [, forceTick] = useState(0);
|
||||||
@@ -113,34 +117,36 @@ export default function OverviewPage() {
|
|||||||
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
{!publicMode && (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="primary"
|
<Button
|
||||||
size="sm"
|
variant="primary"
|
||||||
onClick={() => power("start")}
|
size="sm"
|
||||||
busy={busyAction === "start"}
|
onClick={() => power("start")}
|
||||||
disabled={running || !server?.dockerAvailable}
|
busy={busyAction === "start"}
|
||||||
>
|
disabled={running || !server?.dockerAvailable}
|
||||||
<Play size={13} /> Start
|
>
|
||||||
</Button>
|
<Play size={13} /> Start
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => power("restart")}
|
size="sm"
|
||||||
busy={busyAction === "restart"}
|
onClick={() => power("restart")}
|
||||||
disabled={!running}
|
busy={busyAction === "restart"}
|
||||||
>
|
disabled={!running}
|
||||||
<RotateCw size={13} /> Restart
|
>
|
||||||
</Button>
|
<RotateCw size={13} /> Restart
|
||||||
<Button
|
</Button>
|
||||||
variant="danger"
|
<Button
|
||||||
size="sm"
|
variant="danger"
|
||||||
onClick={() => power("stop")}
|
size="sm"
|
||||||
busy={busyAction === "stop"}
|
onClick={() => power("stop")}
|
||||||
disabled={!running}
|
busy={busyAction === "stop"}
|
||||||
>
|
disabled={!running}
|
||||||
<Square size={13} /> Stop
|
>
|
||||||
</Button>
|
<Square size={13} /> Stop
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!server?.dockerAvailable && server && (
|
{!server?.dockerAvailable && server && (
|
||||||
@@ -230,7 +236,7 @@ export default function OverviewPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* admin quick controls */}
|
{/* admin quick controls */}
|
||||||
<QuickControls running={running} />
|
{!publicMode && <QuickControls running={running} />}
|
||||||
|
|
||||||
{/* world + disk */}
|
{/* world + disk */}
|
||||||
<Card title="World & storage">
|
<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 { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
|
||||||
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
||||||
import { fmtBytes } from "@/lib/format";
|
import { fmtBytes } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type MetricRow = {
|
type MetricRow = {
|
||||||
bucket: number;
|
bucket: number;
|
||||||
@@ -66,6 +67,7 @@ const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PerformancePage() {
|
export default function PerformancePage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const [range, setRange] = useState("1h");
|
const [range, setRange] = useState("1h");
|
||||||
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
||||||
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
||||||
@@ -116,9 +118,11 @@ export default function PerformancePage() {
|
|||||||
<Card
|
<Card
|
||||||
title="Lag diagnosis"
|
title="Lag diagnosis"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
publicMode ? undefined : (
|
||||||
<Stethoscope size={13} /> Full health report
|
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||||
</Button>
|
<Stethoscope size={13} /> Full health report
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start gap-4">
|
<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 { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
||||||
import { timeAgo, fmtDateTime } from "@/lib/format";
|
import { timeAgo, fmtDateTime } from "@/lib/format";
|
||||||
|
import { usePublicMode } from "@/lib/publicmode";
|
||||||
|
|
||||||
type PlayerRow = {
|
type PlayerRow = {
|
||||||
name: string;
|
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 };
|
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||||
|
|
||||||
export default function PlayersPage() {
|
export default function PlayersPage() {
|
||||||
|
const publicMode = usePublicMode();
|
||||||
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
||||||
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
||||||
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
||||||
@@ -129,7 +131,7 @@ export default function PlayersPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{players.map((p) => (
|
{players.map((p) => (
|
||||||
<tr
|
<tr
|
||||||
key={p.uuid}
|
key={p.uuid || p.name}
|
||||||
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
||||||
onClick={() => setDetail(p)}
|
onClick={() => setDetail(p)}
|
||||||
>
|
>
|
||||||
@@ -149,7 +151,7 @@ export default function PlayersPage() {
|
|||||||
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
{p.isOnline && (
|
{!publicMode && p.isOnline && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -226,6 +228,7 @@ export default function PlayersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* admin actions */}
|
{/* admin actions */}
|
||||||
|
{!publicMode && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||||
{detail.isOnline && (
|
{detail.isOnline && (
|
||||||
<>
|
<>
|
||||||
@@ -261,9 +264,10 @@ export default function PlayersPage() {
|
|||||||
<Gavel size={12} /> Ban
|
<Gavel size={12} /> Ban
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* creative admin favors (online players only) */}
|
{/* 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="mt-3 space-y-2 border-t border-border pt-3">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { NextRequest } from "next/server";
|
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";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const database = await source();
|
||||||
|
if (!database) return ok([]);
|
||||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||||
const type = req.nextUrl.searchParams.get("type");
|
const type = req.nextUrl.searchParams.get("type");
|
||||||
const rows = type
|
const rows = type
|
||||||
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
? database
|
||||||
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
.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);
|
return ok(rows);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
const name = req.nextUrl.searchParams.get("name");
|
const name = req.nextUrl.searchParams.get("name");
|
||||||
if (!name) return fail("Missing backup name");
|
if (!name) return fail("Missing backup name");
|
||||||
await deleteBackup(name);
|
deleteBackup(name);
|
||||||
return ok({ deleted: name });
|
return ok({ deleted: name });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { assertDeploymentEditable } from "@/server/managed";
|
|
||||||
import { withServerOperation } from "@/server/operation";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
||||||
import { safetyBackup } from "@/server/backups";
|
import { safetyBackup } from "@/server/backups";
|
||||||
@@ -7,10 +5,9 @@ import { notify } from "@/server/notify";
|
|||||||
import { ok, fail, readJson, handle } from "@/server/api";
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return handle(() => withServerOperation(async () => {
|
return handle(async () => {
|
||||||
assertDeploymentEditable();
|
|
||||||
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
|
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
|
||||||
if (service !== "backup" || backupFirst) await safetyBackup("config apply");
|
if (backupFirst) await safetyBackup("config apply");
|
||||||
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
|
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
notify("error", "Config apply failed", res.output.slice(-800));
|
notify("error", "Config apply failed", res.output.slice(-800));
|
||||||
@@ -18,5 +15,5 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
|
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
|
||||||
return ok({ output: res.output.slice(-1000) });
|
return ok({ output: res.output.slice(-1000) });
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { env } from "@/server/env";
|
|
||||||
import { ok } from "@/server/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export function GET() { return ok({ managed: env.managed }); }
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { db } from "@/server/db";
|
import { env } from "@/server/env";
|
||||||
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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
|
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
||||||
* a verdict the admin can read at a glance.
|
* 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() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicLag);
|
||||||
|
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const { db } = await import("@/server/db");
|
||||||
|
const { sparkPings, listOnlinePlayers } = await import("@/server/mc");
|
||||||
|
|
||||||
const recent = db()
|
const recent = db()
|
||||||
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
.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 }>;
|
.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 { 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";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 },
|
"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) {
|
export async function GET(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
const database = await source();
|
||||||
|
if (!database) return ok([]);
|
||||||
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||||
const since = Date.now() - range.ms;
|
const since = Date.now() - range.ms;
|
||||||
// Bucket-average so long ranges stay light on the wire.
|
// Bucket-average so long ranges stay light on the wire.
|
||||||
const rows = db()
|
const rows = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT (ts / ?) * ? AS bucket,
|
`SELECT (ts / ?) * ? AS bucket,
|
||||||
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { safetyBackup } from "@/server/backups";
|
|
||||||
import { assertDeploymentEditable } from "@/server/managed";
|
|
||||||
import { withServerOperation } from "@/server/operation";
|
|
||||||
import { applyMcConfig } from "@/server/compose";
|
import { applyMcConfig } from "@/server/compose";
|
||||||
import { notify } from "@/server/notify";
|
import { notify } from "@/server/notify";
|
||||||
import { ok, fail, handle } from "@/server/api";
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
return handle(() => withServerOperation(async () => {
|
return handle(async () => {
|
||||||
assertDeploymentEditable();
|
|
||||||
await safetyBackup("mod apply");
|
|
||||||
const res = await applyMcConfig();
|
const res = await applyMcConfig();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
notify("error", "Mod apply failed", res.output.slice(-800));
|
notify("error", "Mod apply failed", res.output.slice(-800));
|
||||||
@@ -17,5 +12,5 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
|
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
|
||||||
return ok({ output: res.output.slice(-1000) });
|
return ok({ output: res.output.slice(-1000) });
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
import { listOnlinePlayers } from "@/server/mc";
|
import { env } from "@/server/env";
|
||||||
import { readAllPlayerStats } from "@/server/playerstats";
|
|
||||||
import { db } from "@/server/db";
|
|
||||||
import { ok, handle } from "@/server/api";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicPlayers);
|
||||||
|
|
||||||
return handle(async () => {
|
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 online: string[] = [];
|
||||||
let max = 0;
|
let max = 0;
|
||||||
try {
|
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 { 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";
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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.
|
// BlueMap detection (in-network probe of the mc container), cached 60 s.
|
||||||
let mapCheck: { at: number; available: boolean } = { at: 0, available: false };
|
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 (!running) return false;
|
||||||
if (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
if (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
||||||
let available = false;
|
let available = false;
|
||||||
try {
|
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;
|
available = res.ok;
|
||||||
} catch {}
|
} catch {}
|
||||||
mapCheck = { at: Date.now(), available };
|
mapCheck = { at: Date.now(), available };
|
||||||
return 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() {
|
export async function GET() {
|
||||||
|
if (env.publicReadonly) return handle(publicStatus);
|
||||||
|
|
||||||
return handle(async () => {
|
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 container;
|
||||||
let dockerAvailable = true;
|
let dockerAvailable = true;
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +96,7 @@ export async function GET() {
|
|||||||
metrics: latest ?? null,
|
metrics: latest ?? null,
|
||||||
pendingApprovals: pendingCount,
|
pendingApprovals: pendingCount,
|
||||||
map: {
|
map: {
|
||||||
available: await bluemapAvailable(container.running),
|
available: await bluemapAvailable(env.mcContainer, container.running),
|
||||||
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
||||||
},
|
},
|
||||||
world: readWorldInfo(getWorldSizeBytes()),
|
world: readWorldInfo(getWorldSizeBytes()),
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { bus } from "@/server/bus";
|
import { bus } from "@/server/bus";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
* Multiplexed live event stream (SSE) for the UI.
|
||||||
* parsed game events, and new notifications.
|
*
|
||||||
|
* 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) {
|
export async function GET(req: NextRequest) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
const isPublic = env.publicReadonly;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -22,11 +30,24 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onMetrics = (m: unknown) => send("metrics", m);
|
const onMetrics = (m: unknown) => send("metrics", m);
|
||||||
const onEvent = (e: unknown) => send("mc-event", e);
|
const onEvent = (e: unknown) => {
|
||||||
const onNotification = (n: unknown) => send("notification", n);
|
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("metrics", onMetrics);
|
||||||
bus().on("mc-event", onEvent);
|
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(() => {
|
const keepalive = setInterval(() => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +60,7 @@ export async function GET(req: NextRequest) {
|
|||||||
closed = true;
|
closed = true;
|
||||||
bus().off("metrics", onMetrics);
|
bus().off("metrics", onMetrics);
|
||||||
bus().off("mc-event", onEvent);
|
bus().off("mc-event", onEvent);
|
||||||
bus().off("notification", onNotification);
|
if (onNotification) bus().off("notification", onNotification);
|
||||||
clearInterval(keepalive);
|
clearInterval(keepalive);
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { assertDeploymentEditable } from "@/server/managed";
|
|
||||||
import { withServerOperation } from "@/server/operation";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -20,8 +18,7 @@ export async function GET() {
|
|||||||
|
|
||||||
/** Danger zone: archive the current world and generate a fresh one. */
|
/** Danger zone: archive the current world and generate a fresh one. */
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return handle(() => withServerOperation(async () => {
|
return handle(async () => {
|
||||||
assertDeploymentEditable();
|
|
||||||
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
||||||
if (op !== "reset") return fail("Unknown operation");
|
if (op !== "reset") return fail("Unknown operation");
|
||||||
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
|
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
|
||||||
@@ -30,8 +27,6 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!fs.existsSync(worldDir)) return fail("No world folder found");
|
if (!fs.existsSync(worldDir)) return fail("No world folder found");
|
||||||
|
|
||||||
await safetyBackup("world reset");
|
await safetyBackup("world reset");
|
||||||
const backup = await inspectContainer(env.backupContainer);
|
|
||||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
|
||||||
const state = await inspectContainer(env.mcContainer);
|
const state = await inspectContainer(env.mcContainer);
|
||||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||||
|
|
||||||
@@ -39,9 +34,8 @@ export async function POST(req: NextRequest) {
|
|||||||
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
|
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
|
||||||
|
|
||||||
await powerContainer(env.mcContainer, "start");
|
await powerContainer(env.mcContainer, "start");
|
||||||
if (backup.running) await powerContainer(env.backupContainer, "start");
|
|
||||||
logActivity("world_reset", null, `old world kept as ${archived}`);
|
logActivity("world_reset", null, `old world kept as ${archived}`);
|
||||||
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
|
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
|
||||||
return ok({ archived });
|
return ok({ archived });
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useApi } from "@/lib/api";
|
|
||||||
|
|
||||||
export function ManagedNotice() {
|
|
||||||
const { data } = useApi<{ managed: boolean }>("/api/deployment", 0);
|
|
||||||
if (!data?.managed) return null;
|
|
||||||
return <div className="mb-4 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm">
|
|
||||||
Managed deployment: versions, mods and files are read-only here. Update the reviewed
|
|
||||||
deployment through Komodo. Console, player controls and backups remain available.
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
import { Badge, Toaster } from "@/components/ui";
|
import { Badge, Toaster } from "@/components/ui";
|
||||||
import { timeAgo } from "@/lib/format";
|
import { timeAgo } from "@/lib/format";
|
||||||
|
import { PublicModeProvider } from "@/lib/publicmode";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: "/", label: "Overview", icon: Gauge },
|
{ href: "/", label: "Overview", icon: Gauge },
|
||||||
@@ -39,6 +41,10 @@ const NAV = [
|
|||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ 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 = {
|
type ServerSummary = {
|
||||||
container: { running: boolean; status: string };
|
container: { running: boolean; status: string };
|
||||||
players: { online: number; max: number };
|
players: { online: number; max: number };
|
||||||
@@ -54,14 +60,19 @@ type NotificationRow = {
|
|||||||
read: number;
|
read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Shell({ children }: { children: React.ReactNode }) {
|
export function Shell({ children, publicMode = false }: { children: React.ReactNode; publicMode?: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
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 [running, setRunning] = useState<boolean | null>(null);
|
||||||
const [players, setPlayers] = useState<number | 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", {
|
useSSE("/api/stream/events", {
|
||||||
metrics: (m) => {
|
metrics: (m) => {
|
||||||
const d = m as { running: boolean; players: number | null };
|
const d = m as { running: boolean; players: number | null };
|
||||||
@@ -98,11 +109,11 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold leading-none">CraftDeck</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>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
<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 active = pathname === item.href;
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +136,15 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-border p-3 text-[11px] text-muted">
|
<div className="border-t border-border p-3 text-[11px] text-muted">
|
||||||
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
{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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -148,17 +167,25 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NotificationBell bump={unreadBump} />
|
{publicMode ? (
|
||||||
<button
|
<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">
|
||||||
onClick={logout}
|
<Eye size={12} /> Read-only public mirror
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
</span>
|
||||||
>
|
) : (
|
||||||
<LogOut size={13} /> Log out
|
<>
|
||||||
</button>
|
<NotificationBell bump={unreadBump} />
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
||||||
|
>
|
||||||
|
<LogOut size={13} /> Log out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<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 —{" "}
|
You are using the initial admin password —{" "}
|
||||||
<Link href="/settings" className="underline">
|
<Link href="/settings" className="underline">
|
||||||
@@ -168,7 +195,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="flex-1 p-6">{children}</main>
|
<main className="flex-1 p-6">
|
||||||
|
<PublicModeProvider value={publicMode}>{children}</PublicModeProvider>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
{/* refresh server summary when the tab regains focus */}
|
{/* refresh server summary when the tab regains focus */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { boot } = await import("./server/boot");
|
const { boot } = await import("./server/boot");
|
||||||
boot();
|
await boot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,14 +33,20 @@ export async function api<T = unknown>(
|
|||||||
return payload.data as T;
|
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 [data, setData] = useState<T | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const alive = useRef(true);
|
const alive = useRef(true);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
try {
|
try {
|
||||||
const d = await api<T>(path);
|
const d = await api<T>(path);
|
||||||
if (!alive.current) return;
|
if (!alive.current) return;
|
||||||
@@ -52,9 +58,13 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setLoading(false);
|
if (alive.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [path]);
|
}, [path, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
alive.current = true;
|
alive.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -63,7 +73,7 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
|||||||
alive.current = false;
|
alive.current = false;
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [refresh, intervalMs]);
|
}, [refresh, intervalMs, enabled]);
|
||||||
|
|
||||||
return { data, error, loading, refresh };
|
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"]);
|
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) {
|
export async function proxy(req: NextRequest) {
|
||||||
|
if (publicReadonly()) return handlePublic(req);
|
||||||
|
|
||||||
|
// ── Normal LAN admin mode (unchanged) ────────────────────────────────
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DockerError } from "./docker";
|
import { DockerError, RconError } from "./errors";
|
||||||
import { RconError } from "./rcon";
|
|
||||||
import { ManagedDeploymentError } from "./managed";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small helpers for route handlers. Authentication is enforced globally in
|
* Small helpers for route handlers. Authentication is enforced globally in
|
||||||
@@ -22,7 +20,6 @@ export function handle(fn: () => Promise<NextResponse> | NextResponse) {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
|
|
||||||
if (err instanceof DockerError && err.kind === "unavailable") {
|
if (err instanceof DockerError && err.kind === "unavailable") {
|
||||||
return fail("Docker is unreachable from the panel", 503);
|
return fail("Docker is unreachable from the panel", 503);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
||||||
import { logActivity } from "./db";
|
import { db, logActivity } from "./db";
|
||||||
import { notify } from "./notify";
|
import { notify } from "./notify";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { withServerOperation } from "./operation";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
|
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
|
||||||
@@ -41,63 +38,43 @@ export function listBackups(): BackupInfo[] {
|
|||||||
.sort((a, b) => b.mtime - a.mtime);
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerBackup(): Promise<string> {
|
export async function triggerBackup(): Promise<string> {
|
||||||
return withServerOperation(triggerBackupUnlocked);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerBackupUnlocked(): Promise<string> {
|
|
||||||
const before = new Set(listBackups().map((b) => b.name));
|
const before = new Set(listBackups().map((b) => b.name));
|
||||||
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
notify("error", "Backup failed", res.output.slice(-800));
|
notify("error", "Backup failed", res.output.slice(-800));
|
||||||
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
||||||
}
|
}
|
||||||
// Some sidecar versions acknowledge the request before writing the archive.
|
const created = listBackups().find((b) => !before.has(b.name));
|
||||||
// Wait for a new complete gzip/tar, never treat the request as a finished backup.
|
logActivity("backup", null, created?.name ?? "manual backup");
|
||||||
const deadline = Date.now() + 30 * 60 * 1000;
|
notify("success", "Backup completed", created ? created.name : "", { discord: false });
|
||||||
while (Date.now() < deadline) {
|
return created?.name ?? "backup completed";
|
||||||
for (const created of listBackups().filter((b) => !before.has(b.name) && b.size > 0)) {
|
|
||||||
try {
|
|
||||||
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", backupPath(created.name)],
|
|
||||||
{ timeout: 10 * 60 * 1000, maxBuffer: 1024 * 1024 });
|
|
||||||
logActivity("backup", null, created.name);
|
|
||||||
notify("success", "Backup completed", created.name, { discord: false });
|
|
||||||
return created.name;
|
|
||||||
} catch { /* Still being written, or invalid: do not report success. */ }
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
}
|
|
||||||
throw new Error("No new complete Minecraft backup appeared within 30 minutes");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreBackup(name: string): Promise<void> {
|
export async function restoreBackup(name: string): Promise<void> {
|
||||||
return withServerOperation(async () => {
|
const file = backupPath(name);
|
||||||
const file = backupPath(name);
|
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
|
||||||
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
|
const state = await inspectContainer(env.mcContainer);
|
||||||
{ timeout: 30 * 60 * 1000 });
|
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||||
const [mc, backup] = await Promise.all([
|
|
||||||
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
|
await new Promise<void>((resolve, reject) => {
|
||||||
]);
|
execFile(
|
||||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
"tar",
|
||||||
if (mc.running) await powerContainer(env.mcContainer, "stop");
|
["-xpzf", file, "-C", env.mcDataDir],
|
||||||
// Failure deliberately leaves the writers stopped for inspection.
|
{ timeout: 30 * 60 * 1000 },
|
||||||
const args = [env.restoreHelper, "restore", "--archive", file,
|
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
||||||
"--data", env.mcDataDir, "--work", env.restoreWorkDir];
|
);
|
||||||
if (env.managed) args.push("--lock-file", path.join(env.projectDir, "release-lock.json"));
|
|
||||||
const result = await execFileAsync("python3", args, { timeout: 30 * 60 * 1000 });
|
|
||||||
if (mc.running) await powerContainer(env.mcContainer, "start");
|
|
||||||
if (backup.running) await powerContainer(env.backupContainer, "start");
|
|
||||||
logActivity("restore", null, `${name}: ${result.stdout.trim()}`);
|
|
||||||
notify("success", "Backup restored", "Previous data retained in the restore workspace.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await powerContainer(env.mcContainer, "start");
|
||||||
|
logActivity("restore", null, name);
|
||||||
|
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBackup(name: string): Promise<void> {
|
export function deleteBackup(name: string): void {
|
||||||
return withServerOperation(async () => {
|
fs.unlinkSync(backupPath(name));
|
||||||
fs.unlinkSync(backupPath(name));
|
logActivity("backup_delete", null, name);
|
||||||
logActivity("backup_delete", null, name);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function backupsDirSize(): number {
|
export function backupsDirSize(): number {
|
||||||
@@ -115,7 +92,7 @@ export async function safetyBackup(reason: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await triggerBackup();
|
await triggerBackup();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
|
db();
|
||||||
throw err;
|
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,45 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { db } from "./db";
|
|
||||||
import { env } from "./env";
|
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
|
* One-time process bootstrap, invoked from instrumentation.ts when the
|
||||||
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
* 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 };
|
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
||||||
|
|
||||||
export function boot() {
|
export async function boot() {
|
||||||
if (g.__craftdeckBooted) return;
|
if (g.__craftdeckBooted) return;
|
||||||
g.__craftdeckBooted = true;
|
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]) {
|
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
seedAdminUser();
|
await seedAdminUser();
|
||||||
|
|
||||||
|
const [{ startLogWatcher }, { startSampler }, { startAuthWatcher }, { startScheduler }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("./logwatch"),
|
||||||
|
import("./sampler"),
|
||||||
|
import("./authflow"),
|
||||||
|
import("./scheduler"),
|
||||||
|
]);
|
||||||
startLogWatcher();
|
startLogWatcher();
|
||||||
startSampler();
|
startSampler();
|
||||||
startAuthWatcher();
|
startAuthWatcher();
|
||||||
@@ -33,7 +48,9 @@ export function boot() {
|
|||||||
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
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;
|
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
||||||
if (count > 0) return;
|
if (count > 0) return;
|
||||||
let password = env.initialAdminPassword;
|
let password = env.initialAdminPassword;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { assertDeploymentEditable } from "./managed";
|
|
||||||
import { withServerOperation } from "./operation";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply configuration changes by re-running docker compose for a service.
|
* Apply configuration changes by re-running docker compose for a service.
|
||||||
@@ -12,22 +9,21 @@ import path from "node:path";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||||
assertDeploymentEditable();
|
return new Promise((resolve) => {
|
||||||
return withServerOperation(() => new Promise((resolve) => {
|
|
||||||
execFile(
|
execFile(
|
||||||
"docker",
|
"docker",
|
||||||
["compose", "--project-directory", env.projectDir, "-f", path.join(env.projectDir, env.composeFile), ...args],
|
["compose", "--project-directory", env.projectDir, ...args],
|
||||||
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
|
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recreate the Minecraft container so new .env values take effect. */
|
/** Recreate the Minecraft container so new .env values take effect. */
|
||||||
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
|
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
|
||||||
return runCompose(["up", "-d", "--no-deps", env.mcService]);
|
return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recreate the backup sidecar (after backup schedule changes). */
|
/** Recreate the backup sidecar (after backup schedule changes). */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { withServerOperation } from "./operation";
|
import { DockerError } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||||
@@ -8,15 +8,9 @@ import { withServerOperation } from "./operation";
|
|||||||
* unavailable" / "container missing" states instead of crashing.
|
* unavailable" / "container missing" states instead of crashing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class DockerError extends Error {
|
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||||
constructor(
|
// that read-only routes can recognise it without importing this module.
|
||||||
message: string,
|
export { DockerError };
|
||||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
|
||||||
public statusCode?: number
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReqOptions = {
|
type ReqOptions = {
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -131,10 +125,6 @@ export async function containerEnv(name: string): Promise<Record<string, string>
|
|||||||
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||||
|
|
||||||
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||||
return withServerOperation(() => powerContainerUnlocked(name, action));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
|
||||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -297,6 +287,5 @@ export async function execInContainer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
|
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
|
||||||
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
|
return { exitCode: info.ExitCode ?? 0, output };
|
||||||
return { exitCode: info.ExitCode, output };
|
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,10 @@ function str(key: string, fallback: string): string {
|
|||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
dev,
|
dev,
|
||||||
managed: process.env.PANEL_MANAGED === "true",
|
// Public read-only mirror mode (see docs/public-mode.md).
|
||||||
mcService: str("MC_SERVICE", "mc"),
|
publicReadonly: process.env.PUBLIC_READONLY === "1",
|
||||||
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
|
feedPath: path.resolve(str("FEED_PATH", dev ? "./dev-data/feed/feed.db" : "/feed/feed.db")),
|
||||||
restoreWorkDir: path.resolve(str("RESTORE_WORK_DIR", dev ? "./dev-data/restore" : "/restore-work")),
|
|
||||||
restoreHelper: str("RESTORE_HELPER", "/app/restore_data.py"),
|
|
||||||
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { assertDeploymentEditable } from "./managed";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read/write the project .env file while preserving comments and ordering.
|
* Read/write the project .env file while preserving comments and ordering.
|
||||||
@@ -19,20 +18,16 @@ export function readEnvFile(): Record<string, string> {
|
|||||||
try {
|
try {
|
||||||
text = fs.readFileSync(envFilePath(), "utf8");
|
text = fs.readFileSync(envFilePath(), "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : out;
|
return out;
|
||||||
}
|
}
|
||||||
for (const line of text.split("\n")) {
|
for (const line of text.split("\n")) {
|
||||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||||
if (m) out[m[1]] = m[2].trim();
|
if (m) out[m[1]] = m[2].trim();
|
||||||
}
|
}
|
||||||
if (env.managed) {
|
|
||||||
for (const key of EDITABLE_ENV_KEYS) out[key] = process.env[key] ?? out[key] ?? "";
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeEnvFile(updates: Record<string, string>): void {
|
export function writeEnvFile(updates: Record<string, string>): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const file = envFilePath();
|
const file = envFilePath();
|
||||||
let lines: string[];
|
let lines: string[];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { assertDeploymentEditable } from "./managed";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sandboxed file access for the file browser / editors. Every path is
|
* Sandboxed file access for the file browser / editors. Every path is
|
||||||
@@ -81,21 +80,18 @@ export function readTextFile(rel: string): { content: string; truncated: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function writeTextFile(rel: string, content: string): void {
|
export function writeTextFile(rel: string, content: string): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const abs = resolveSafe(rel);
|
const abs = resolveSafe(rel);
|
||||||
fs.writeFileSync(abs, content);
|
fs.writeFileSync(abs, content);
|
||||||
fixOwnership(abs);
|
fixOwnership(abs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeBinaryFile(rel: string, data: Buffer): void {
|
export function writeBinaryFile(rel: string, data: Buffer): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const abs = resolveSafe(rel);
|
const abs = resolveSafe(rel);
|
||||||
fs.writeFileSync(abs, data);
|
fs.writeFileSync(abs, data);
|
||||||
fixOwnership(abs);
|
fixOwnership(abs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePath(rel: string): void {
|
export function deletePath(rel: string): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const abs = resolveSafe(rel);
|
const abs = resolveSafe(rel);
|
||||||
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
||||||
const st = fs.statSync(abs);
|
const st = fs.statSync(abs);
|
||||||
@@ -107,7 +103,6 @@ export function deletePath(rel: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeDir(rel: string): void {
|
export function makeDir(rel: string): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const abs = resolveSafe(rel);
|
const abs = resolveSafe(rel);
|
||||||
fs.mkdirSync(abs, { recursive: true });
|
fs.mkdirSync(abs, { recursive: true });
|
||||||
fixOwnership(abs);
|
fixOwnership(abs);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { env } from "./env";
|
|
||||||
|
|
||||||
export class ManagedDeploymentError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("This deployment is managed by Komodo. Change its reviewed configuration in homelab-infra.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertDeploymentEditable(): void {
|
|
||||||
if (env.managed) throw new ManagedDeploymentError();
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { readEnvFile, writeEnvFile } from "./envfile";
|
import { readEnvFile, writeEnvFile } from "./envfile";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { assertDeploymentEditable } from "./managed";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
||||||
@@ -25,7 +24,7 @@ export type ModSearchResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function currentMcVersion(): string {
|
export function currentMcVersion(): string {
|
||||||
return readEnvFile()["MC_VERSION"] || "26.3";
|
return readEnvFile()["MC_VERSION"] || "26.2";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
||||||
@@ -144,13 +143,11 @@ function safeModFile(file: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toggleMod(file: string): void {
|
export function toggleMod(file: string): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
const full = safeModFile(file);
|
const full = safeModFile(file);
|
||||||
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
||||||
fs.renameSync(full, target);
|
fs.renameSync(full, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteModFile(file: string): void {
|
export function deleteModFile(file: string): void {
|
||||||
assertDeploymentEditable();
|
|
||||||
fs.unlinkSync(safeModFile(file));
|
fs.unlinkSync(safeModFile(file));
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
|
|
||||||
// One reentrant queue for power, backup, restore and Compose operations.
|
|
||||||
const state = globalThis as unknown as {
|
|
||||||
__craftdeckOperation?: { tail: Promise<unknown>; context: AsyncLocalStorage<boolean> };
|
|
||||||
};
|
|
||||||
const queue = state.__craftdeckOperation ??= {
|
|
||||||
tail: Promise.resolve(), context: new AsyncLocalStorage<boolean>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function withServerOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
||||||
if (queue.context.getStore()) return operation();
|
|
||||||
const result = queue.tail.then(() => queue.context.run(true, operation));
|
|
||||||
queue.tail = result.catch(() => undefined);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { RconError } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||||
@@ -11,11 +12,9 @@ import { env } from "./env";
|
|||||||
const TYPE_AUTH = 3;
|
const TYPE_AUTH = 3;
|
||||||
const TYPE_COMMAND = 2;
|
const TYPE_COMMAND = 2;
|
||||||
|
|
||||||
export class RconError extends Error {
|
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
// that read-only routes can recognise it without importing this module.
|
||||||
super(message);
|
export { RconError };
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePacket(id: number, type: number, body: string): Buffer {
|
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||||
const bodyBuf = Buffer.from(body, "utf8");
|
const bodyBuf = Buffer.from(body, "utf8");
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import cron, { type ScheduledTask } from "node-cron";
|
import cron, { type ScheduledTask } from "node-cron";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { notify } from "./notify";
|
import { notify } from "./notify";
|
||||||
import { powerContainer } from "./docker";
|
import { powerContainer, execInContainer } from "./docker";
|
||||||
import { rcon } from "./rcon";
|
import { rcon } from "./rcon";
|
||||||
import { say } from "./mc";
|
import { say } from "./mc";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { triggerBackup } from "./backups";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
||||||
@@ -48,7 +47,9 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
|
|||||||
return "Restarted";
|
return "Restarted";
|
||||||
}
|
}
|
||||||
case "backup": {
|
case "backup": {
|
||||||
return await triggerBackup();
|
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
|
||||||
|
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
|
||||||
|
return "Backup completed";
|
||||||
}
|
}
|
||||||
case "command": {
|
case "command": {
|
||||||
if (!payload) throw new Error("No command configured");
|
if (!payload) throw new Error("No command configured");
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { withServerOperation } from "../src/server/operation";
|
|
||||||
|
|
||||||
describe("server operation queue", () => {
|
|
||||||
it("serializes destructive operations while allowing nested backups", async () => {
|
|
||||||
const events: string[] = [];
|
|
||||||
let release!: () => void;
|
|
||||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
||||||
const first = withServerOperation(async () => {
|
|
||||||
events.push("restore-start");
|
|
||||||
await withServerOperation(async () => { events.push("nested-backup"); });
|
|
||||||
await gate;
|
|
||||||
events.push("restore-finish");
|
|
||||||
});
|
|
||||||
const second = withServerOperation(async () => { events.push("power"); });
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
expect(events).toEqual(["restore-start", "nested-backup"]);
|
|
||||||
release();
|
|
||||||
await Promise.all([first, second]);
|
|
||||||
expect(events).toEqual(["restore-start", "nested-backup", "restore-finish", "power"]);
|
|
||||||
});
|
|
||||||
it("a failure does not poison later operations", async () => {
|
|
||||||
await expect(withServerOperation(async () => { throw new Error("failed"); })).rejects.toThrow("failed");
|
|
||||||
await expect(withServerOperation(async () => "next")).resolves.toBe("next");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
vi.mock("../src/server/env", () => ({ env: { managed: false, backupsDir: "/missing-test-backups", backupContainer: "backup", projectDir: "/missing-project" } }));
|
|
||||||
vi.mock("../src/server/docker", () => ({
|
|
||||||
execInContainer: vi.fn(async () => ({ exitCode: 1, output: "backup disk full" })),
|
|
||||||
powerContainer: vi.fn(), inspectContainer: vi.fn(), DockerError: class extends Error {},
|
|
||||||
}));
|
|
||||||
vi.mock("../src/server/db", () => ({ logActivity: vi.fn() }));
|
|
||||||
vi.mock("../src/server/notify", () => ({ notify: vi.fn() }));
|
|
||||||
vi.mock("../src/server/compose", () => ({ applyMcConfig: vi.fn(), applyBackupConfig: vi.fn() }));
|
|
||||||
import { env } from "../src/server/env";
|
|
||||||
import { safetyBackup } from "../src/server/backups";
|
|
||||||
import { execInContainer } from "../src/server/docker";
|
|
||||||
import { applyMcConfig } from "../src/server/compose";
|
|
||||||
import { writeEnvFile } from "../src/server/envfile";
|
|
||||||
import { writeTextFile, writeBinaryFile, deletePath, makeDir } from "../src/server/files";
|
|
||||||
import { POST } from "../src/app/api/config/apply/route";
|
|
||||||
import { POST as applyMods } from "../src/app/api/mods/apply/route";
|
|
||||||
|
|
||||||
beforeEach(() => { env.managed = false; vi.clearAllMocks(); });
|
|
||||||
describe("required backups and managed deployment", () => {
|
|
||||||
it("does not swallow a failed safety backup", async () => {
|
|
||||||
await expect(safetyBackup("upgrade")).rejects.toThrow("disk full");
|
|
||||||
});
|
|
||||||
it("aborts a config apply even when a client asks to skip backup", async () => {
|
|
||||||
const response = await POST(new NextRequest("http://localhost/api/config/apply", {
|
|
||||||
method: "POST", body: JSON.stringify({ backupFirst: false }),
|
|
||||||
}));
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it("also aborts mod apply after a failed backup", async () => {
|
|
||||||
expect((await applyMods()).status).toBe(500);
|
|
||||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it("rejects managed config apply before executing anything", async () => {
|
|
||||||
env.managed = true;
|
|
||||||
const response = await POST(new NextRequest("http://localhost/api/config/apply", { method: "POST", body: "{}" }));
|
|
||||||
expect(response.status).toBe(409);
|
|
||||||
expect(execInContainer).not.toHaveBeenCalled();
|
|
||||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it("guards direct config and file mutation helpers", () => {
|
|
||||||
env.managed = true;
|
|
||||||
for (const change of [
|
|
||||||
() => writeEnvFile({ MC_VERSION: "latest" }),
|
|
||||||
() => writeTextFile("server.properties", "bad"),
|
|
||||||
() => writeBinaryFile("mods/unreviewed.jar", Buffer.from("bad")),
|
|
||||||
() => deletePath("world"), () => makeDir("mods"),
|
|
||||||
]) expect(change).toThrow("managed by Komodo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("restore_data", Path(__file__).parents[1] / "scripts/restore_data.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
|
|
||||||
|
|
||||||
class RestoreTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temp.cleanup)
|
|
||||||
self.root = Path(self.temp.name)
|
|
||||||
self.data = self.root / "data"
|
|
||||||
self.data.mkdir()
|
|
||||||
(self.data / "newer-only.dat").write_text("preserve me")
|
|
||||||
self.archive = self.root / "backup.tgz"
|
|
||||||
|
|
||||||
def make_archive(self, files, link=False):
|
|
||||||
with tarfile.open(self.archive, "w:gz") as tar:
|
|
||||||
for name, content in files.items():
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
payload = content.encode()
|
|
||||||
info.size = len(payload)
|
|
||||||
tar.addfile(info, io.BytesIO(payload))
|
|
||||||
if link:
|
|
||||||
info = tarfile.TarInfo("world/escape")
|
|
||||||
info.type, info.linkname = tarfile.SYMTYPE, "/etc"
|
|
||||||
tar.addfile(info)
|
|
||||||
|
|
||||||
def test_clean_restore_keeps_previous_and_bind_root(self):
|
|
||||||
self.make_archive({"./world/level.dat": "old world"})
|
|
||||||
inode = self.data.stat().st_ino
|
|
||||||
result = module.restore(self.archive, self.data, self.root / "restore")
|
|
||||||
self.assertEqual(self.data.stat().st_ino, inode)
|
|
||||||
self.assertFalse((self.data / "newer-only.dat").exists())
|
|
||||||
self.assertEqual((self.data / "world/level.dat").read_text(), "old world")
|
|
||||||
self.assertEqual((Path(result["previous_data"]) / "newer-only.dat").read_text(), "preserve me")
|
|
||||||
|
|
||||||
def test_traversal_rejected_before_mutation(self):
|
|
||||||
self.make_archive({"world/level.dat": "old", "../outside": "bad"})
|
|
||||||
with self.assertRaises(ValueError): module.restore(self.archive, self.data, self.root / "restore")
|
|
||||||
self.assertTrue((self.data / "newer-only.dat").exists())
|
|
||||||
|
|
||||||
def test_link_rejected(self):
|
|
||||||
self.make_archive({"world/level.dat": "old"}, link=True)
|
|
||||||
with self.assertRaises(ValueError): module.verify(self.archive)
|
|
||||||
|
|
||||||
def test_release_mismatch_keeps_current_world(self):
|
|
||||||
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": '{"minecraft":"26.2"}'})
|
|
||||||
lock = self.root / "lock.json"
|
|
||||||
lock.write_text('{"minecraft":"26.3"}')
|
|
||||||
with self.assertRaisesRegex(ValueError, "lock differs"):
|
|
||||||
module.restore(self.archive, self.data, self.root / "restore", lock)
|
|
||||||
self.assertTrue((self.data / "newer-only.dat").exists())
|
|
||||||
|
|
||||||
def test_matching_release_can_restore(self):
|
|
||||||
lock = self.root / "lock.json"
|
|
||||||
lock.write_text('{"minecraft":"26.3"}')
|
|
||||||
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": lock.read_text()})
|
|
||||||
module.restore(self.archive, self.data, self.root / "restore", lock)
|
|
||||||
self.assertTrue((self.data / "world/level.dat").exists())
|
|
||||||
|
|
||||||
def test_truncated_archive_rejected(self):
|
|
||||||
self.make_archive({"world/level.dat": "old"})
|
|
||||||
self.archive.write_bytes(self.archive.read_bytes()[:-12])
|
|
||||||
with self.assertRaises((EOFError, tarfile.TarError)):
|
|
||||||
module.verify(self.archive)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": unittest.main()
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
export default defineConfig({
|
|
||||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
|
||||||
test: { environment: "node", include: ["tests/**/*.test.ts"] },
|
|
||||||
});
|
|
||||||
+82
-26
@@ -1,29 +1,20 @@
|
|||||||
name: minecraft
|
name: minecraft
|
||||||
|
|
||||||
x-logging: &logging
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ── Minecraft server (Fabric via itzg/minecraft-server) ─────────────────
|
# ── Minecraft server (Fabric via itzg/minecraft-server) ─────────────────
|
||||||
mc:
|
mc:
|
||||||
image: itzg/minecraft-server:java25@sha256:783d2712019a3996b4168752517a08d3448ef8995879b200316c3888f98dc394
|
image: itzg/minecraft-server:latest
|
||||||
container_name: mc
|
container_name: mc
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
<<: *logging
|
|
||||||
stop_grace_period: 2m
|
stop_grace_period: 2m
|
||||||
tty: false
|
tty: false
|
||||||
stdin_open: false
|
stdin_open: false
|
||||||
environment:
|
environment:
|
||||||
EULA: "TRUE"
|
EULA: "TRUE"
|
||||||
TYPE: ${SERVER_TYPE:-FABRIC}
|
TYPE: ${SERVER_TYPE:-FABRIC}
|
||||||
VERSION: ${MC_VERSION:-26.3}
|
VERSION: ${MC_VERSION:-26.2}
|
||||||
MEMORY: ${MC_MEMORY:-6G}
|
MEMORY: ${MC_MEMORY:-6G}
|
||||||
FABRIC_LOADER_VERSION: "0.19.5"
|
USE_AIKAR_FLAGS: "true"
|
||||||
FABRIC_LAUNCHER_VERSION: "1.1.2"
|
|
||||||
|
|
||||||
# Offline mode + whitelist as the approval gate (see docs/auth-flow.md).
|
# Offline mode + whitelist as the approval gate (see docs/auth-flow.md).
|
||||||
# enforce-secure-profile must be off or offline-launcher players are
|
# enforce-secure-profile must be off or offline-launcher players are
|
||||||
@@ -45,28 +36,35 @@ services:
|
|||||||
ENABLE_RCON: "true"
|
ENABLE_RCON: "true"
|
||||||
RCON_PASSWORD: ${RCON_PASSWORD:?generate with scripts/setup.sh}
|
RCON_PASSWORD: ${RCON_PASSWORD:?generate with scripts/setup.sh}
|
||||||
|
|
||||||
# Reviewed exact Modrinth version IDs. Update these with Minecraft.
|
# Server-side mods, auto-resolved for the current MC version on every start.
|
||||||
MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB}
|
# ":beta" allows beta-typed builds for that project (EasyAuth publishes
|
||||||
MODRINTH_DOWNLOAD_DEPENDENCIES: none
|
# its 26.x builds as beta).
|
||||||
|
MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api,easyauth:beta,spark,lithium,ferrite-core}
|
||||||
|
MODRINTH_DOWNLOAD_DEPENDENCIES: required
|
||||||
|
|
||||||
# Modpack support (leave empty for lean Fabric; see docs/mods-and-modpacks.md)
|
# Modpack support (leave empty for lean Fabric; see docs/mods-and-modpacks.md)
|
||||||
MODRINTH_MODPACK: ${MODRINTH_MODPACK:-}
|
MODRINTH_MODPACK: ${MODRINTH_MODPACK:-}
|
||||||
|
|
||||||
|
# Enforce mod configs on every boot (proxy-protocol/TCPShield settings —
|
||||||
|
# see docs/tcpshield.md). Mods that rewrite their own config files can't
|
||||||
|
# clobber these: patches re-apply before each server start.
|
||||||
|
PATCH_DEFINITIONS: /patches/patch-set.json
|
||||||
|
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "${MC_PORT:-25565}:25565"
|
- "${MC_PORT:-25565}:25565"
|
||||||
|
# BlueMap web map (only active if the bluemap mod is installed)
|
||||||
|
- "${MAP_PORT:-8100}:8100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/mc:/data
|
- ./data/mc:/data
|
||||||
|
- ./config/patches:/patches:ro
|
||||||
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}
|
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}
|
||||||
|
|
||||||
# ── Scheduled world backups (itzg/mc-backup) ─────────────────────────────
|
# ── Scheduled world backups (itzg/mc-backup) ─────────────────────────────
|
||||||
backup:
|
backup:
|
||||||
image: itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383
|
image: itzg/mc-backup:latest
|
||||||
container_name: mc-backup
|
container_name: mc-backup
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
<<: *logging
|
|
||||||
environment:
|
environment:
|
||||||
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
|
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
|
||||||
INITIAL_DELAY: 10m
|
INITIAL_DELAY: 10m
|
||||||
@@ -92,7 +90,6 @@ services:
|
|||||||
image: minecraft-dashboard:local
|
image: minecraft-dashboard:local
|
||||||
container_name: mc-dashboard
|
container_name: mc-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
<<: *logging
|
|
||||||
environment:
|
environment:
|
||||||
PANEL_SESSION_SECRET: ${PANEL_SESSION_SECRET:?generate with scripts/setup.sh}
|
PANEL_SESSION_SECRET: ${PANEL_SESSION_SECRET:?generate with scripts/setup.sh}
|
||||||
PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-}
|
PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-}
|
||||||
@@ -101,11 +98,10 @@ services:
|
|||||||
RCON_PASSWORD: ${RCON_PASSWORD}
|
RCON_PASSWORD: ${RCON_PASSWORD}
|
||||||
MC_CONTAINER: mc
|
MC_CONTAINER: mc
|
||||||
BACKUP_CONTAINER: mc-backup
|
BACKUP_CONTAINER: mc-backup
|
||||||
MC_DATA_DIR: /server-data/mc
|
MC_DATA_DIR: /mc-data
|
||||||
RESTORE_WORK_DIR: /server-data/restore
|
BACKUPS_DIR: /backups
|
||||||
BACKUPS_DIR: /server-data/backups
|
APP_DATA_DIR: /app-data
|
||||||
APP_DATA_DIR: /server-data/dashboard
|
PROJECT_DIR: /project
|
||||||
PROJECT_DIR: ${PROJECT_DIR:?Set PROJECT_DIR to the absolute host checkout path}
|
|
||||||
REGISTRATION_WINDOW_MINUTES: ${REGISTRATION_WINDOW_MINUTES:-2}
|
REGISTRATION_WINDOW_MINUTES: ${REGISTRATION_WINDOW_MINUTES:-2}
|
||||||
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
|
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
@@ -114,8 +110,68 @@ services:
|
|||||||
- "${DASHBOARD_PORT:-8080}:8080"
|
- "${DASHBOARD_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./data:/server-data
|
- ./data/mc:/mc-data
|
||||||
- ${PROJECT_DIR:?Set PROJECT_DIR}:${PROJECT_DIR}
|
- ./data/backups:/backups
|
||||||
|
- ./data/dashboard:/app-data
|
||||||
|
- ./:/project
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
depends_on:
|
depends_on:
|
||||||
- mc
|
- 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
|
||||||
+48
-10
@@ -1,19 +1,57 @@
|
|||||||
# Backups and restore
|
# Backups & restore
|
||||||
|
|
||||||
The backup sidecar archives server data every 24 hours by default and keeps seven days locally. It flushes the world through RCON first and excludes temporary files, caches and logs. Manual panel backups wait for a newly created archive to pass complete gzip/tar validation. A request acknowledgment alone is not backup success.
|
## What runs automatically
|
||||||
|
|
||||||
Config and mod Apply require a successful safety backup; failure cancels the operation. Standalone world reset does too. Orion managed mode rejects configuration changes and world reset entirely; upgrades are reviewed in homelab-infra.
|
The `backup` sidecar (itzg/mc-backup) tars the entire server data directory to
|
||||||
|
`data/backups/world-<timestamp>.tgz`:
|
||||||
|
|
||||||
## Same-release world restore
|
- flushes the world to disk first (`save-all`), safe while players are online
|
||||||
|
- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
|
||||||
|
- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
|
||||||
|
- excludes caches and logs
|
||||||
|
|
||||||
Use the Backups page, or `scripts/restore.sh /absolute/path/backup.tgz` as the standalone fallback. Orion's fallback takes the extra arguments `/opt/minecraft/project /opt/minecraft/data`.
|
Safety backups are also taken automatically before config applies (when enabled) and
|
||||||
|
world resets.
|
||||||
|
|
||||||
The panel stops Minecraft and the backup writer, validates archive paths and the managed release lock, extracts a clean staging tree, and moves the existing data into a retained `restore-*/previous` directory. It preserves the bind-mount root and correct server ownership. Files that only existed in the newer world are not left active. On failure the writers remain stopped for inspection. The shell fallback also stops the panel to avoid competing requests.
|
## Manual backup
|
||||||
|
|
||||||
Do not overlay an archive with `tar -xzf` onto a populated world. Keep the world and staging workspace under a common parent mount, on the same filesystem. Reserve disk for the active world, staged copy and retained previous data, and remove retained copies only after confirming a successful recovery.
|
- Panel: Backups page → **Back up now**
|
||||||
|
- CLI: `docker exec mc-backup backup now`
|
||||||
|
|
||||||
## Complete recovery
|
## Restoring
|
||||||
|
|
||||||
A world archive contains Minecraft and EasyAuth state. It does not contain CraftDeck's database, deployment config, proxy secret or panel credentials. For Orion use `homelab-infra/stacks/minecraft/cold-backup.sh`: it stops the stack and captures the complete deployment plus an export of the pinned panel image. Copy the secret-bearing backup off-host with private permissions.
|
Panel: Backups page → **Restore** → type `restore`. The panel stops the server,
|
||||||
|
extracts the tar over the data directory, and starts the server again. Everything since
|
||||||
|
that backup is lost.
|
||||||
|
|
||||||
Restore a different release only with its matching complete backup, release lock and image. Keep the failed state recoverable, restore into a clean directory and recreate containers when replacing mount roots. Do not downgrade Minecraft against a newer world. Test the complete restore in isolation before relying on it. See [orion.md](orion.md) and the infrastructure runbook for the release workflow.
|
CLI fallback (panel down):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore.sh data/backups/world-20260710-050000.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Off-site copies (recommended)
|
||||||
|
|
||||||
|
`data/backups/` is just files — sync it anywhere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# example: nightly rsync to another machine (add to the host's crontab)
|
||||||
|
0 6 * * * rsync -a --delete /path/to/minecraft/data/backups/ backup-host:/srv/mc-backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also download any backup from the panel (⬇ button) before risky experiments.
|
||||||
|
|
||||||
|
## Disaster recovery (new machine)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> minecraft && cd minecraft
|
||||||
|
./scripts/setup.sh # fresh secrets — friends keep their passwords,
|
||||||
|
# those live in the world data, not in .env
|
||||||
|
mkdir -p data/backups && cp <your-backup>.tgz data/backups/
|
||||||
|
docker compose up -d # let it initialize once
|
||||||
|
./scripts/restore.sh data/backups/<your-backup>.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
The backup contains the world, mods, configs, and the EasyAuth database, so player
|
||||||
|
registrations survive. Panel state (approval history, metrics) lives in
|
||||||
|
`data/dashboard/` — include it in your off-site sync if you want it back too.
|
||||||
@@ -54,7 +54,3 @@ setup and add a `caddy` service; the panel works fine behind a reverse proxy
|
|||||||
Performance & Lag page. Server-side lag shows as low TPS / high MSPT (affects everyone
|
Performance & Lag page. Server-side lag shows as low TPS / high MSPT (affects everyone
|
||||||
equally); player-side lag shows as high ping for that player only. The page gives a
|
equally); player-side lag shows as high ping for that player only. The page gives a
|
||||||
plain-English verdict and per-player ping history so you can settle the argument.
|
plain-English verdict and per-player ping history so you can settle the argument.
|
||||||
|
|
||||||
## Orion integration
|
|
||||||
|
|
||||||
Orion uses the dedicated managed Compose file in homelab-infra, with TCPShield and OPNsense. Follow [orion.md](orion.md) and [tcpshield.md](tcpshield.md); standalone direct-port examples above do not describe that ingress path.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Orion managed deployment
|
|
||||||
|
|
||||||
The authoritative Compose file, release lock, initial preparation script and OPNsense instructions are in `geneticglitch1/homelab-infra`, under `stacks/minecraft/`. Deploy that stack through Komodo on Orion. Do not deploy this repository's root Compose file on top of it.
|
|
||||||
|
|
||||||
The release selects Minecraft 26.3, Java 25, Fabric loader 0.19.5, launcher 1.1.2 and exact Modrinth version IDs. Velocity 4.2.0 build 30 consumes TCPShield PROXY protocol; FabricProxy-Lite handles authenticated modern forwarding to the private backend. Offline mode, EasyAuth and the whitelist remain the existing authentication model. Check player UUIDs against a copied world before a migration.
|
|
||||||
|
|
||||||
CraftDeck uses `PANEL_MANAGED=true`. Config, mod and file writes, Compose apply and world reset are rejected by the backend. The UI identifies managed mode. Console, player administration, metrics, power controls and backups remain available. These admin controls still have broad authority; managed mode prevents accidental configuration drift and is not a security boundary around the Docker socket.
|
|
||||||
|
|
||||||
Persistent state lives in `/opt/minecraft/data/{mc,backups,dashboard,restore}`. CraftDeck mounts their common parent at `/server-data`; this is necessary for Linux rename operations during restore. The deployment lives in `/opt/minecraft/project`, mounted read-only at the same path. Never put a world under a disposable Komodo source checkout.
|
|
||||||
|
|
||||||
Build the reviewed source commit on Orion using the infrastructure preparation script. It records Docker's local immutable image ID. Komodo uses that ID with `pull_policy: never`; a restart cannot pull a newer panel. Preparation refuses existing containers or an unknown world. It does not accept the Minecraft EULA or start services.
|
|
||||||
|
|
||||||
The panel listens only at `192.168.1.88:8080`, reachable through LAN/VPN and the host firewall. Never forward this port on OPNsense or publish it with NPM. The Docker socket grants host control; keep the panel credentials private.
|
|
||||||
|
|
||||||
## Restore and upgrade behavior
|
|
||||||
|
|
||||||
A required safety backup failure aborts the operation. A backup request is successful only after a newly created archive passes a complete gzip/tar validation. Power, backup, restore and Compose operations share one reentrant queue within the panel process.
|
|
||||||
|
|
||||||
Restores stop both Minecraft and the backup sidecar, validate paths and the managed deployment lock, unpack into a clean staging directory, then move the previous data aside without replacing the bind-mount root. Newer-only files are removed from the active world and retained with the previous data. Failure leaves writers stopped; inspect the recorded restore directory before recovery. Restored files retain the current server directory's ownership.
|
|
||||||
|
|
||||||
The panel accepts only backups with the current release lock. For a different release, use a complete stopped backup and the matching deployment/image, following the infrastructure runbook. A world archive does not include the panel database, proxy state or host secrets. Use `cold-backup.sh` for the complete deployment and copy it off Orion.
|
|
||||||
|
|
||||||
The root standalone Compose file uses the same restore mount layout. Existing standalone installs must recreate the dashboard with the new mounts and set `PROJECT_DIR` to the absolute host checkout path before using Apply or Restore. `scripts/setup.sh` fills that path for new installs. The manual restore fallback is documented in `scripts/restore.sh`; it stops the panel too, avoiding concurrency with web requests.
|
|
||||||
@@ -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.
|
||||||
+77
-8
@@ -1,15 +1,84 @@
|
|||||||
# TCPShield on Orion
|
# TCPShield (DDoS protection + real player IPs)
|
||||||
|
|
||||||
Use the maintained runbook in `homelab-infra/stacks/minecraft/TCPSHIELD.md` for the exact OPNsense and host firewall steps.
|
[TCPShield](https://tcpshield.com) sits in front of the server: players connect to
|
||||||
|
your TCPShield domain, and your real server IP stays hidden and DDoS-protected.
|
||||||
|
The catch: without extra setup, every connection appears to come from TCPShield's
|
||||||
|
proxies, so the Security page, IP bans, and connection logs are useless.
|
||||||
|
|
||||||
Traffic flows: player → TCPShield → OPNsense source-restricted TCP 25565 DNAT → Orion Velocity → private Fabric backend. Enable PROXY protocol in TCPShield and `haproxy-protocol = true` in Velocity. Velocity then uses modern forwarding, with the same secret as `FABRIC_PROXY_SECRET` in FabricProxy-Lite.
|
TCPShield's own RealIP plugin is Bukkit/Bungee/Velocity-only — it does not exist
|
||||||
|
for Fabric. The working path for this stack is **PROXY protocol v2** plus the
|
||||||
|
**Proxy Protocol Support** Fabric mod.
|
||||||
|
|
||||||
The reviewed deployment pins Minecraft 26.3 and Velocity 4.2.0 build 30. The direct Fabric `proxy-protocol-support` mod did not advertise 26.3 support at review time, so it is not used. The old `config/patches` files are legacy and are no longer mounted by the standalone Compose file. Do not combine that legacy direct-server guide with the Velocity deployment.
|
## Setup
|
||||||
|
|
||||||
OPNsense must preserve the TCPShield source address and permit only the official IPv4 ranges from https://tcpshield.com/v4/. Host DOCKER-USER rules enforce the same restriction before trusted-LAN exceptions. Do not add game port 25565 to unrestricted public ports. Do not expose the backend, RCON, panel or a map port.
|
1. **TCPShield panel** → your backend → enable **Proxy Protocol**.
|
||||||
|
2. **Mods page** → Recommended → add **Proxy Protocol Support** → Apply
|
||||||
|
(or append `proxy-protocol-support` to `MODRINTH_PROJECTS` in `.env` and
|
||||||
|
`docker compose up -d mc`).
|
||||||
|
3. Nothing else — the mod's configuration is **enforced automatically on every
|
||||||
|
boot** by `config/patches/patch-set.json` (see below). Restart once after
|
||||||
|
installing so the patch applies:
|
||||||
|
|
||||||
The public Minecraft hostname is a DNS-only CNAME to the hostname assigned by TCPShield. An ordinary Cloudflare orange-cloud HTTP proxy is not a Minecraft proxy. Use the public protected hostname even from home; direct LAN joins intentionally fail on the protected proxy port.
|
```bash
|
||||||
|
docker compose restart mc
|
||||||
|
```
|
||||||
|
|
||||||
Verify an external join, real player IP, EasyAuth registration, whitelist/UUID continuity and direct-origin rejection before inviting players. Configure TCPShield for the exact protected hostname and backend; account/plan controls and any account-specific verification steps must be completed in its dashboard.
|
## Verifying it works
|
||||||
|
|
||||||
Sources: [TCPShield documentation](https://docs.tcpshield.com/), [Velocity forwarding](https://docs.papermc.io/velocity/player-information-forwarding/), [OPNsense NAT](https://docs.opnsense.org/manual/nat.html).
|
Watch the startup log (`docker compose logs -f mc`). You must see all three:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxy Protocol Support is enabled!
|
||||||
|
TCPShield integration enabled! Fetching official IPs...
|
||||||
|
Successfully added 2 TCPShield IPs to the trusted proxy list.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then join through your TCPShield domain — the console shows your **real IP**, and
|
||||||
|
`docker compose ps` shows `mc` **healthy**.
|
||||||
|
|
||||||
|
## How the config is managed (don't hand-edit!)
|
||||||
|
|
||||||
|
The mod rewrites `config/proxy_protocol_support.json` at every startup, which
|
||||||
|
silently clobbers hand edits. Instead, the server image applies
|
||||||
|
`config/patches/patch-set.json` from this repo **before every server start**,
|
||||||
|
enforcing:
|
||||||
|
|
||||||
|
| Setting | Value | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `enableProxyProtocol` | `true` | decode PROXY protocol headers |
|
||||||
|
| `whitelistTCPShieldServers` | `true` | auto-fetch TCPShield's proxy IP ranges — no hardcoding, survives their infra changes |
|
||||||
|
| `proxyServerIPs` | `127.0.0.1` | (TCPShield's ranges are added automatically at runtime) |
|
||||||
|
| `directAccessIPs` | localhost (`127.0.0.1`, `::1`) + private ranges (`10/8`, `172.16/12`, `192.168/16`) | container healthcheck + LAN/panel access |
|
||||||
|
|
||||||
|
To change a value, edit the patch file in the repo and `docker compose up -d mc`.
|
||||||
|
|
||||||
|
**First-boot note:** on a brand-new install the mod's config file doesn't exist
|
||||||
|
yet when patches run, so the very first boot uses mod defaults; every boot after
|
||||||
|
that is patched. One `docker compose restart mc` after first install settles it.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
PROXY protocol headers carry no signature, so anyone who finds your raw server IP
|
||||||
|
could spoof client IPs — the mod prevents this with default-deny:
|
||||||
|
|
||||||
|
- PROXY headers are only accepted from **TCPShield's published ranges** (auto-fetched)
|
||||||
|
- Direct, header-less connections are only accepted from **localhost + LAN**
|
||||||
|
- Everything else → `REJECTED unauthorized direct connection` in the log
|
||||||
|
|
||||||
|
So occasional `REJECTED` lines from random public IPs are the wall working —
|
||||||
|
that's scanner bots hitting your raw IP and bouncing. A `REJECTED ... /0:0:0:0:0:0:0:1`
|
||||||
|
(IPv6 localhost) every few seconds means the container healthcheck is being
|
||||||
|
blocked — that's exactly what the `::1` entry in the patch prevents; if you see
|
||||||
|
it, the patch didn't apply (check `docker compose logs mc | head -50`).
|
||||||
|
|
||||||
|
For belt-and-braces, also enable TCPShield's Advanced Firewall (panel → Network →
|
||||||
|
Firewall), and consider host-firewalling port 25565 to TCPShield's ranges
|
||||||
|
(`curl tcpshield.com/v4`) once everything works.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Friends must connect via the TCPShield domain** — direct-IP connections from
|
||||||
|
outside your LAN are rejected by design once this is active.
|
||||||
|
- If real players start bouncing with `REJECTED`, TCPShield may have changed
|
||||||
|
their IP ranges *and* the auto-fetch failed at boot — restart the server so it
|
||||||
|
re-fetches.
|
||||||
+34
-6
@@ -1,11 +1,39 @@
|
|||||||
# Reviewed updates
|
# Updating
|
||||||
|
|
||||||
For Orion, edit `homelab-infra/stacks/minecraft/release-lock.json` and Compose together. Verify the Minecraft, Java, Fabric, mod and Velocity compatibility matrix against upstream releases. Build and test the exact CraftDeck commit; record its immutable local image ID. No production setting should use `latest` without an image digest or an unversioned mod slug.
|
## Upgrading Minecraft (e.g. 26.2 → 26.3)
|
||||||
|
|
||||||
Before an update, make a complete stopped backup with the infrastructure `cold-backup.sh`, copy it off-host, and verify a restore in an isolated instance. Start the candidate on a disposable world and a copy of existing state first. Confirm authentication UUIDs, whitelist, registration, proxy IP forwarding and resource headroom.
|
1. **Configuration** page → change `Minecraft version` → **Save**
|
||||||
|
2. **Apply** with *"Take a safety backup first"* enabled
|
||||||
|
3. The server container is recreated: the new server jar, a matching Fabric loader, and
|
||||||
|
fresh builds of every managed mod are downloaded automatically
|
||||||
|
|
||||||
After approval, stop through Komodo, materialize the reviewed files, preserve the previous project/lock and image, and update the world's `.deployment-lock.json` to the candidate release. Do not use the initial preparation script to overwrite an existing installation. Deploy manually and run the acceptance checks in the infrastructure README.
|
If a managed mod has no build for the new version yet, the server won't start — check
|
||||||
|
the Console page for which one, remove it from the Mods page (or wait a few days), and
|
||||||
|
Apply again. Worlds upgrade forward automatically; **there is no downgrade** — that's
|
||||||
|
what the safety backup is for.
|
||||||
|
|
||||||
Rollback means restoring the matching complete data backup, panel database, secrets, proxy configuration and previous image/lock. Downgrading a version setting against a newer world is not a rollback. Managed panel restores deliberately refuse mismatched release locks.
|
Command-line equivalent:
|
||||||
|
|
||||||
For the standalone setup, `PROJECT_DIR` must equal the absolute host checkout. Update the explicit image, Minecraft, loader and Modrinth pins together. Config/mod Apply takes a required backup and aborts on failure. Newly added unversioned mod slugs remain a standalone convenience; pin their reviewed Modrinth version IDs before treating that installation as a locked release.
|
```bash
|
||||||
|
sed -i 's/^MC_VERSION=.*/MC_VERSION=26.3/' .env
|
||||||
|
docker exec mc-backup backup now
|
||||||
|
docker compose up -d mc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating mods only
|
||||||
|
|
||||||
|
Mods page → **Update all & restart**. (Any server restart re-resolves managed mods.)
|
||||||
|
|
||||||
|
## Updating the panel / images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose pull # new itzg server + backup images
|
||||||
|
docker compose build dashboard
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Snapshots / release candidates
|
||||||
|
|
||||||
|
`MC_VERSION` accepts any version the launcher knows (`26.3-rc-1`, `26.3-snapshot-3`…).
|
||||||
|
Mods rarely support snapshots — expect to trim the mod list if you try them.
|
||||||
+23
-27
@@ -1,29 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Clean restore fallback. Python 3.12+ required. Retains the prior world.
|
# Manual backup restore (fallback for when the dashboard is unavailable).
|
||||||
# Standalone: scripts/restore.sh /absolute/backup.tgz
|
# The dashboard's Backups page does this same procedure with one click.
|
||||||
# Orion: scripts/restore.sh /absolute/backup.tgz /opt/minecraft/project /opt/minecraft/data
|
#
|
||||||
|
# Usage: ./scripts/restore.sh data/backups/world-20260710-120000.tgz
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
repo=$(cd "$(dirname "$0")/.." && pwd)
|
cd "$(dirname "$0")/.."
|
||||||
archive=${1:?Supply an existing archive}
|
|
||||||
archive=$(realpath "$archive")
|
archive="${1:-}"
|
||||||
project=${2:-$repo}
|
[[ -n "$archive" && -f "$archive" ]] || {
|
||||||
data=${3:-$repo/data}
|
echo "Usage: $0 <backup .tgz file>"
|
||||||
[[ -f "$archive" ]] || exit 1
|
echo "Available backups:"
|
||||||
helper="$repo/dashboard/scripts/restore_data.py"
|
ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)"
|
||||||
python3 "$helper" verify --archive "$archive"
|
exit 1
|
||||||
echo "Restore $archive into $data/mc; retain current data in $data/restore."
|
}
|
||||||
read -r -p "Type 'restore' to stop the stack and continue: " confirm
|
|
||||||
[[ "$confirm" == restore ]] || exit 1
|
echo "This will STOP the server and overwrite the current world with:"
|
||||||
cd "$project"
|
echo " $archive"
|
||||||
export PROJECT_DIR="$project"
|
read -r -p "Type 'restore' to continue: " confirm
|
||||||
# Stop the panel as well: its in-process queue cannot coordinate with this script.
|
[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; }
|
||||||
running=$(docker compose ps --status running --services)
|
|
||||||
docker compose stop dashboard backup mc
|
docker compose stop mc
|
||||||
args=(restore --archive "$archive" --data "$data/mc" --work "$data/restore")
|
tar -xzf "$archive" -C data/mc
|
||||||
[[ ! -f "$project/release-lock.json" ]] || args+=(--lock-file "$project/release-lock.json")
|
docker compose start mc
|
||||||
# On failure, leave writers stopped for inspection.
|
echo "✔ Restore complete — server starting."
|
||||||
python3 "$helper" "${args[@]}"
|
|
||||||
for service in mc backup dashboard; do
|
|
||||||
if [[ $'\n'"$running"$'\n' == *$'\n'"$service"$'\n'* ]]; then docker compose start "$service"; fi
|
|
||||||
done
|
|
||||||
echo 'Restore complete. Previous data was retained in the restore workspace.'
|
|
||||||
+1
-3
@@ -21,9 +21,7 @@ sed -e "s|^RCON_PASSWORD=.*|RCON_PASSWORD=${rcon_pw}|" \
|
|||||||
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
|
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
|
||||||
.env.example > .env
|
.env.example > .env
|
||||||
|
|
||||||
printf '\nPROJECT_DIR=%s\n' "$PWD" >> .env
|
mkdir -p data/mc data/backups data/dashboard
|
||||||
chmod 600 .env
|
|
||||||
mkdir -p data/mc data/backups data/dashboard data/restore
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo " ✔ .env created with generated secrets"
|
echo " ✔ .env created with generated secrets"
|
||||||
|
|||||||
Reference in new issue
Block a user