78 changed files with 6162 additions and 1424 deletions

No files matched your search

+6 -5
View File
@@ -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.2 MC_VERSION=26.3
# 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,10 +25,8 @@ MC_SIMULATION_DISTANCE=8
MC_SEED= MC_SEED=
# Server-side mods (Modrinth project slugs, comma-separated). # Server-side mods (Modrinth project slugs, comma-separated).
# Auto-resolved to the newest compatible release on every container start. # Exact version IDs are locked. Review all IDs before upgrading Minecraft.
# Append ":beta" to allow beta-typed builds for a project (EasyAuth ships MODRINTH_PROJECTS=fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB
# 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=
@@ -40,6 +38,9 @@ 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
+4
View File
@@ -12,3 +12,7 @@ dashboard/next-env.d.ts
# OS noise # OS noise
.DS_Store .DS_Store
# Local validation artifacts
__pycache__/
*.tsbuildinfo
+12 -4
View File
@@ -1,6 +1,8 @@
# Minecraft 26.2 Fabric Server + CraftDeck Panel # Minecraft 26.3 Fabric Server + CraftDeck Panel
A production-quality, self-hosted Minecraft **Java 26.2** server for friends: 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.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
@@ -9,11 +11,14 @@ A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
- **CraftDeck**, a full web management panel: live console, players & stats, lag - **CraftDeck**, a full web management panel: live console, players & stats, lag
diagnostics, performance graphs, mod manager with Modrinth search, backups & restore, diagnostics, performance graphs, mod manager with Modrinth search, backups & restore,
file browser/editor, config editor, scheduled tasks, Discord alerts file browser/editor, config editor, scheduled tasks, Discord alerts
- **Worlds & Network**: isolated concurrent server profiles, Velocity lobby routing,
Paper/Folia and modded runtimes, live map integrations, resource pack settings,
per-server consoles/files and verified backup/restore — [guide](docs/worlds-and-network.md)
- **Automatic backups** with retention, **one-command deployment**, everything in Docker - **Automatic backups** with retention, **one-command deployment**, everything in Docker
| | | | | |
|---|---| |---|---|
| Minecraft | `26.2` (change one line to upgrade) | | Minecraft | `26.3` (review loader and all mod pins together) |
| 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 |
@@ -65,6 +70,9 @@ they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
## Documentation ## Documentation
- [docs/worlds-and-network.md](docs/worlds-and-network.md) — multiple servers, lobby routing, maps and resource packs
- [docs/apple-container.md](docs/apple-container.md) — native Mac tests and x86_64 image builds with Apple’s container CLI
- [docs/auth-flow.md](docs/auth-flow.md) — how the approval/registration system works - [docs/auth-flow.md](docs/auth-flow.md) — how the approval/registration system works
- [docs/mods-and-modpacks.md](docs/mods-and-modpacks.md) — adding mods, switching to modpacks or other loaders - [docs/mods-and-modpacks.md](docs/mods-and-modpacks.md) — adding mods, switching to modpacks or other loaders
- [docs/updating.md](docs/updating.md) — upgrading Minecraft, mods, and the panel - [docs/updating.md](docs/updating.md) — upgrading Minecraft, mods, and the panel
@@ -76,7 +84,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.2, offline mode, whitelist enforced, ├── mc itzg/minecraft-server (Fabric 26.3, 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
+1 -2
View File
@@ -2,6 +2,5 @@ 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 dev-data
*.tsbuildinfo
+6 -8
View File
@@ -1,25 +1,25 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:26-slim AS deps FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 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 AS build FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 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 AS runtime FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
ARG TARGETARCH=amd64 ARG TARGETARCH
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 \ && apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip python3 \
&& 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,9 +46,7 @@ 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
# Feed exporter for the public read-only mirror (uses only Node built-ins; COPY scripts/restore_data.py /app/restore_data.py
# 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 \
+1296 -202
View File
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -6,7 +6,9 @@
"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",
@@ -14,18 +16,20 @@
"@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.2.10", "next": "16.3.7",
"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",
"yaml": "2.9.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.2", "@tailwindcss/postcss": "4.3.2",
"@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.16", "postcss": "8.5.28",
"tailwindcss": "4.3.2", "tailwindcss": "4.3.2",
"typescript": "5.9.3" "typescript": "5.9.3",
"vitest": "5.0.2"
} }
} }
-169
View File
@@ -1,169 +0,0 @@
#!/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}`
);
+116
View File
@@ -0,0 +1,116 @@
#!/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)))
+5 -2
View File
@@ -39,6 +39,8 @@ 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);
@@ -103,10 +105,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={!dirty}> <Button onClick={save} busy={saving} disabled={managed || !dirty}>
Save Save
</Button> </Button>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}> <Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
<Rocket size={14} /> Apply (restarts server) <Rocket size={14} /> Apply (restarts server)
</Button> </Button>
</div> </div>
@@ -128,6 +130,7 @@ 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"
+262
View File
@@ -0,0 +1,262 @@
import Link from "next/link";
import {
ArrowUpRight,
Boxes,
Cpu,
Globe2,
Shield,
Sparkles,
Wrench,
} from "lucide-react";
import { Badge, Card } from "@/components/ui";
const groups = [
{
title: "Worlds & networks",
icon: Globe2,
items: [
[
"Independent world servers",
"Built in",
"Concurrent profiles with separate seeds, versions, Java runtimes, mods, configs, player data, and backups.",
],
[
"Lobby & server switching",
"Built in",
"Velocity gateway, default lobby, fallback routes, and hostname routing for Paper/Folia. Use /server to switch compatible servers.",
],
[
"Several worlds inside one server",
"Addon",
"Multiverse-style plugins or modded dimensions. These share one runtime, so use separate profiles for incompatible modpacks.",
],
[
"Cross-server inventories & chat",
"Addon",
"A compatible sync plugin and shared database can connect inventories, economies, permissions, and chat. Decide what each game mode should share.",
],
],
},
{
title: "Performance & scale",
icon: Cpu,
items: [
[
"Region-based ticking",
"Runtime option",
"Folia runs independent regions in parallel. It needs suitable hardware and Folia-aware plugins; crowded regions can still lag.",
],
[
"Fabric / Forge / NeoForge / Quilt",
"Built in",
"Dedicated runtime profiles preserve mod compatibility. Match the Java release and client modpack to the selected server version.",
],
[
"Profiling & tuning",
"Primary panel",
"Performance charts and spark diagnostics remain in the primary server panel. Tune view/simulation distances per network profile.",
],
[
"Chunk pregeneration",
"Addon",
"Pregenerate a bounded play area with a compatible tool to reduce exploration spikes. Budget disk space and schedule it outside peak hours.",
],
],
},
{
title: "Maps & client experience",
icon: Sparkles,
items: [
[
"BlueMap / Dynmap",
"Integration",
"Compatible release resolution, isolated renderer ports, and an embedded viewer for your HTTPS map URL. Rendering runs on the server.",
],
[
"Resource pack downloads",
"Built in",
"Per-server ZIP URL, SHA-1, download prompt, and required/optional mode. Textures, models, sounds, and fonts; client mods are separate.",
],
[
"Claims, towns & economies",
"Addon",
"Install compatible protection, town, economy, shop, and map-marker plugins. Plugin compatibility depends on the runtime and version.",
],
[
"Bedrock crossplay",
"Addon",
"Geyser and optionally Floodgate can bridge supported clients. Configure identity handling and a separate UDP port; this panel does not deploy it automatically.",
],
],
},
{
title: "Safety & operations",
icon: Shield,
items: [
[
"Backups & recovery",
"Built in",
"Per-server backup sidecars, retention policies, verified cold snapshots, and staged restores that retain previous data.",
],
[
"Access & isolation",
"Built in",
"New profiles require verified accounts and a whitelist. Proxy backends are private and use a forwarding secret. Primary offline authentication remains separate.",
],
[
"Console & file management",
"Built in",
"Profile-specific commands, log tails, stopped-server config editing, mod/plugin uploads, and datapack uploads.",
],
[
"Remote backups & disaster recovery",
"External setup",
"Replicate archives to another host or object store and test restores. Local backups do not survive loss of the host disk.",
],
],
},
{
title: "Gameplay extensions",
icon: Boxes,
items: [
[
"Custom terrain & dimensions",
"Addon",
"World-generation datapacks and mods, structure packs, biomes, and dimensions. Install generation content before exploring new terrain.",
],
[
"Minigames & events",
"Addon",
"Create separate arena profiles for PvP, parkour, creative, adventure, or seasonal worlds and route through the gateway.",
],
[
"Voice chat & Discord",
"Addon",
"Compatible voice mods need an extra UDP port. The primary panel already supports Discord operational alerts; in-game bridges need plugins.",
],
[
"Permissions, moderation & rollback",
"Addon",
"Use compatible permissions, audit, anti-cheat, and rollback tools. Folia requires explicit support; a Paper plugin label is insufficient.",
],
],
},
{
title: "Advanced infrastructure",
icon: Wrench,
items: [
[
"Custom domains & ingress",
"External setup",
"DNS/SRV records, HTTPS map reverse proxies, and Minecraft-aware TCP protection can provide a consistent entry point.",
],
[
"Version translation",
"Addon",
"ViaVersion-family plugins translate supported protocol differences. They cannot make incompatible client modpacks interchangeable.",
],
[
"Multiple physical hosts",
"External setup",
"A private network and per-host orchestration can spread servers across machines. This implementation manages one Docker host.",
],
[
"Maintenance & automation",
"Primary panel",
"The existing scheduler, metrics, notifications, and access flows target the primary server. Network profiles have independent automatic backups.",
],
],
},
];
export default function FeaturesPage() {
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
CraftDeck / Possibilities
</div>
<h1 className="text-2xl font-bold">Build the server you want.</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-ink2">
A practical capability guide, from a small survival world to a network
of game modes. The labels show what this panel manages and what needs
additional software or infrastructure.
</p>
<Link
href="/network"
className="mt-4 inline-flex items-center gap-2 text-sm text-accent"
>
Open your network
<ArrowUpRight size={15} />
</Link>
</div>
<div className="grid gap-5 lg:grid-cols-2">
{groups.map((g) => (
<Card
key={g.title}
title={
<span className="flex items-center gap-2">
<g.icon size={16} className="text-accent" />
{g.title}
</span>
}
>
<div className="space-y-5">
{g.items.map(([title, status, body]) => (
<div key={title}>
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium">{title}</h3>
<Badge
color={
status === "Built in"
? "good"
: status === "Addon"
? "muted"
: "info"
}
>
{status}
</Badge>
</div>
<p className="text-xs leading-relaxed text-ink2">{body}</p>
</div>
))}
</div>
</Card>
))}
</div>
<p className="text-xs text-muted">
Compatibility changes by Minecraft release. Verify the exact mod/plugin
versions and test changes on a fresh profile before moving players.
</p>
<div className="flex flex-wrap gap-4 text-xs text-accent">
<a
href="https://docs.papermc.io/folia/"
target="_blank"
rel="noreferrer"
>
Folia documentation ↗
</a>
<a
href="https://docs.papermc.io/velocity/server-compatibility/"
target="_blank"
rel="noreferrer"
>
Velocity compatibility ↗
</a>
<a
href="https://bluemap.bluecolored.de/wiki/"
target="_blank"
rel="noreferrer"
>
BlueMap documentation ↗
</a>
<a
href="https://docker-minecraft-server.readthedocs.io/"
target="_blank"
rel="noreferrer"
>
Runtime documentation ↗
</a>
</div>
</div>
);
}
+9 -4
View File
@@ -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 } from "@/lib/api"; import { api, toast, useApi } 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,6 +32,8 @@ 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);
@@ -124,10 +126,10 @@ export default function FilesPage() {
e.target.value = ""; e.target.value = "";
}} }}
/> />
<Button size="sm" onClick={() => uploadRef.current?.click()}> <Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
<Upload size={13} /> Upload here <Upload size={13} /> Upload here
</Button> </Button>
<Button size="sm" onClick={() => setMkdirOpen(true)}> <Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlus size={13} /> New folder <FolderPlus size={13} /> New folder
</Button> </Button>
</div> </div>
@@ -187,6 +189,7 @@ 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"}
@@ -213,7 +216,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={!editing.dirty || editing.truncated}> <Button size="sm" variant="primary" onClick={save} busy={saving} disabled={managed || !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)}>
@@ -223,6 +226,8 @@ 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)}
+2 -6
View File
@@ -1,10 +1,6 @@
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 }) {
const publicMode = process.env.PUBLIC_READONLY === "1"; return <Shell><ManagedNotice />{children}</Shell>;
return <Shell publicMode={publicMode}>{children}</Shell>;
} }
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Map, ExternalLink, RefreshCw, Layers } from "lucide-react";
import { useApi } from "@/lib/api";
import { Badge, Button, Card, EmptyState, Select } from "@/components/ui";
import type { NetworkView } from "@/lib/network";
export default function MapsPage() {
const { data, error, loading } = useApi<NetworkView>("/api/network", 15000);
const [id, setId] = useState("");
const [reload, setReload] = useState(0);
const maps = data?.profiles.filter((p) => p.map !== "none") ?? [];
const selected = maps.find((p) => p.id === id) ?? maps[0];
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
CraftDeck / Atlas
</div>
<h1 className="text-2xl font-bold">Live world maps</h1>
<p className="mt-2 text-sm text-ink2">
Explore terrain, follow players, and view the overlays supplied by
your map renderer.
</p>
</div>
<Link href="/network" className="text-sm text-accent">
Configure maps →
</Link>
</div>
{error && (
<p role="alert" className="text-sm text-warn">
{error}
</p>
)}
{loading && <p className="text-sm text-muted">Loading maps…</p>}
{!!maps.length && (
<div className="flex flex-wrap items-center gap-3">
<Layers size={16} className="text-accent" />
<Select
value={selected?.id ?? ""}
onChange={setId}
options={maps.map((p) => ({ value: p.id, label: p.name }))}
/>
<Badge color="info">
{selected?.map === "bluemap"
? "BlueMap · 3D"
: "Dynmap · 2D / isometric"}
</Badge>
<span className="text-xs text-muted">
{selected?.version} · {selected?.loader}
</span>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={() => setReload((x) => x + 1)}>
<RefreshCw size={13} />
Reload
</Button>
{selected?.mapUrl && (
<a
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1 text-xs"
href={selected.mapUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink size={13} />
Open full map
</a>
)}
</div>
</div>
)}
{selected?.mapUrl ? (
<>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<iframe
key={`${selected.id}-${reload}`}
title={`${selected.name} live map`}
src={selected.mapUrl}
className="h-[70vh] min-h-96 w-full"
sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-popups"
referrerPolicy="no-referrer"
allowFullScreen
/>
</div>
<p className="text-xs text-muted">
The map is served by {selected.map}. If the frame is blank, open the
full map and check HTTPS, renderer startup, reverse proxy settings,
and framing policy. A loaded frame alone does not verify renderer
health.
</p>
</>
) : (
<Card>
<EmptyState
icon={<Map size={40} />}
title={
selected
? "Connect this map to its web address"
: "Your worlds, from a different perspective"
}
hint={
selected
? `Publish loopback port ${selected.mapPort} through an HTTPS reverse proxy, then save the browser URL in this server’s Live web map settings.`
: "Create a world server with BlueMap or Dynmap, deploy a compatible renderer, and add its browser URL."
}
/>
</Card>
)}
<div className="grid gap-4 md:grid-cols-3">
{[
{
title: "Terrain & dimensions",
body: "The renderer builds real tiles or 3D geometry from your server’s worlds. Initial rendering takes time and uses CPU, disk, and memory.",
},
{
title: "Live player locations",
body: "Enable player visibility in the renderer and configure permissions. Players, caves, and hidden areas follow its privacy settings.",
},
{
title: "Claims & markers",
body: "Claim boundaries need a compatible integration for your claims plugin. Configure the addon on the server; its overlays appear here automatically.",
},
].map((x) => (
<Card key={x.title} title={x.title}>
<p className="text-xs leading-relaxed text-ink2">{x.body}</p>
</Card>
))}
</div>
</div>
);
}
+14 -10
View File
@@ -60,15 +60,17 @@ 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: "proxy-protocol-support", slug: "fabricproxy-lite",
emoji: "🛡", emoji: "🛡",
title: "Proxy Protocol Support", title: "FabricProxy-Lite",
description: description:
"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.", "Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
}, },
]; ];
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);
@@ -127,10 +129,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 mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply {managed ? "Mod versions are locked by the reviewed deployment." : "Pinned Modrinth version IDs remain fixed when the server restarts."}
</p> </p>
</div> </div>
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}> <Button disabled={managed} 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>
@@ -150,7 +152,7 @@ export default function ModsPage() {
<Button <Button
size="sm" size="sm"
variant={already ? "ghost" : "primary"} variant={already ? "ghost" : "primary"}
disabled={already} disabled={managed || 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`)}
> >
@@ -170,7 +172,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.2"}…`} placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.3"}…`}
className="w-full" className="w-full"
/> />
<Button onClick={() => void search()} busy={searching}> <Button onClick={() => void search()} busy={searching}>
@@ -208,7 +210,7 @@ export default function ModsPage() {
<Button <Button
size="sm" size="sm"
variant={already ? "ghost" : "primary"} variant={already ? "ghost" : "primary"}
disabled={already} disabled={managed || 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`)}
> >
@@ -223,8 +225,7 @@ 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">
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
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) => (
@@ -240,6 +241,7 @@ 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`)}
> >
@@ -285,6 +287,7 @@ 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(
@@ -300,6 +303,7 @@ 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`)}
> >
File diff suppressed because it is too large. Load diff
+2 -8
View File
@@ -8,7 +8,6 @@ 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;
@@ -65,15 +64,12 @@ 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);
@@ -117,7 +113,6 @@ export default function OverviewPage() {
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>} {running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
</p> </p>
</div> </div>
{!publicMode && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="primary" variant="primary"
@@ -146,7 +141,6 @@ export default function OverviewPage() {
<Square size={13} /> Stop <Square size={13} /> Stop
</Button> </Button>
</div> </div>
)}
</div> </div>
{!server?.dockerAvailable && server && ( {!server?.dockerAvailable && server && (
@@ -236,7 +230,7 @@ export default function OverviewPage() {
)} )}
{/* admin quick controls */} {/* admin quick controls */}
{!publicMode && <QuickControls running={running} />} <QuickControls running={running} />
{/* world + disk */} {/* world + disk */}
<Card title="World & storage"> <Card title="World & storage">
@@ -6,7 +6,6 @@ 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;
@@ -67,7 +66,6 @@ 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);
@@ -118,11 +116,9 @@ export default function PerformancePage() {
<Card <Card
title="Lag diagnosis" title="Lag diagnosis"
actions={ actions={
publicMode ? undefined : (
<Button size="sm" onClick={runHealthReport} busy={busyReport}> <Button size="sm" onClick={runHealthReport} busy={busyReport}>
<Stethoscope size={13} /> Full health report <Stethoscope size={13} /> Full health report
</Button> </Button>
)
} }
> >
<div className="flex flex-wrap items-start gap-4"> <div className="flex flex-wrap items-start gap-4">
+3 -7
View File
@@ -5,7 +5,6 @@ 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;
@@ -28,7 +27,6 @@ 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);
@@ -131,7 +129,7 @@ export default function PlayersPage() {
<tbody> <tbody>
{players.map((p) => ( {players.map((p) => (
<tr <tr
key={p.uuid || p.name} key={p.uuid}
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)}
> >
@@ -151,7 +149,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">
{!publicMode && p.isOnline && ( {p.isOnline && (
<Button <Button
size="sm" size="sm"
variant="danger" variant="danger"
@@ -228,7 +226,6 @@ 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 && (
<> <>
@@ -264,10 +261,9 @@ 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) */}
{!publicMode && detail.isOnline && ( {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
+3 -15
View File
@@ -1,28 +1,16 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import type { DatabaseSync } from "node:sqlite"; import { db } from "@/server/db";
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
? database ? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
.prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?") : db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
.all(type, limit)
: database.prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
return ok(rows); return ok(rows);
}); });
} }
+1 -1
View File
@@ -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");
deleteBackup(name); await deleteBackup(name);
return ok({ deleted: name }); return ok({ deleted: name });
}); });
} }
+6 -3
View File
@@ -1,3 +1,5 @@
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";
@@ -5,9 +7,10 @@ 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(async () => { return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req); const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
if (backupFirst) await safetyBackup("config apply"); if (service !== "backup" || 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));
@@ -15,5 +18,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) });
}); }));
} }
@@ -0,0 +1,5 @@
import { env } from "@/server/env";
import { ok } from "@/server/api";
export const dynamic = "force-dynamic";
export function GET() { return ok({ managed: env.managed }); }
+2 -33
View File
@@ -1,4 +1,5 @@
import { env } from "@/server/env"; import { db } from "@/server/db";
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";
@@ -8,40 +9,8 @@ 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 }>;
+2 -11
View File
@@ -1,6 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import type { DatabaseSync } from "node:sqlite"; import { db } from "@/server/db";
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";
@@ -12,20 +11,12 @@ 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 = database const rows = db()
.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,
+7 -2
View File
@@ -1,10 +1,15 @@
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(async () => { return handle(() => withServerOperation(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));
@@ -12,5 +17,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) });
}); }));
} }
@@ -0,0 +1,88 @@
import fs from "node:fs";
import path from "node:path";
import { NextRequest } from "next/server";
import { ok, fail, handle, readJson } from "@/server/api";
import { assertDeploymentEditable } from "@/server/managed";
import { readNetwork, profileById } from "@/server/network-config";
import { networkFilePath } from "@/server/network-files";
import { assertNetworkIdle } from "@/server/network";
import { inspectContainer } from "@/server/docker";
import { withServerOperation } from "@/server/operation";
import { serverName } from "@/server/network-render";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return handle(async () => {
const p = profileById(readNetwork(), req.nextUrl.searchParams.get("id"));
const relative = req.nextUrl.searchParams.get("path") || "";
const file = networkFilePath(p.id, relative);
if (fs.statSync(file).isDirectory())
return ok({
entries: fs
.readdirSync(file, { withFileTypes: true })
.map((e) => ({
name: e.name,
directory: e.isDirectory(),
link: e.isSymbolicLink(),
})),
});
if (fs.statSync(file).size > 2 * 1024 * 1024)
return fail("File exceeds the 2 MB editor limit");
const data = fs.readFileSync(file);
if (data.includes(0)) return fail("Binary files cannot be edited as text");
return ok({ content: data.toString("utf8") });
});
}
export async function POST(req: NextRequest) {
return handle(() =>
withServerOperation(async () => {
assertDeploymentEditable();
assertNetworkIdle();
const isUpload = req.headers
.get("content-type")
?.includes("multipart/form-data");
if (Number(req.headers.get("content-length") || 0) > 100 * 1024 * 1024)
return fail("Upload limit is 100 MB");
const form = isUpload ? await req.formData() : null;
const body = form
? { id: form.get("id"), path: form.get("path"), content: undefined }
: await readJson<{ id: string; path: string; content: string }>(req);
const p = profileById(readNetwork(), body.id);
if ((await inspectContainer(serverName(p.id))).running)
return fail("Stop this server before editing or uploading files", 409);
const file = networkFilePath(p.id, body.path as string);
// Terrain, player state, executable scripts and registry files are not text-editable.
const allowed = /\.(json|ya?ml|toml|conf|properties|txt|cfg)$/i;
let data: Buffer;
if (form) {
const upload = form.get("file");
if (!(upload instanceof File) || upload.size > 100 * 1024 * 1024)
return fail("Choose a file under 100 MB");
const rel = path.relative(networkFilePath(p.id, ""), file);
if (
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) &&
!/^world\/datapacks\/[^/]+\.zip$/.test(rel)
)
return fail(
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
);
if (fs.existsSync(file))
return fail("A file already exists with this name");
data = Buffer.from(await upload.arrayBuffer());
} else {
if (
!allowed.test(file) ||
typeof body.content !== "string" ||
Buffer.byteLength(body.content) > 2 * 1024 * 1024
)
return fail("Choose a supported configuration file under 2 MB");
data = Buffer.from(body.content);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data, { flag: form ? "wx" : "w" });
try {
fs.chownSync(file, 1000, 1000);
} catch {}
return ok({ saved: true });
}),
);
}
+162
View File
@@ -0,0 +1,162 @@
import { NextRequest } from "next/server";
import { ok, fail, handle, readJson } from "@/server/api";
import { env } from "@/server/env";
import { readEnvFile } from "@/server/envfile";
import { assertDeploymentEditable } from "@/server/managed";
import { inspectContainer, powerContainer } from "@/server/docker";
import {
readNetwork,
writeNetwork,
validateProfile,
validateProxy,
profileById,
NetworkInputError,
} from "@/server/network-config";
import { serverName, proxyName } from "@/server/network-render";
import {
networkBusy,
assertNetworkIdle,
reconcileJobs,
checkPorts,
queueNetworkJob,
deployProfile,
deployProxy,
stopProfile,
restartProfile,
snapshotProfile,
restoreProfile,
profileConsole,
profileLogs,
profileBackups,
} from "@/server/network";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function networkHandle(fn: () => Promise<ReturnType<typeof ok>>) {
return handle(async () => {
try {
return await fn();
} catch (e) {
if (e instanceof NetworkInputError) return fail(e.message, 400);
throw e;
}
});
}
export async function GET(req: NextRequest) {
return networkHandle(async () => {
const config = reconcileJobs(readNetwork());
const id = req.nextUrl.searchParams.get("id");
const kind = req.nextUrl.searchParams.get("kind");
if (id) {
profileById(config, id);
if (kind === "logs") return ok(await profileLogs(id));
if (kind === "backups") return ok(profileBackups(id));
}
const entries = await Promise.all(
[
...config.profiles.map((p) => [p.id, serverName(p.id)]),
["proxy", proxyName],
].map(async ([id, name]) => {
try {
return [id, await inspectContainer(name)] as const;
} catch (e) {
return [
id,
{
exists: false,
running: false,
status: "unavailable",
error: (e as Error).message,
},
] as const;
}
}),
);
return ok({
...config,
managed: env.managed,
runtime: env.containerRuntime,
states: Object.fromEntries(entries),
primaryPort: Number(readEnvFile().MC_PORT || 25565),
busy: networkBusy(),
});
});
}
export async function POST(req: NextRequest) {
return networkHandle(async () => {
assertDeploymentEditable();
assertNetworkIdle();
const body = await readJson<Record<string, unknown>>(req);
if (!body || typeof body !== "object") return fail("Invalid request");
const config = readNetwork();
if (body.op === "save") {
const existing = body.id ? profileById(config, body.id) : undefined;
if (!existing && config.profiles.length >= 32)
return fail("Maximum 32 profiles per panel");
const p = validateProfile(body.profile, existing);
config.profiles = [...config.profiles.filter((s) => s.id !== p.id), p];
if (!config.proxy.lobby && p.route === "proxy") config.proxy.lobby = p.id;
checkPorts(config);
writeNetwork(config);
return ok(p);
}
if (body.op === "proxy-save") {
config.proxy = validateProxy(body.proxy, config);
checkPorts(config);
writeNetwork(config);
return ok(config.proxy);
}
if (body.op === "proxy-apply") {
if (body.confirm !== "apply proxy")
return fail(
"Confirm applying the proxy; existing connections will close",
);
return ok(queueNetworkJob("proxy", "apply proxy", deployProxy));
}
if (body.op === "proxy-stop") {
if (body.confirm !== "stop proxy")
return fail("Confirm stopping the gateway");
return ok(
queueNetworkJob("proxy", "stop proxy", async () => {
if ((await inspectContainer(proxyName)).running)
await powerContainer(proxyName, "stop");
return "Gateway stopped. World servers and their data remain available.";
}),
);
}
const p = profileById(config, body.id);
if (body.op === "command")
return ok(await profileConsole(p.id, body.command as string));
if (body.confirm !== p.name)
return fail(`Confirm by entering the server name: ${p.name}`);
switch (body.op) {
case "deploy":
return ok(
queueNetworkJob(p.id, "deploy", (progress) =>
deployProfile(p.id, progress),
),
);
case "stop":
return ok(queueNetworkJob(p.id, "stop", () => stopProfile(p.id)));
case "restart":
return ok(queueNetworkJob(p.id, "restart", () => restartProfile(p.id)));
case "snapshot":
return ok(
queueNetworkJob(p.id, "snapshot", (progress) =>
snapshotProfile(p.id, progress),
),
);
case "restore": {
if (typeof body.backup !== "string") return fail("Choose a backup");
const name = body.backup;
return ok(
queueNetworkJob(p.id, "restore", (progress) =>
restoreProfile(p.id, name, progress),
),
);
}
default:
return fail("Unknown network action");
}
});
}
+3 -38
View File
@@ -1,47 +1,12 @@
import { env } from "@/server/env"; import { listOnlinePlayers } from "@/server/mc";
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 {
+9 -39
View File
@@ -1,4 +1,10 @@
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";
@@ -6,56 +12,20 @@ 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(mcContainer: string, running: boolean): Promise<boolean> { async function bluemapAvailable(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://${mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) }); const res = await fetch(`http://${env.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 {
@@ -96,7 +66,7 @@ export async function GET() {
metrics: latest ?? null, metrics: latest ?? null,
pendingApprovals: pendingCount, pendingApprovals: pendingCount,
map: { map: {
available: await bluemapAvailable(env.mcContainer, container.running), available: await bluemapAvailable(container.running),
port: Number(readEnvFile()["MAP_PORT"]) || 8100, port: Number(readEnvFile()["MAP_PORT"]) || 8100,
}, },
world: readWorldInfo(getWorldSizeBytes()), world: readWorldInfo(getWorldSizeBytes()),
+6 -27
View File
@@ -1,22 +1,14 @@
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. * Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
* * 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({
@@ -30,24 +22,11 @@ export async function GET(req: NextRequest) {
} }
}; };
const onMetrics = (m: unknown) => send("metrics", m); const onMetrics = (m: unknown) => send("metrics", m);
const onEvent = (e: unknown) => { const onEvent = (e: unknown) => send("mc-event", e);
if (isPublic) { const onNotification = (n: unknown) => send("notification", n);
// 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 {
@@ -60,7 +39,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);
if (onNotification) bus().off("notification", onNotification); bus().off("notification", onNotification);
clearInterval(keepalive); clearInterval(keepalive);
try { try {
controller.close(); controller.close();
+8 -2
View File
@@ -1,3 +1,5 @@
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";
@@ -18,7 +20,8 @@ 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(async () => { return handle(() => withServerOperation(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');
@@ -27,6 +30,8 @@ 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");
@@ -34,8 +39,9 @@ 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 });
}); }));
} }
@@ -0,0 +1,11 @@
"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>;
}
+13 -36
View File
@@ -19,15 +19,19 @@ import {
Bell, Bell,
LogOut, LogOut,
CircleDot, CircleDot,
Eye, Network,
Map,
Compass,
} 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 },
{ href: "/network", label: "Worlds & Network", icon: Network },
{ href: "/maps", label: "Live Maps", icon: Map },
{ href: "/features", label: "Capabilities", icon: Compass },
{ href: "/console", label: "Console", icon: TerminalSquare }, { href: "/console", label: "Console", icon: TerminalSquare },
{ href: "/players", label: "Players", icon: Users }, { href: "/players", label: "Players", icon: Users },
{ href: "/access", label: "Access & Auth", icon: ShieldCheck }, { href: "/access", label: "Access & Auth", icon: ShieldCheck },
@@ -41,10 +45,6 @@ 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 };
@@ -60,19 +60,14 @@ type NotificationRow = {
read: number; read: number;
}; };
export function Shell({ children, publicMode = false }: { children: React.ReactNode; publicMode?: boolean }) { export function Shell({ children }: { children: React.ReactNode }) {
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);
// /api/auth/me is blocked (404) in public mode — don't request it. const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
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 };
@@ -109,11 +104,11 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
</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">{publicMode ? "public mirror" : "server panel"}</div> <div className="mt-0.5 text-[10px] text-muted">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 (
@@ -136,15 +131,7 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
})} })}
</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">
{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> Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
</>
)}
</div> </div>
</aside> </aside>
@@ -158,7 +145,7 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
}`} }`}
> >
<CircleDot size={12} /> <CircleDot size={12} />
{isRunning ? "Online" : "Offline"} Primary · {isRunning ? "Online" : "Offline"}
</span> </span>
{isRunning && ( {isRunning && (
<span className="text-xs text-ink2"> <span className="text-xs text-ink2">
@@ -167,12 +154,6 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{publicMode ? (
<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">
<Eye size={12} /> Read-only public mirror
</span>
) : (
<>
<NotificationBell bump={unreadBump} /> <NotificationBell bump={unreadBump} />
<button <button
onClick={logout} onClick={logout}
@@ -180,12 +161,10 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
> >
<LogOut size={13} /> Log out <LogOut size={13} /> Log out
</button> </button>
</>
)}
</div> </div>
</header> </header>
{!publicMode && me?.mustChange && pathname !== "/settings" && ( {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">
@@ -195,9 +174,7 @@ export function Shell({ children, publicMode = false }: { children: React.ReactN
</div> </div>
)} )}
<main className="flex-1 p-6"> <main className="flex-1 p-6">{children}</main>
<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 -1
View File
@@ -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");
await boot(); boot();
} }
} }
+4 -14
View File
@@ -33,20 +33,14 @@ export async function api<T = unknown>(
return payload.data as T; return payload.data as T;
} }
/** /** Poll a GET endpoint; refresh() for manual refetch. */
* Poll a GET endpoint; refresh() for manual refetch. Pass { enabled: false } export function useApi<T>(path: string, intervalMs = 0) {
* 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;
@@ -58,13 +52,9 @@ export function useApi<T>(path: string, intervalMs = 0, opts: { enabled?: boolea
} finally { } finally {
if (alive.current) setLoading(false); if (alive.current) setLoading(false);
} }
}, [path, enabled]); }, [path]);
useEffect(() => { useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
alive.current = true; alive.current = true;
setLoading(true); setLoading(true);
void refresh(); void refresh();
@@ -73,7 +63,7 @@ export function useApi<T>(path: string, intervalMs = 0, opts: { enabled?: boolea
alive.current = false; alive.current = false;
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
}; };
}, [refresh, intervalMs, enabled]); }, [refresh, intervalMs]);
return { data, error, loading, refresh }; return { data, error, loading, refresh };
} }
+115
View File
@@ -0,0 +1,115 @@
/** Shared, serializable network configuration. No credentials belong here. */
export const LOADERS = [
"PAPER",
"FOLIA",
"FABRIC",
"FORGE",
"NEOFORGE",
"QUILT",
"VANILLA",
"MODRINTH",
] as const;
export type Loader = (typeof LOADERS)[number];
export type WorldProfile = {
id: string;
name: string;
version: string;
loader: Loader;
java: "8" | "17" | "21" | "25";
seed: string;
memory: number;
maxPlayers: number;
difficulty: string;
mode: string;
viewDistance: number;
simulationDistance: number;
pvp: boolean;
hardcore: boolean;
route: "proxy" | "direct";
port: number;
hostname: string;
mods: string;
modpack: string;
modpackVersion: string;
loaderVersion: string;
map: "none" | "bluemap" | "dynmap";
mapPort: number;
mapUrl: string;
resourcePack: string;
resourcePackSha1: string;
resourcePackRequired: boolean;
resourcePackPrompt: string;
whitelist: string;
backupHours: number;
retentionDays: number;
createdAt: number;
};
export type ProxySettings = {
port: number;
lobby: string;
motd: string;
version: string;
};
export type NetworkJob = {
id: string;
profileId: string;
action: string;
state: "queued" | "running" | "succeeded" | "failed";
message: string;
startedAt: number;
finishedAt?: number;
};
export type NetworkConfig = {
schema: 1;
profiles: WorldProfile[];
proxy: ProxySettings;
jobs: NetworkJob[];
};
export type RuntimeState = {
exists: boolean;
running: boolean;
status: string;
health?: string;
error?: string;
};
export type NetworkView = NetworkConfig & {
managed: boolean;
runtime: string;
states: Record<string, RuntimeState>;
primaryPort: number;
busy: boolean;
};
export const defaultProfile: Omit<WorldProfile, "id" | "createdAt"> = {
name: "",
version: "1.21.11",
loader: "PAPER",
java: "21",
seed: "",
memory: 4,
maxPlayers: 20,
difficulty: "normal",
mode: "survival",
viewDistance: 10,
simulationDistance: 6,
pvp: true,
hardcore: false,
route: "proxy",
port: 25570,
hostname: "",
mods: "",
modpack: "",
modpackVersion: "",
loaderVersion: "",
map: "none",
mapPort: 8101,
mapUrl: "",
resourcePack: "",
resourcePackSha1: "",
resourcePackRequired: false,
resourcePackPrompt: "",
whitelist: "",
backupHours: 24,
retentionDays: 7,
};
export const proxyCompatible = (loader: Loader) =>
loader === "PAPER" || loader === "FOLIA";
-26
View File
@@ -1,26 +0,0 @@
"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);
}
-47
View File
@@ -3,54 +3,7 @@ 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();
+4 -1
View File
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { DockerError, RconError } from "./errors"; import { DockerError } from "./docker";
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
@@ -20,6 +22,7 @@ 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);
} }
+338
View File
@@ -0,0 +1,338 @@
/** Apple's native macOS container CLI. The panel process must run on the Mac host. */
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
import { atomicWrite } from "./network-config";
import type {
ContainerState,
ContainerStats,
LogStreamHandle,
PowerAction,
} from "./docker";
const exec = promisify(execFile);
export type AppleInspect = {
configuration: {
id: string;
image: { reference: string };
initProcess: { environment: string[] };
mounts: Array<{ source: string; destination: string }>;
resources: { memoryInBytes: number };
};
status: {
state: string;
startedDate?: string;
networks: Array<{ ipv4Address: string; network: string }>;
};
};
function cli() {
if (process.platform !== "darwin")
throw new Error(
"Apple container control must run on the macOS host. Use scripts/apple-container-dev.sh.",
);
return process.env.APPLE_CONTAINER_CLI || "/opt/homebrew/bin/container";
}
export async function appleCommand(args: string[], timeout = 30_000) {
try {
return await exec(cli(), args, { timeout, maxBuffer: 4 * 1024 * 1024 });
} catch (error) {
// execFile's generic message includes all argv: report stderr rather than secrets/commands.
const e = error as Error & { stderr?: string; code?: string | number };
if (e.code === "ENOENT")
throw new Error(
"Apple container CLI is unavailable; check APPLE_CONTAINER_CLI",
);
throw new Error(
e.stderr?.trim() ||
`Apple container ${args[0]} failed (${e.code ?? "timeout"})`,
);
}
}
export async function appleInspectRaw(
name: string,
): Promise<AppleInspect | null> {
try {
const r = await appleCommand(["inspect", name]);
return (JSON.parse(r.stdout) as AppleInspect[])[0] ?? null;
} catch (e) {
if (
/not found|does not exist|no such container/i.test((e as Error).message)
)
return null;
throw e;
}
}
const health = new Map<string, { time: number; value: string }>();
export async function appleInspect(name: string): Promise<ContainerState> {
const info = await appleInspectRaw(name);
if (!info) return { exists: false, running: false, status: "missing" };
const running = info.status.state === "running";
let ready: string | undefined;
if (running && !name.endsWith("-backup")) {
const cached = health.get(name);
if (cached && Date.now() - cached.time < 8000) ready = cached.value;
else {
try {
await appleCommand(
[
"exec",
name,
"mc-monitor",
"status",
"--host",
"127.0.0.1",
"--port",
"25565",
],
8000,
);
ready = "healthy";
} catch {
ready = "starting";
}
health.set(name, { time: Date.now(), value: ready });
}
}
return {
exists: true,
running,
status: info.status.state,
health: ready,
startedAt: info.status.startedDate,
image: info.configuration.image.reference,
};
}
export async function applePower(name: string, action: PowerAction) {
health.delete(name);
if (action === "restart") {
const current = await appleInspectRaw(name);
if (current?.status.state === "running")
await appleCommand(["stop", "--time", "90", name], 150000);
await appleCommand(["start", name], 120000);
} else
await appleCommand(
action === "stop" ? ["stop", "--time", "90", name] : [action, name],
150000,
);
}
export async function appleExec(name: string, args: string[], timeout: number) {
try {
const r = await appleCommand(["exec", name, ...args], timeout);
return { exitCode: 0, output: r.stdout + r.stderr };
} catch (e) {
return { exitCode: 1, output: (e as Error).message };
}
}
export async function appleEnvironment(name: string) {
const info = await appleInspectRaw(name);
if (!info) throw new Error("Container not found");
return Object.fromEntries(
info.configuration.initProcess.environment.map((s) => {
const i = s.indexOf("=");
return [s.slice(0, i), s.slice(i + 1)];
}),
);
}
const cpuSamples = new Map<string, { time: number; usage: number }>();
export async function appleStats(name: string): Promise<ContainerStats> {
const result = await appleCommand([
"stats",
name,
"--no-stream",
"--format",
"json",
]);
const s = JSON.parse(result.stdout)[0] as {
cpuUsageUsec: number;
memoryUsageBytes: number;
memoryLimitBytes: number;
networkRxBytes: number;
networkTxBytes: number;
};
if (!s) throw new Error("No container stats available");
const now = Date.now();
const prev = cpuSamples.get(name);
cpuSamples.set(name, { time: now, usage: s.cpuUsageUsec });
return {
cpuPercent:
prev && now > prev.time
? Math.max(
0,
((s.cpuUsageUsec - prev.usage) / ((now - prev.time) * 1000)) * 100,
)
: 0,
memUsed: s.memoryUsageBytes,
memLimit: s.memoryLimitBytes,
netRx: s.networkRxBytes,
netTx: s.networkTxBytes,
};
}
export function appleLogStream(
name: string,
opts: { tail: number; follow: boolean },
onLine: (s: string) => void,
onEnd?: (e?: Error) => void,
): LogStreamHandle {
let child: ReturnType<typeof spawn>;
try {
child = spawn(cli(), [
"logs",
"-n",
String(opts.tail),
...(opts.follow ? ["--follow"] : []),
name,
]);
} catch (e) {
onEnd?.(e as Error);
return { stop() {} };
}
let carry = "";
let stopped = false;
const receive = (chunk: Buffer) => {
carry += chunk.toString();
let i;
while ((i = carry.indexOf("\n")) >= 0) {
onLine(carry.slice(0, i));
carry = carry.slice(i + 1);
}
};
child.stdout?.on("data", receive);
child.stderr?.on("data", receive);
child.on("error", (e) => {
if (!stopped) onEnd?.(e);
});
child.on("close", () => {
if (!stopped) onEnd?.();
});
return {
stop() {
stopped = true;
child.kill();
},
};
}
export async function appleInfrastructure() {
await appleCommand(["system", "status"]);
const network = process.env.APPLE_CONTAINER_NETWORK || "craftdeck";
try {
await appleCommand(["network", "inspect", network]);
} catch (e) {
if (!/not found|does not exist/i.test((e as Error).message)) throw e;
await appleCommand(["network", "create", network]);
}
return { hostDataDir: env.mcDataDir, network };
}
export async function appleAddress(name: string) {
const info = await appleInspectRaw(name);
const address = info?.status.networks[0]?.ipv4Address?.split("/")[0];
if (!address)
throw new Error(`Start ${name} before applying gateway routing`);
return address;
}
type Service = {
image: string;
container_name: string;
environment: Record<string, string>;
mem_limit?: string;
volumes: string[];
ports?: string[];
};
type ComposePlan = {
services: Record<string, Service>;
networks: { stack: { name: string } };
};
/** Translate only CraftDeck's own generated service definition, never arbitrary Compose input. */
export function appleRunArguments(
service: Service,
network: string,
envFile: string,
) {
return [
"run",
"-d",
"--name",
service.container_name,
"--cpus",
service.container_name.endsWith("-backup") ? "1" : "2",
"--memory",
(service.mem_limit ?? "512m").toUpperCase(),
"--network",
network,
"--dns",
process.env.APPLE_CONTAINER_DNS || "1.1.1.1",
...(service.container_name === "craftdeck-velocity"
? [
"--user",
`${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
]
: []),
"--env-file",
envFile,
...service.volumes.flatMap((v) => ["--volume", v]),
...(service.ports ?? []).flatMap((p) => ["--publish", p]),
service.image,
];
}
async function runService(service: Service, network: string, dir: string) {
// Pull first so a registry failure leaves the existing container available.
await appleCommand(["image", "pull", service.image], 15 * 60_000);
const previous = await appleInspectRaw(service.container_name);
if (previous) {
if (previous.status.state === "running")
await applePower(service.container_name, "stop");
await appleCommand(["delete", service.container_name]);
}
health.delete(service.container_name);
const file = path.join(dir, `${service.container_name}.env`);
atomicWrite(
file,
Object.entries(service.environment)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n",
);
await appleCommand(appleRunArguments(service, network, file), 120_000);
}
export async function appleWaitReady(name: string, seconds = 600) {
const deadline = Date.now() + seconds * 1000;
while (Date.now() < deadline) {
const info = await appleInspect(name);
if (info.health === "healthy") return;
if (!info.running)
throw new Error(
`${name} exited before Minecraft became ready; inspect its console`,
);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`${name} did not become ready within ${seconds} seconds`);
}
function readPlan(file: string): ComposePlan {
// Undo Compose's escaped dollar signs before passing literal values via env-file.
return JSON.parse(
fs.readFileSync(file, "utf8").replace(/\$\$/g, "$"),
) as ComposePlan;
}
/** A stopped/started Apple VM can acquire a new IP, including after snapshots. */
export async function appleRefreshBackup(file: string) {
const plan = readPlan(file);
const server = plan.services.server;
if (!server) throw new Error("Missing server deployment plan");
await appleWaitReady(server.container_name);
const backup = plan.services.backup;
if (backup) {
backup.environment.RCON_HOST = await appleAddress(server.container_name);
await runService(backup, plan.networks.stack.name, path.dirname(file));
}
}
export async function appleApply(file: string) {
const plan = readPlan(file);
const network = plan.networks.stack.name;
const dir = path.dirname(file);
if (plan.services.server) {
await runService(plan.services.server, network, dir);
await appleRefreshBackup(file);
} else if (plan.services.proxy) {
await runService(plan.services.proxy, network, dir);
await appleWaitReady(plan.services.proxy.container_name, 180);
} else throw new Error("Unsupported Apple container deployment plan");
}
+48 -25
View File
@@ -2,9 +2,12 @@ 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 { db, logActivity } from "./db"; import { 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)
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
.sort((a, b) => b.mtime - a.mtime); .sort((a, b) => b.mtime - a.mtime);
} }
export async function triggerBackup(): Promise<string> { export 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)}`);
} }
const created = listBackups().find((b) => !before.has(b.name)); // Some sidecar versions acknowledge the request before writing the archive.
logActivity("backup", null, created?.name ?? "manual backup"); // Wait for a new complete gzip/tar, never treat the request as a finished backup.
notify("success", "Backup completed", created ? created.name : "", { discord: false }); const deadline = Date.now() + 30 * 60 * 1000;
return created?.name ?? "backup completed"; while (Date.now() < deadline) {
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 async function restoreBackup(name: string): Promise<void> { export 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): void { export function deleteBackup(name: string): Promise<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 {
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise<void> {
try { try {
await triggerBackup(); await triggerBackup();
} catch (err) { } catch (err) {
db(); notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`); throw err;
} }
} }
+9 -26
View File
@@ -1,45 +1,30 @@
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 async function boot() { export 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 {}
} }
await seedAdminUser(); seedAdminUser();
const [{ startLogWatcher }, { startSampler }, { startAuthWatcher }, { startScheduler }] =
await Promise.all([
import("./logwatch"),
import("./sampler"),
import("./authflow"),
import("./scheduler"),
]);
startLogWatcher(); startLogWatcher();
startSampler(); startSampler();
startAuthWatcher(); startAuthWatcher();
@@ -48,9 +33,7 @@ export async function boot() {
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`); console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
} }
async function seedAdminUser() { 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;
+9 -4
View File
@@ -1,5 +1,8 @@
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.
@@ -9,21 +12,23 @@ import { env } from "./env";
*/ */
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> { export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => { assertDeploymentEditable();
if (env.containerRuntime === "apple") throw new Error("Primary Compose configuration applies on the production Docker host. Use Worlds & Network for native Apple-container profiles.");
return withServerOperation(() => new Promise((resolve) => {
execFile( execFile(
"docker", "docker",
["compose", "--project-directory", env.projectDir, ...args], ["compose", "--project-directory", env.projectDir, "-f", path.join(env.projectDir, env.composeFile), ...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.mcContainer]); return runCompose(["up", "-d", "--no-deps", env.mcService]);
} }
/** Recreate the backup sidecar (after backup schedule changes). */ /** Recreate the backup sidecar (after backup schedule changes). */
+33 -5
View File
@@ -1,6 +1,7 @@
import http from "node:http"; import http from "node:http";
import { appleInspect, applePower, appleExec, appleEnvironment, appleStats, appleLogStream, appleInfrastructure } from "./apple-container";
import { env } from "./env"; import { env } from "./env";
import { DockerError } from "./errors"; import { withServerOperation } from "./operation";
/** /**
* Minimal Docker Engine API client over the unix socket (no dependencies). * Minimal Docker Engine API client over the unix socket (no dependencies).
@@ -8,9 +9,15 @@ import { DockerError } from "./errors";
* unavailable" / "container missing" states instead of crashing. * unavailable" / "container missing" states instead of crashing.
*/ */
// Re-exported for existing callers; the class itself lives in errors.ts so export class DockerError extends Error {
// that read-only routes can recognise it without importing this module. constructor(
export { DockerError }; message: string,
public kind: "unavailable" | "not_found" | "api" = "api",
public statusCode?: number
) {
super(message);
}
}
type ReqOptions = { type ReqOptions = {
method?: string; method?: string;
@@ -91,6 +98,7 @@ type InspectResponse = {
}; };
export async function inspectContainer(name: string): Promise<ContainerState> { export async function inspectContainer(name: string): Promise<ContainerState> {
if (env.containerRuntime === "apple") return appleInspect(name);
try { try {
const data = await json<InspectResponse>(`/containers/${name}/json`); const data = await json<InspectResponse>(`/containers/${name}/json`);
return { return {
@@ -111,6 +119,7 @@ export async function inspectContainer(name: string): Promise<ContainerState> {
} }
export async function containerEnv(name: string): Promise<Record<string, string>> { export async function containerEnv(name: string): Promise<Record<string, string>> {
if (env.containerRuntime === "apple") return appleEnvironment(name);
const data = await json<InspectResponse>(`/containers/${name}/json`); const data = await json<InspectResponse>(`/containers/${name}/json`);
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const kv of data.Config.Env ?? []) { for (const kv of data.Config.Env ?? []) {
@@ -125,6 +134,11 @@ 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> {
if (env.containerRuntime === "apple") return applePower(name, action);
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",
@@ -162,6 +176,7 @@ type StatsResponse = {
}; };
export async function containerStats(name: string): Promise<ContainerStats> { export async function containerStats(name: string): Promise<ContainerStats> {
if (env.containerRuntime === "apple") return appleStats(name);
// one-shot=false + stream=false makes the daemon take two samples so the // one-shot=false + stream=false makes the daemon take two samples so the
// precpu fields are populated and CPU% is computable from a single call // precpu fields are populated and CPU% is computable from a single call
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`); const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
@@ -221,6 +236,7 @@ export function streamContainerLogs(
onLine: (line: string) => void, onLine: (line: string) => void,
onEnd?: (err?: Error) => void onEnd?: (err?: Error) => void
): LogStreamHandle { ): LogStreamHandle {
if (env.containerRuntime === "apple") return appleLogStream(name, opts, onLine, onEnd);
const req = http.request({ const req = http.request({
socketPath: env.dockerSocket, socketPath: env.dockerSocket,
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`, path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
@@ -257,6 +273,7 @@ export async function execInContainer(
cmd: string[], cmd: string[],
timeoutMs = 15 * 60 * 1000 timeoutMs = 15 * 60 * 1000
): Promise<{ exitCode: number; output: string }> { ): Promise<{ exitCode: number; output: string }> {
if (env.containerRuntime === "apple") return appleExec(name, cmd, timeoutMs);
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, { const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
method: "POST", method: "POST",
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd }, body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
@@ -287,5 +304,16 @@ export async function execInContainer(
}); });
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`); const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
return { exitCode: info.ExitCode ?? 0, output }; if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
return { exitCode: info.ExitCode, output };
}
/** Network profiles join the primary stack's Docker network and data mount. */
export async function networkInfrastructure() {
if (env.containerRuntime === "apple") return appleInfrastructure();
const data = await json<{ Mounts: Array<{ Type: string; Source: string; Destination: string }>; NetworkSettings: { Networks: Record<string, unknown> } }>(`/containers/${env.mcContainer}/json`);
const mount = data.Mounts.find(m => m.Destination === "/data" && m.Type === "bind");
const networks = Object.keys(data.NetworkSettings.Networks).filter(n => n !== "host" && n !== "none" && n !== "bridge");
if (!mount || networks.length !== 1) throw new Error("Network deployment requires the primary server's /data bind mount and one user-defined Docker network");
return { hostDataDir: mount.Source, network: networks[0] };
} }
+6 -4
View File
@@ -14,10 +14,12 @@ function str(key: string, fallback: string): string {
export const env = { export const env = {
dev, dev,
// Public read-only mirror mode (see docs/public-mode.md). containerRuntime: str("CONTAINER_RUNTIME", "docker"),
publicReadonly: process.env.PUBLIC_READONLY === "1", managed: process.env.PANEL_MANAGED === "true",
feedPath: path.resolve(str("FEED_PATH", dev ? "./dev-data/feed/feed.db" : "/feed/feed.db")), mcService: str("MC_SERVICE", "mc"),
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
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" : ""),
+6 -1
View File
@@ -1,6 +1,7 @@
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.
@@ -18,16 +19,20 @@ export function readEnvFile(): Record<string, string> {
try { try {
text = fs.readFileSync(envFilePath(), "utf8"); text = fs.readFileSync(envFilePath(), "utf8");
} catch { } catch {
return out; return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : 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 {
-26
View File
@@ -1,26 +0,0 @@
/**
* 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);
}
}
-153
View File
@@ -1,153 +0,0 @@
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();
}
+5
View File
@@ -1,6 +1,7 @@
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
@@ -80,18 +81,21 @@ 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);
@@ -103,6 +107,7 @@ 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);
+11
View File
@@ -0,0 +1,11 @@
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();
}
+4 -1
View File
@@ -3,6 +3,7 @@ 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
@@ -24,7 +25,7 @@ export type ModSearchResult = {
}; };
export function currentMcVersion(): string { export function currentMcVersion(): string {
return readEnvFile()["MC_VERSION"] || "26.2"; return readEnvFile()["MC_VERSION"] || "26.3";
} }
export async function searchMods(query: string): Promise<ModSearchResult[]> { export async function searchMods(query: string): Promise<ModSearchResult[]> {
@@ -143,11 +144,13 @@ 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));
} }
+312
View File
@@ -0,0 +1,312 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { env } from "./env";
import {
LOADERS,
defaultProfile,
proxyCompatible,
type NetworkConfig,
type WorldProfile,
type ProxySettings,
} from "@/lib/network";
export class NetworkInputError extends Error {}
export function networkFile() {
return path.join(env.appDataDir, "network.json");
}
export function readNetwork(): NetworkConfig {
if (!fs.existsSync(networkFile()))
return {
schema: 1,
profiles: [],
proxy: {
port: 25566,
lobby: "",
motd: "A CraftDeck network",
version: "latest",
},
jobs: [],
};
const value = JSON.parse(
fs.readFileSync(networkFile(), "utf8"),
) as NetworkConfig;
if (
value.schema !== 1 ||
!Array.isArray(value.profiles) ||
!Array.isArray(value.jobs)
)
throw new Error(
"Invalid network registry; restore network.json from backup",
);
return value;
}
export function atomicWrite(file: string, content: string, mode = 0o600) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${randomUUID()}.tmp`;
fs.writeFileSync(tmp, content, { mode });
fs.renameSync(tmp, file);
}
export function writeNetwork(config: NetworkConfig) {
atomicWrite(networkFile(), JSON.stringify(config, null, 2));
}
export function profileById(config: NetworkConfig, id: unknown) {
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
throw new NetworkInputError("Invalid server ID");
const p = config.profiles.find((p) => p.id === id);
if (!p) throw new NetworkInputError("Server profile not found");
return p;
}
export function profileRoot(id: string) {
if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
throw new NetworkInputError("Invalid server ID");
return path.join(path.dirname(env.mcDataDir), "network", id);
}
function string(value: unknown, label: string, max = 200): string {
if (
typeof value !== "string" ||
value.length > max ||
/[\x00-\x1f\x7f]/.test(value)
)
throw new NetworkInputError(`Invalid ${label}`);
return value.trim();
}
function integer(
value: unknown,
label: string,
min: number,
max: number,
): number {
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max)
throw new NetworkInputError(`${label} must be ${min}–${max}`);
return Number(value);
}
function choice<T extends string>(
value: unknown,
choices: readonly T[],
label: string,
): T {
if (!choices.includes(value as T))
throw new NetworkInputError(`Invalid ${label}`);
return value as T;
}
function bool(value: unknown, label: string) {
if (typeof value !== "boolean")
throw new NetworkInputError(`Invalid ${label}`);
return value;
}
function url(value: unknown, label: string) {
const s = string(value, label, 2000);
if (!s) return "";
let u: URL;
try {
u = new URL(s);
} catch {
throw new NetworkInputError(`${label} must be an HTTP(S) URL`);
}
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password)
throw new NetworkInputError(`Invalid ${label}`);
return s;
}
export function validateProfile(
input: unknown,
existing?: WorldProfile,
): WorldProfile {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new NetworkInputError("Missing profile");
const v = { ...defaultProfile, ...input } as Record<string, unknown>;
const name = string(v.name, "name", 60);
if (!name) throw new NetworkInputError("Name is required");
const version = string(v.version, "Minecraft version", 40);
if (!/^[0-9][a-zA-Z0-9._-]*$/.test(version))
throw new NetworkInputError(
"Use an exact Minecraft release or snapshot version",
);
const p: WorldProfile = {
id:
existing?.id ??
`${
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 28) || "world"
}-${randomUUID().slice(0, 8)}`,
createdAt: existing?.createdAt ?? Date.now(),
name,
version,
loader: choice(v.loader, LOADERS, "loader"),
java: choice(v.java, ["8", "17", "21", "25"], "Java runtime"),
seed: string(v.seed, "seed", 128),
memory: integer(v.memory, "Heap in GiB", 1, 128),
maxPlayers: integer(v.maxPlayers, "Player slots", 1, 10000),
difficulty: choice(
v.difficulty,
["peaceful", "easy", "normal", "hard"],
"difficulty",
),
mode: choice(
v.mode,
["survival", "creative", "adventure", "spectator"],
"game mode",
),
viewDistance: integer(v.viewDistance, "View distance", 2, 32),
simulationDistance: integer(
v.simulationDistance,
"Simulation distance",
2,
32,
),
pvp: bool(v.pvp, "PVP"),
hardcore: bool(v.hardcore, "hardcore"),
route: choice(v.route, ["proxy", "direct"], "connection mode"),
port: integer(v.port, "Game port", 1024, 65535),
hostname: string(v.hostname, "hostname", 253).toLowerCase(),
mods: string(v.mods, "mods", 4000),
modpack: string(v.modpack, "modpack", 100),
modpackVersion: string(v.modpackVersion, "modpack version", 100),
loaderVersion: string(v.loaderVersion, "loader version", 60),
map: choice(v.map, ["none", "bluemap", "dynmap"], "map"),
mapPort: integer(v.mapPort, "Map port", 1024, 65535),
mapUrl: url(v.mapUrl, "Map URL"),
resourcePack: url(v.resourcePack, "Resource pack URL"),
resourcePackSha1: string(
v.resourcePackSha1,
"Resource pack SHA-1",
40,
).toLowerCase(),
resourcePackRequired: bool(v.resourcePackRequired, "Required pack"),
resourcePackPrompt: string(v.resourcePackPrompt, "Pack prompt", 300),
whitelist: string(v.whitelist, "whitelist", 8000),
backupHours: integer(v.backupHours, "Backup interval", 1, 168),
retentionDays: integer(v.retentionDays, "Backup retention", 1, 365),
};
for (const field of [p.modpack, p.modpackVersion, p.loaderVersion])
if (field && !/^[a-zA-Z0-9._+-]+$/.test(field))
throw new NetworkInputError(
"Use loader/modpack IDs or versions, not URLs",
);
if (
p.mods &&
!p.mods.split(",").every((s) => /^[\w-]+(?::[\w.+-]+)?$/.test(s.trim()))
)
throw new NetworkInputError(
"Mods must be comma-separated Modrinth slugs with optional :version IDs",
);
if (
p.whitelist &&
!p.whitelist.split(",").every((s) => /^[a-zA-Z0-9_]{3,16}$/.test(s.trim()))
)
throw new NetworkInputError(
"Whitelist must contain comma-separated Minecraft usernames",
);
if (
p.hostname &&
!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(p.hostname)
)
throw new NetworkInputError("Invalid hostname");
if (p.route === "proxy" && !proxyCompatible(p.loader))
throw new NetworkInputError(
"Automatic secure proxy routing supports Paper and Folia; use a direct port for this loader",
);
if (
p.route === "proxy" &&
/^1\.(\d+)/.test(p.version) &&
Number(p.version.split(".")[1]) < 19
)
throw new NetworkInputError(
"Managed proxy profiles require Paper/Folia 1.19 or newer",
);
if (p.loader === "MODRINTH" && (!p.modpack || !p.modpackVersion))
throw new NetworkInputError(
"Choose a Modrinth pack and an exact pack version ID",
);
if (p.loader !== "MODRINTH" && p.modpack)
throw new NetworkInputError(
"Select Modrinth modpack as the runtime to install a pack",
);
if (p.map !== "none" && ["VANILLA", "MODRINTH"].includes(p.loader))
throw new NetworkInputError(
"Automatic map installation needs an explicit mod/plugin loader",
);
if (p.resourcePackSha1 && !/^[a-f0-9]{40}$/.test(p.resourcePackSha1))
throw new NetworkInputError(
"Resource pack SHA-1 must be 40 hexadecimal characters",
);
if (p.resourcePack && !p.resourcePackSha1)
throw new NetworkInputError(
"Provide the resource pack SHA-1 so clients can verify downloads",
);
if (p.resourcePackRequired && !p.resourcePack)
throw new NetworkInputError("Required resource packs need a download URL");
if (existing)
for (const key of [
"loader",
"version",
"java",
"modpack",
"modpackVersion",
"seed",
"route",
] as const) {
if (
existing[key] !== p[key] &&
fs.existsSync(
path.join(profileRoot(existing.id), "data", "server.properties"),
)
)
throw new NetworkInputError(
"Create a new profile for seed, version, runtime, pack or authentication changes; existing world data stays intact",
);
}
return p;
}
export function validateProxy(
input: unknown,
config: NetworkConfig,
): ProxySettings {
if (!input || typeof input !== "object")
throw new NetworkInputError("Missing proxy configuration");
const v = input as Record<string, unknown>;
const p = {
port: integer(v.port, "Proxy port", 1024, 65535),
lobby: string(v.lobby, "lobby", 48),
motd: string(v.motd, "MOTD", 200),
version: string(v.version, "Velocity version", 40),
};
if (!/^[a-zA-Z0-9._+-]+$/.test(p.version))
throw new NetworkInputError("Invalid Velocity version");
if (!config.profiles.some((s) => s.id === p.lobby && s.route === "proxy"))
throw new NetworkInputError(
"Choose a Paper/Folia proxy profile as the lobby",
);
return p;
}
export function validatePorts(
config: NetworkConfig,
primaryPort: number,
panelPort: number,
) {
const used = new Map<number, string>([
[primaryPort, "primary server"],
[panelPort, "panel"],
]);
const reserve = (port: number, name: string) => {
if (used.has(port))
throw new NetworkInputError(
`Port ${port} is already assigned to ${used.get(port)}`,
);
used.set(port, name);
};
reserve(config.proxy.port, "proxy");
const hosts = new Set<string>();
for (const p of config.profiles) {
if (p.route === "direct") reserve(p.port, p.name);
if (p.map !== "none") reserve(p.mapPort, `${p.name} map`);
if (p.route === "proxy" && p.hostname) {
if (hosts.has(p.hostname))
throw new NetworkInputError(`Duplicate hostname: ${p.hostname}`);
hosts.add(p.hostname);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { profileRoot, NetworkInputError } from "./network-config";
export function networkFilePath(id: string, relative: string) {
const root = path.join(profileRoot(id), "data");
if (!fs.existsSync(root))
throw new NetworkInputError("Deploy this server before browsing files");
if (typeof relative !== "string" || relative.includes("\0"))
throw new NetworkInputError("Invalid file path");
const resolved = path.resolve(root, relative || ".");
if (resolved !== root && !resolved.startsWith(root + path.sep))
throw new NetworkInputError("Path escapes server directory");
let parent = resolved;
while (true) {
try {
fs.lstatSync(/* turbopackIgnore: true */ parent);
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
parent = path.dirname(parent);
}
}
const real = fs.realpathSync(/* turbopackIgnore: true */ parent);
const realRoot = fs.realpathSync(root);
if (real !== realRoot && !real.startsWith(realRoot + path.sep))
throw new NetworkInputError("Symlink escapes server directory");
return resolved;
}
+164
View File
@@ -0,0 +1,164 @@
import path from "node:path";
import type { NetworkConfig, WorldProfile } from "@/lib/network";
export const serverName = (id: string) => `craftdeck-${id}`;
export const backupName = (id: string) => `craftdeck-${id}-backup`;
export const proxyName = "craftdeck-velocity";
export function serverEnvironment(
p: WorldProfile,
password: string,
mapProject = "",
) {
const mods = [p.mods, mapProject].filter(Boolean).join(",");
return {
EULA: "TRUE",
TYPE: p.loader,
VERSION: p.version,
MEMORY: `${p.memory}G`,
ONLINE_MODE: p.route === "proxy" ? "FALSE" : "TRUE",
ENFORCE_SECURE_PROFILE: p.route === "proxy" ? "FALSE" : "TRUE",
ENABLE_WHITELIST: "TRUE",
ENFORCE_WHITELIST: "TRUE",
WHITELIST: p.whitelist,
EXISTING_WHITELIST_FILE: "SYNCHRONIZE",
ENABLE_RCON: "TRUE",
RCON_PASSWORD: password,
RCON_PORT: "25575",
MOTD: p.name,
SEED: p.seed,
LEVEL: "world",
DIFFICULTY: p.difficulty,
MODE: p.mode,
MAX_PLAYERS: String(p.maxPlayers),
VIEW_DISTANCE: String(p.viewDistance),
SIMULATION_DISTANCE: String(p.simulationDistance),
PVP: String(p.pvp),
HARDCORE: String(p.hardcore),
SPAWN_PROTECTION: "0",
MODRINTH_PROJECTS: mods,
MODRINTH_DOWNLOAD_DEPENDENCIES: "required",
...(p.loader === "MODRINTH"
? { MODRINTH_MODPACK: p.modpack, MODRINTH_VERSION: p.modpackVersion }
: {}),
...(p.loaderVersion
? {
[(
{
FABRIC: "FABRIC_LOADER_VERSION",
FORGE: "FORGE_VERSION",
NEOFORGE: "NEOFORGE_VERSION",
QUILT: "QUILT_LOADER_VERSION",
} as Record<string, string>
)[p.loader] ?? "PAPER_BUILD"]: p.loaderVersion,
}
: {}),
RESOURCE_PACK: p.resourcePack,
RESOURCE_PACK_SHA1: p.resourcePackSha1,
RESOURCE_PACK_ENFORCE: String(p.resourcePackRequired),
RESOURCE_PACK_PROMPT: p.resourcePackPrompt,
};
}
const logging = {
driver: "json-file",
options: { "max-size": "10m", "max-file": "3" },
};
export function renderProfileCompose(
p: WorldProfile,
infra: { hostDataDir: string; network: string },
password: string,
mapProject: string,
) {
const hostRoot = path.join(path.dirname(infra.hostDataDir), "network", p.id);
return {
name: `craftdeck-${p.id}`,
services: {
server: {
image: `itzg/minecraft-server:java${p.java}`,
container_name: serverName(p.id),
restart: "unless-stopped",
stop_grace_period: "2m",
logging,
environment: serverEnvironment(p, password, mapProject),
mem_limit: `${p.memory + 1}g`,
volumes: [`${hostRoot}/data:/data`],
networks: ["stack"],
ports: [
...(p.route === "direct" ? [`${p.port}:25565`] : []),
...(p.map !== "none"
? [`127.0.0.1:${p.mapPort}:${p.map === "bluemap" ? 8100 : 8123}`]
: []),
],
},
backup: {
image:
"itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383",
container_name: backupName(p.id),
restart: "unless-stopped",
logging,
mem_limit: "512m",
depends_on: { server: { condition: "service_healthy" } },
networks: ["stack"],
environment: {
BACKUP_INTERVAL: `${p.backupHours}h`,
INITIAL_DELAY: "10m",
PRUNE_BACKUPS_DAYS: String(p.retentionDays),
BACKUP_NAME: "world",
TAR_COMPRESS_METHOD: "gzip",
PAUSE_IF_NO_PLAYERS: "false",
RCON_HOST: serverName(p.id),
RCON_PORT: "25575",
RCON_PASSWORD: password,
EXCLUDES: "*.tmp,cache,logs",
},
volumes: [`${hostRoot}/data:/data:ro`, `${hostRoot}/backups:/backups`],
},
},
networks: { stack: { external: true, name: infra.network } },
};
}
export function renderVelocity(
config: NetworkConfig,
addresses: Record<string, string> = {},
) {
const routes = config.profiles.filter((p) => p.route === "proxy");
// JSON strings are valid TOML basic strings for the validated single-line inputs.
const q = JSON.stringify;
return `config-version = "2.8"\nbind = "0.0.0.0:25565"\nmotd = ${q(config.proxy.motd)}\nshow-max-players = ${routes.reduce((n, p) => n + p.maxPlayers, 0)}\nonline-mode = true\nforce-key-authentication = true\nplayer-info-forwarding-mode = "modern"\nforwarding-secret-file = "forwarding.secret"\nping-passthrough = "DISABLED"\n\n[servers]\n${routes.map((p) => `${q(p.id)} = ${q(`${addresses[p.id] ?? serverName(p.id)}:25565`)}`).join("\n")}\ntry = ${JSON.stringify([config.proxy.lobby, ...routes.map((p) => p.id).filter((id) => id !== config.proxy.lobby)])}\n\n[forced-hosts]\n${routes
.filter((p) => p.hostname)
.map((p) => `${q(p.hostname)} = [${q(p.id)}]`)
.join(
"\n",
)}\n\n[advanced]\nfailover-on-unexpected-server-disconnect = true\n`;
}
export function renderProxyCompose(
config: NetworkConfig,
infra: { hostDataDir: string; network: string },
) {
const root = path.join(path.dirname(infra.hostDataDir), "network", "proxy");
return {
name: "craftdeck-proxy",
services: {
proxy: {
image: "itzg/mc-proxy:latest",
container_name: proxyName,
restart: "unless-stopped",
logging,
environment: {
TYPE: "VELOCITY",
VELOCITY_VERSION: config.proxy.version,
MEMORY: "512m",
HEALTH_PORT: "25565",
},
mem_limit: "1g",
ports: [`${config.proxy.port}:25565`],
networks: ["stack"],
volumes: [`${root}/data:/server`, `${root}/config:/config:ro`],
},
},
networks: { stack: { external: true, name: infra.network } },
};
}
/** Compose interpolates dollar signs even in JSON/YAML quoted strings. */
export function composeJson(value: unknown) {
return JSON.stringify(value, null, 2).replace(/\$/g, "$$$$");
}
+503
View File
@@ -0,0 +1,503 @@
import fs from "node:fs";
import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import YAML from "yaml";
import {
appleApply,
appleAddress,
appleCommand,
appleRefreshBackup,
appleWaitReady,
} from "./apple-container";
import { env } from "./env";
import { readEnvFile } from "./envfile";
import {
inspectContainer,
powerContainer,
execInContainer,
networkInfrastructure,
} from "./docker";
import { withServerOperation } from "./operation";
import { assertDeploymentEditable } from "./managed";
import {
atomicWrite,
readNetwork,
writeNetwork,
profileById,
profileRoot,
validatePorts,
NetworkInputError,
} from "./network-config";
import {
serverName,
backupName,
proxyName,
renderProfileCompose,
renderProxyCompose,
renderVelocity,
composeJson,
} from "./network-render";
import type { NetworkConfig, NetworkJob, WorldProfile } from "@/lib/network";
const exec = promisify(execFile);
const globalJobs = globalThis as unknown as { __networkJobs?: Set<string> };
const runningJobs = (globalJobs.__networkJobs ??= new Set<string>());
export const networkBusy = () => runningJobs.size > 0;
export function assertNetworkIdle() {
if (networkBusy())
throw new NetworkInputError(
"Wait for the current network operation to finish",
);
}
export function checkPorts(config: NetworkConfig) {
const values = readEnvFile();
validatePorts(
config,
Number(values.MC_PORT || 25565),
Number(values.DASHBOARD_PORT || 8080),
);
}
export function reconcileJobs(config: NetworkConfig) {
let changed = false;
for (const job of config.jobs)
if (["queued", "running"].includes(job.state) && !runningJobs.has(job.id)) {
job.state = "failed";
job.message =
"Panel restarted during this operation. Inspect server status and logs before retrying.";
job.finishedAt = Date.now();
changed = true;
}
if (changed) writeNetwork(config);
return config;
}
function redact(message: string) {
const secrets = [env.rconPassword];
const secretFile = path.join(
profileRoot("proxy"),
"config",
"forwarding.secret",
);
if (fs.existsSync(secretFile))
secrets.push(fs.readFileSync(secretFile, "utf8").trim());
for (const value of secrets.filter(Boolean))
message = message.split(value).join("[redacted]");
return message.slice(-2500);
}
function updateJob(id: string, changes: Partial<NetworkJob>) {
const config = readNetwork();
const job = config.jobs.find((j) => j.id === id);
if (job) {
Object.assign(job, changes);
writeNetwork(config);
}
}
export function queueNetworkJob(
profileId: string,
action: string,
operation: (progress: (message: string) => void) => Promise<string>,
) {
assertDeploymentEditable();
assertNetworkIdle();
const config = readNetwork();
const job: NetworkJob = {
id: randomUUID(),
profileId,
action,
state: "queued",
message: "Waiting for server operations",
startedAt: Date.now(),
};
config.jobs = [job, ...config.jobs].slice(0, 40);
writeNetwork(config);
runningJobs.add(job.id);
void withServerOperation(async () => {
try {
updateJob(job.id, { state: "running" });
const result = await operation((message) =>
updateJob(job.id, { message }),
);
updateJob(job.id, {
state: "succeeded",
message: result,
finishedAt: Date.now(),
});
} catch (e) {
try {
updateJob(job.id, {
state: "failed",
message: redact((e as Error).message),
finishedAt: Date.now(),
});
} catch {
// A full/unwritable registry must not leave the process locked forever.
// The next registry read reconciles this unfinished job as interrupted.
console.error("[craftdeck] Could not persist network operation status");
}
} finally {
runningJobs.delete(job.id);
}
});
return job;
}
async function compose(file: string, args: string[]) {
if (env.containerRuntime === "apple") return appleApply(file);
try {
await exec(
"docker",
[
"compose",
"--project-directory",
path.dirname(file),
"-f",
file,
...args,
],
{ timeout: 15 * 60_000, maxBuffer: 4 * 1024 * 1024 },
);
} catch (e) {
const err = e as Error & { stderr?: string };
throw new Error(redact(err.stderr || err.message));
}
}
function secret() {
const file = path.join(profileRoot("proxy"), "config", "forwarding.secret");
if (!fs.existsSync(file))
atomicWrite(file, randomBytes(32).toString("hex"), 0o644);
return fs.readFileSync(file, "utf8").trim();
}
async function resolveMap(p: WorldProfile) {
if (p.map === "none") return "";
const loader = p.loader.toLowerCase();
const params = new URLSearchParams({
loaders: JSON.stringify([loader]),
game_versions: JSON.stringify([p.version]),
});
const res = await fetch(
`https://api.modrinth.com/v2/project/${p.map}/version?${params}`,
{
headers: { "User-Agent": "CraftDeck/1.0" },
signal: AbortSignal.timeout(15000),
},
);
if (!res.ok)
throw new Error(
`Could not resolve ${p.map} (${res.status}); choose another map or install a compatible release manually`,
);
const versions = (await res.json()) as Array<{
id: string;
version_type: string;
}>;
const version = versions.find((v) => v.version_type === "release");
if (!version)
throw new Error(
`No stable ${p.map} build advertises ${p.loader} ${p.version} support. No server files were changed.`,
);
return `${p.map}:${version.id}`;
}
async function validatePack(p: WorldProfile) {
if (p.loader !== "MODRINTH") return;
const res = await fetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(p.modpack)}/version/${encodeURIComponent(p.modpackVersion)}`,
{
headers: { "User-Agent": "CraftDeck/1.0" },
signal: AbortSignal.timeout(15000),
},
);
if (!res.ok) throw new Error("Modrinth pack version could not be resolved");
const version = (await res.json()) as { game_versions: string[] };
if (!version.game_versions.includes(p.version))
throw new Error(
"The pack version does not support the selected Minecraft version",
);
}
function prepareForwarding(p: WorldProfile) {
if (p.route !== "proxy") return;
const file = path.join(
profileRoot(p.id),
"data",
"config",
"paper-global.yml",
);
const doc = fs.existsSync(file)
? YAML.parseDocument(fs.readFileSync(file, "utf8"))
: new YAML.Document({});
if (doc.errors.length)
throw new Error(
"Could not parse paper-global.yml; repair it before deploying",
);
doc.setIn(["proxies", "velocity", "enabled"], true);
doc.setIn(["proxies", "velocity", "online-mode"], true);
doc.setIn(["proxies", "velocity", "secret"], secret());
atomicWrite(file, doc.toString(), 0o644);
try {
fs.chownSync(file, 1000, 1000);
} catch {}
}
export async function deployProfile(id: string, progress: (s: string) => void) {
const config = readNetwork();
checkPorts(config);
const p = profileById(config, id);
progress("Checking loader, map and container runtime");
await validatePack(p);
const mapProject = await resolveMap(p);
const infra = await networkInfrastructure();
const root = profileRoot(id);
fs.mkdirSync(path.join(root, "data"), { recursive: true });
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
const state = await inspectContainer(serverName(id));
if (
state.running ||
fs.existsSync(path.join(root, "data", "world", "level.dat"))
) {
progress("Taking a verified backup before applying changes");
await snapshotProfile(id, progress);
}
const backup = await inspectContainer(backupName(id));
if (backup.running) await powerContainer(backupName(id), "stop");
if ((await inspectContainer(serverName(id))).running)
await powerContainer(serverName(id), "stop");
prepareForwarding(p);
const file = path.join(root, "compose.json");
atomicWrite(
file,
composeJson(renderProfileCompose(p, infra, env.rconPassword, mapProject)),
);
progress(
"Downloading runtime and starting server; first world generation can take several minutes",
);
await compose(file, ["up", "-d", "--wait", "--wait-timeout", "600"]);
const ready = await inspectContainer(serverName(id));
if (ready.health !== "healthy")
throw new Error(
"Container started but Minecraft readiness is unverified; check its console",
);
const routing = await refreshAppleGateway();
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : `Connect on port ${p.port}.`)}`;
}
/** Native VM addresses are ephemeral. Refresh only a gateway the user has started. */
async function refreshAppleGateway() {
if (
env.containerRuntime !== "apple" ||
!(await inspectContainer(proxyName)).running
)
return "";
const config = readNetwork();
const addresses: Record<string, string> = {};
const active = [];
for (const p of config.profiles.filter((p) => p.route === "proxy")) {
const state = await inspectContainer(serverName(p.id));
if (state.running && state.health === "healthy") {
addresses[p.id] = await appleAddress(serverName(p.id));
active.push(p);
}
}
if (!active.length) {
await powerContainer(proxyName, "stop");
return "No routed worlds are ready; gateway stopped. Apply routing after starting a world.";
}
const routed = {
...config,
profiles: active,
proxy: {
...config.proxy,
lobby: active.some((p) => p.id === config.proxy.lobby)
? config.proxy.lobby
: active[0].id,
},
};
const file = path.join(profileRoot("proxy"), "config", "velocity.toml");
const rendered = renderVelocity(routed, addresses);
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === rendered)
return "Gateway routes are current.";
atomicWrite(file, rendered, 0o644);
await powerContainer(proxyName, "restart");
await appleWaitReady(proxyName, 180);
return "Gateway restarted with current world addresses; players can reconnect.";
}
export async function deployProxy(progress: (s: string) => void) {
const config = readNetwork();
checkPorts(config);
if (!config.proxy.lobby)
throw new NetworkInputError("Choose and save a lobby first");
const lobby = profileById(config, config.proxy.lobby);
if (lobby.route !== "proxy")
throw new NetworkInputError("Lobby must be a proxy profile");
const state = await inspectContainer(serverName(lobby.id));
if (!state.running || state.health !== "healthy")
throw new NetworkInputError(
"Start the lobby and wait until healthy before applying the proxy",
);
const infra = await networkInfrastructure();
secret();
const root = profileRoot("proxy");
const configFile = path.join(root, "config", "velocity.toml");
if (fs.existsSync(configFile))
fs.copyFileSync(configFile, `${configFile}.previous`);
const addresses: Record<string, string> = {};
if (env.containerRuntime === "apple") {
for (const p of config.profiles.filter((p) => p.route === "proxy"))
addresses[p.id] = await appleAddress(serverName(p.id));
}
atomicWrite(configFile, renderVelocity(config, addresses), 0o644);
fs.mkdirSync(path.join(root, "data"), { recursive: true });
const file = path.join(root, "compose.json");
atomicWrite(file, composeJson(renderProxyCompose(config, infra)));
progress("Applying Velocity routing; connected players will reconnect");
await compose(file, [
"up",
"-d",
"--force-recreate",
"--wait",
"--wait-timeout",
"180",
]);
return `Velocity is ready on port ${config.proxy.port}; lobby: ${lobby.name}`;
}
export async function stopProfile(id: string) {
profileById(readNetwork(), id);
if ((await inspectContainer(backupName(id))).running)
await powerContainer(backupName(id), "stop");
const state = await inspectContainer(serverName(id));
if (state.running) await powerContainer(serverName(id), "stop");
const routing = await refreshAppleGateway();
return `Server and backup writer stopped; all world files retained. ${routing}`.trim();
}
export async function restartProfile(id: string) {
profileById(readNetwork(), id);
if (
env.containerRuntime === "apple" &&
(await inspectContainer(backupName(id))).running
)
await powerContainer(backupName(id), "stop");
await powerContainer(serverName(id), "restart");
if (env.containerRuntime === "apple") {
await appleRefreshBackup(path.join(profileRoot(id), "compose.json"));
const routing = await refreshAppleGateway();
return `Server is healthy; backup connection refreshed. ${routing}`.trim();
}
const backup = await inspectContainer(backupName(id));
if (backup.exists && !backup.running)
await powerContainer(backupName(id), "start");
return "Restart requested; watch the health indicator for readiness";
}
export function profileBackups(id: string) {
profileById(readNetwork(), id);
const dir = path.join(profileRoot(id), "backups");
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((n) => /^[\w.-]+\.(tgz|tar\.gz)$/.test(n))
.map((name) => ({
name,
size: fs.statSync(path.join(dir, name)).size,
mtime: fs.statSync(path.join(dir, name)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
}
export async function snapshotProfile(
id: string,
progress: (s: string) => void,
) {
profileById(readNetwork(), id);
const root = profileRoot(id);
const data = path.join(root, "data");
const server = await inspectContainer(serverName(id));
const backup = await inspectContainer(backupName(id));
progress("Stopping writers for a consistent world snapshot");
if (backup.running) await powerContainer(backupName(id), "stop");
if (server.running) await powerContainer(serverName(id), "stop");
try {
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
const name = `snapshot-${Date.now()}.tgz`;
const file = path.join(root, "backups", name);
await exec("tar", ["-czf", `${file}.partial`, "-C", data, "."], {
timeout: 30 * 60_000,
});
await exec(
"python3",
[env.restoreHelper, "verify", "--archive", `${file}.partial`],
{ timeout: 30 * 60_000 },
);
fs.renameSync(`${file}.partial`, file);
return `Verified snapshot: ${name}`;
} finally {
if (server.running) await powerContainer(serverName(id), "start");
if (env.containerRuntime === "apple") {
if (server.running) {
progress(
"Waiting for world readiness and refreshing native container connections",
);
if (backup.running)
await appleRefreshBackup(path.join(root, "compose.json"));
else await appleWaitReady(serverName(id));
}
await refreshAppleGateway();
} else if (backup.running) await powerContainer(backupName(id), "start");
}
}
export async function restoreProfile(
id: string,
name: string,
progress: (s: string) => void,
) {
if (!profileBackups(id).some((b) => b.name === name))
throw new NetworkInputError("Backup not found");
const root = profileRoot(id);
const archive = path.join(root, "backups", name);
await exec("python3", [env.restoreHelper, "verify", "--archive", archive], {
timeout: 30 * 60_000,
});
await stopProfile(id);
progress(
"Restoring verified archive; previous data is retained in the recovery directory",
);
await exec(
"python3",
[
env.restoreHelper,
"restore",
"--archive",
archive,
"--data",
path.join(root, "data"),
"--work",
path.join(root, "recovery"),
],
{ timeout: 30 * 60_000 },
);
return "Restored. Server remains stopped; inspect files and deploy when ready.";
}
export async function profileConsole(id: string, command: string) {
profileById(readNetwork(), id);
if (
typeof command !== "string" ||
!command.trim() ||
command.length > 2000 ||
/[\r\n\0]/.test(command)
)
throw new NetworkInputError("Enter one console command");
const result = await execInContainer(
serverName(id),
["rcon-cli", command.trim().replace(/^\//, "")],
20000,
);
if (result.exitCode)
throw new Error(result.output || "Console command failed");
return result.output;
}
export async function profileLogs(id: string) {
profileById(readNetwork(), id);
const result =
env.containerRuntime === "apple"
? await appleCommand(["logs", "-n", "160", serverName(id)], 15000)
: await exec("docker", ["logs", "--tail", "160", serverName(id)], {
timeout: 15000,
maxBuffer: 512 * 1024,
});
return redactLogs(`${result.stdout}\n${result.stderr}`);
}
function redactLogs(s: string) {
return s.split(env.rconPassword).join("[redacted]").slice(-32000);
}
export { proxyName };
+16
View File
@@ -0,0 +1,16 @@
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;
}
+5 -4
View File
@@ -1,6 +1,5 @@
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
@@ -12,9 +11,11 @@ import { RconError } from "./errors";
const TYPE_AUTH = 3; const TYPE_AUTH = 3;
const TYPE_COMMAND = 2; const TYPE_COMMAND = 2;
// Re-exported for existing callers; the class itself lives in errors.ts so export class RconError extends Error {
// that read-only routes can recognise it without importing this module. constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
export { RconError }; super(message);
}
}
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");
+3 -4
View File
@@ -1,10 +1,11 @@
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, execInContainer } from "./docker"; import { powerContainer } 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
@@ -47,9 +48,7 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
return "Restarted"; return "Restarted";
} }
case "backup": { case "backup": {
const res = await execInContainer(env.backupContainer, ["backup", "now"]); return await triggerBackup();
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");
+310
View File
@@ -0,0 +1,310 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/server/env", () => ({
env: {
mcDataDir: "",
appDataDir: "",
projectDir: "",
managed: false,
rconPassword: "test-rcon",
restoreHelper: "/helper.py",
containerRuntime: "docker",
},
}));
vi.mock("../src/server/docker", () => ({
inspectContainer: vi.fn(async () => ({
exists: true,
running: true,
health: "healthy",
})),
powerContainer: vi.fn(async () => {}),
execInContainer: vi.fn(),
networkInfrastructure: vi.fn(async () => ({
hostDataDir: "/srv/data/mc",
network: "stack",
})),
}));
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
execFile: vi.fn((...args: unknown[]) => {
const cb = args.at(-1) as (
err: Error | null,
stdout: string,
stderr: string,
) => void;
cb(null, "", "");
}),
}));
vi.mock("../src/server/apple-container", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/server/apple-container")>()),
appleRefreshBackup: vi.fn(async () => {}),
appleWaitReady: vi.fn(async () => {}),
appleAddress: vi.fn(async () => "192.0.2.25"),
}));
import { env } from "../src/server/env";
import { execFile } from "node:child_process";
import { inspectContainer, powerContainer } from "../src/server/docker";
import { defaultProfile } from "../src/lib/network";
import {
readNetwork,
writeNetwork,
validateProfile,
profileRoot,
} from "../src/server/network-config";
import {
deployProfile,
snapshotProfile,
restoreProfile,
restartProfile,
stopProfile,
queueNetworkJob,
reconcileJobs,
networkBusy,
assertNetworkIdle,
} from "../src/server/network";
import {
appleRunArguments,
appleRefreshBackup,
appleWaitReady,
} from "../src/server/apple-container";
let root: string;
let id: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-ops-"));
env.mcDataDir = path.join(root, "mc");
env.appDataDir = path.join(root, "app");
env.projectDir = root;
env.managed = false;
env.containerRuntime = "docker";
vi.clearAllMocks();
vi.mocked(inspectContainer).mockResolvedValue({
exists: true,
running: true,
health: "healthy",
status: "running",
});
const p = validateProfile({ ...defaultProfile, name: "Lobby" });
id = p.id;
writeNetwork({
schema: 1,
profiles: [p],
proxy: { port: 25566, lobby: id, motd: "Network", version: "latest" },
jobs: [],
});
fs.mkdirSync(path.join(profileRoot(id), "data", "world"), {
recursive: true,
});
fs.writeFileSync(
path.join(profileRoot(id), "data", "world", "level.dat"),
"test",
);
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
vi.unstubAllGlobals();
});
describe("network lifecycle safety", () => {
it("reconnects native backups and gateway routes after a restart changes the world IP", async () => {
env.containerRuntime = "apple";
await restartProfile(id);
expect(powerContainer).toHaveBeenCalledWith(
`craftdeck-${id}-backup`,
"stop",
);
expect(appleRefreshBackup).toHaveBeenCalledWith(
path.join(profileRoot(id), "compose.json"),
);
expect(
fs.readFileSync(
path.join(profileRoot("proxy"), "config", "velocity.toml"),
"utf8",
),
).toContain("192.0.2.25:25565");
expect(powerContainer).toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
expect(appleWaitReady).toHaveBeenCalledWith("craftdeck-velocity", 180);
const backupCall =
vi.mocked(appleRefreshBackup).mock.invocationCallOrder[0];
const gatewayCall = vi
.mocked(powerContainer)
.mock.invocationCallOrder.at(-1)!;
expect(backupCall).toBeLessThan(gatewayCall);
});
it("restores native connections even when snapshot creation fails", async () => {
env.containerRuntime = "apple";
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
return {} as ReturnType<typeof execFile>;
});
await expect(snapshotProfile(id, () => {})).rejects.toThrow("disk full");
expect(appleRefreshBackup).toHaveBeenCalledWith(
path.join(profileRoot(id), "compose.json"),
);
expect(powerContainer).toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
});
it("removes a stopped native world from routes instead of retaining its reusable IP", async () => {
env.containerRuntime = "apple";
const config = readNetwork();
const other = validateProfile({ ...defaultProfile, name: "Other world" });
config.profiles.push(other);
writeNetwork(config);
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
exists: true,
running: name !== `craftdeck-${id}`,
status: "running",
health: "healthy",
}));
await stopProfile(id);
const routes = fs.readFileSync(
path.join(profileRoot("proxy"), "config", "velocity.toml"),
"utf8",
);
expect(routes).not.toContain(id);
expect(routes).toContain(other.id);
expect(readNetwork().proxy.lobby).toBe(id);
});
it("stops the native gateway when its last world is stopped", async () => {
env.containerRuntime = "apple";
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
exists: true,
running: name !== `craftdeck-${id}`,
status: "running",
health: "healthy",
}));
expect(await stopProfile(id)).toContain("Apply routing");
expect(powerContainer).toHaveBeenCalledWith("craftdeck-velocity", "stop");
expect(powerContainer).not.toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
});
it("backup failure cancels deployment and resumes original writers", async () => {
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
return {} as ReturnType<typeof execFile>;
});
await expect(deployProfile(id, () => {})).rejects.toThrow("disk full");
expect(vi.mocked(execFile).mock.calls.some((c) => c[0] === "docker")).toBe(
false,
);
expect(powerContainer).toHaveBeenCalledWith(`craftdeck-${id}`, "start");
expect(powerContainer).toHaveBeenCalledWith(
`craftdeck-${id}-backup`,
"start",
);
expect(fs.existsSync(path.join(profileRoot(id), "compose.json"))).toBe(
false,
);
});
it("does not stop or change a world when map compatibility resolution fails", async () => {
const c = readNetwork();
c.profiles[0].map = "bluemap";
writeNetwork(c);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => [] })),
);
await expect(deployProfile(id, () => {})).rejects.toThrow("No stable");
expect(powerContainer).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
});
it("does not publish an archive until verification succeeds", async () => {
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
const argv = args[1] as string[];
fs.writeFileSync(argv[1], "partial");
(args.at(-1) as (e: null, stdout: string, stderr: string) => void)(
null,
"",
"",
);
return {} as ReturnType<typeof execFile>;
});
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("archive invalid"));
return {} as ReturnType<typeof execFile>;
});
await expect(snapshotProfile(id, () => {})).rejects.toThrow(
"archive invalid",
);
expect(
fs
.readdirSync(path.join(profileRoot(id), "backups"))
.every((n) => n.endsWith(".partial")),
).toBe(true);
});
it("validates restore selection before stopping writers", async () => {
await expect(restoreProfile(id, "../../bad.tgz", () => {})).rejects.toThrow(
"Backup not found",
);
expect(powerContainer).not.toHaveBeenCalled();
});
it("marks orphan jobs failed without retrying destructive operations", () => {
const c = readNetwork();
c.jobs = [
{
id: "old",
profileId: id,
action: "restore",
state: "running",
message: "",
startedAt: 1,
},
];
writeNetwork(c);
expect(reconcileJobs(c).jobs[0].state).toBe("failed");
expect(powerContainer).not.toHaveBeenCalled();
});
it("blocks conflicting edits and persists job failures without secrets", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const job = queueNetworkJob(id, "test", async () => {
await gate;
throw new Error(`oops ${env.rconPassword}`);
});
expect(networkBusy()).toBe(true);
expect(() => assertNetworkIdle()).toThrow("Wait");
expect(() => queueNetworkJob(id, "second", async () => "ok")).toThrow(
"Wait",
);
release();
await vi.waitFor(() => expect(networkBusy()).toBe(false));
const saved = readNetwork().jobs.find((j) => j.id === job.id)!;
expect(saved.state).toBe("failed");
expect(saved.message).toBe("oops [redacted]");
});
it("managed deployments reject jobs before changing persistent state", () => {
env.managed = true;
expect(() => queueNetworkJob(id, "start", async () => "ok")).toThrow(
"managed by Komodo",
);
expect(readNetwork().jobs).toEqual([]);
});
it("uses the native Apple CLI with separate arguments and a credential file", () => {
const args = appleRunArguments(
{
image: "itzg/minecraft-server:java21",
container_name: "craftdeck-lobby",
environment: { RCON_PASSWORD: "never-in-argv" },
mem_limit: "3g",
volumes: ["/path with spaces/data:/data"],
ports: ["127.0.0.1:8101:8100"],
},
"craftdeck",
"/private/profile.env",
);
expect(args).toContain("/path with spaces/data:/data");
expect(args).toContain("3G");
expect(args).not.toContain("never-in-argv");
expect(args).not.toContain("docker");
expect(args).toContain("--env-file");
});
});
+240
View File
@@ -0,0 +1,240 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/server/env", () => ({
env: { mcDataDir: "", appDataDir: "" },
}));
import { env } from "../src/server/env";
import {
defaultProfile,
type NetworkConfig,
type WorldProfile,
} from "../src/lib/network";
import {
validateProfile,
validatePorts,
validateProxy,
profileRoot,
readNetwork,
writeNetwork,
profileById,
} from "../src/server/network-config";
import {
composeJson,
renderProfileCompose,
renderVelocity,
serverEnvironment,
} from "../src/server/network-render";
import { networkFilePath } from "../src/server/network-files";
let dir: string;
const make = (overrides: Partial<WorldProfile> = {}) =>
validateProfile({ ...defaultProfile, name: "Test world", ...overrides });
const config = (profiles: WorldProfile[]): NetworkConfig => ({
schema: 1,
profiles,
proxy: {
port: 25566,
lobby: profiles[0]?.id ?? "",
motd: 'Friends "together"',
version: "latest",
},
jobs: [],
});
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-network-"));
env.mcDataDir = path.join(dir, "mc");
env.appDataDir = path.join(dir, "app");
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
describe("isolated server profiles", () => {
it("generates server-owned IDs and ignores caller supplied paths", () => {
const p = make({ id: "../../outside", name: "../Friends ♥", createdAt: 1 });
expect(p.id).toMatch(/^friends-[a-f0-9]{8}$/);
expect(p.createdAt).not.toBe(1);
expect(() => profileRoot("../escape")).toThrow();
});
it.each(["FABRIC", "FORGE", "NEOFORGE", "QUILT", "VANILLA", "MODRINTH"])(
"does not claim transparent proxy support for %s",
(loader) => {
expect(() =>
make({ loader: loader as WorldProfile["loader"], route: "proxy" }),
).toThrow("Automatic secure proxy");
},
);
it("requires exact modpack selection", () => {
expect(() =>
make({ loader: "MODRINTH", route: "direct", modpack: "pack" }),
).toThrow("exact pack version");
const p = make({
loader: "MODRINTH",
route: "direct",
modpack: "pack",
modpackVersion: "version123",
});
expect(serverEnvironment(p, "secret")).toMatchObject({
TYPE: "MODRINTH",
MODRINTH_MODPACK: "pack",
MODRINTH_VERSION: "version123",
});
});
it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
for (const update of [
{ memory: 0 },
{ memory: "4" },
{ pvp: "true" },
{ name: "world\nEULA=TRUE" },
{ resourcePack: "javascript:alert(1)" },
{ mapUrl: "https://user:password@example.com" },
{ version: "latest" },
])
expect(() =>
validateProfile({ ...defaultProfile, name: "world", ...update }),
).toThrow();
});
it("requires checksums and validates resource pack consistency", () => {
expect(() =>
make({ resourcePack: "https://example.com/pack.zip" }),
).toThrow("SHA-1");
expect(() => make({ resourcePackRequired: true })).toThrow("download URL");
const p = make({
resourcePack: "https://example.com/pack.zip",
resourcePackSha1: "a".repeat(40),
resourcePackRequired: true,
});
expect(serverEnvironment(p, "secret")).toMatchObject({
RESOURCE_PACK_ENFORCE: "true",
RESOURCE_PACK_SHA1: "a".repeat(40),
});
});
it("prevents seed, identity, version and runtime changes after initialization", () => {
const p = make();
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
fs.writeFileSync(
path.join(profileRoot(p.id), "data", "server.properties"),
"",
);
for (const update of [
{ seed: "123" },
{ version: "1.21.4" },
{ route: "direct" },
{ loader: "FOLIA" },
])
expect(() => validateProfile({ ...p, ...update }, p)).toThrow(
"Create a new profile",
);
expect(validateProfile({ ...p, memory: 8 }, p).memory).toBe(8);
});
it("persists registry and rejects malformed registry instead of losing profiles", () => {
const p = make();
writeNetwork(config([p]));
expect(readNetwork().profiles[0]).toEqual(p);
expect(profileById(readNetwork(), p.id)).toEqual(p);
fs.writeFileSync(path.join(env.appDataDir, "network.json"), "{");
expect(() => readNetwork()).toThrow();
});
});
describe("routing and network isolation", () => {
it("reserves gateway, primary, dashboard, direct, and map ports together", () => {
for (const port of [25565, 25566, 8080])
expect(() =>
validatePorts(config([make({ route: "direct", port })]), 25565, 8080),
).toThrow("already assigned");
expect(() =>
validatePorts(
config([
make({
route: "direct",
port: 25570,
map: "bluemap",
mapPort: 25570,
}),
]),
25565,
8080,
),
).toThrow("already assigned");
});
it("allows private backends to share internal game ports", () => {
expect(() =>
validatePorts(config([make(), make({ name: "Survival" })]), 25565, 8080),
).not.toThrow();
});
it("rejects duplicate hostname routes and direct-server lobbies", () => {
const p = make({ hostname: "survival.example.com" });
expect(() =>
validatePorts(config([p, make({ hostname: p.hostname })]), 25565, 8080),
).toThrow("Duplicate hostname");
const direct = make({ route: "direct" });
const c = config([direct]);
expect(() => validateProxy(c.proxy, c)).toThrow("Paper/Folia");
});
it("publishes no backend or RCON ports, isolates storage and enables forwarding authentication", () => {
const p = make({ map: "bluemap" });
const rendered = renderProfileCompose(
p,
{ hostDataDir: "/srv/game/data/mc", network: "minecraft_default" },
"secret",
"bluemap:exact",
);
expect(rendered.services.server.ports).toEqual(["127.0.0.1:8101:8100"]);
expect(rendered.services.server.volumes[0]).toBe(
`/srv/game/data/network/${p.id}/data:/data`,
);
expect(rendered.services.backup.volumes[1]).toBe(
`/srv/game/data/network/${p.id}/backups:/backups`,
);
expect(rendered.services.server.environment.ONLINE_MODE).toBe("FALSE");
expect(rendered.services.server.environment.MODRINTH_PROJECTS).toBe(
"bluemap:exact",
);
});
it("direct profiles verify accounts and never inherit primary Fabric mods", () => {
const p = make({ loader: "FORGE", route: "direct" });
const e = serverEnvironment(p, "secret");
expect(e.ONLINE_MODE).toBe("TRUE");
expect(e.MODRINTH_PROJECTS).toBe("");
expect(e).not.toHaveProperty("FABRIC_LOADER_VERSION");
});
it("escapes Compose interpolation in all user strings and credentials", () => {
expect(
JSON.parse(composeJson({ password: "${SECRET}", motd: "Costs $5" })),
).toEqual({ password: "$${SECRET}", motd: "Costs $$5" });
});
it("emits only compatible routes with explicit lobby and modern forwarding", () => {
const lobby = make({ hostname: "lobby.example.com" });
const direct = make({ loader: "FABRIC", route: "direct" });
const text = renderVelocity(config([lobby, direct]));
expect(text).toContain('player-info-forwarding-mode = "modern"');
expect(text).toContain("online-mode = true");
expect(text).toContain(`try = ["${lobby.id}"]`);
expect(text).toContain(`"lobby.example.com" = ["${lobby.id}"]`);
expect(text).not.toContain(direct.id);
expect(text).toContain('motd = "Friends \\"together\\""');
});
});
describe("profile file boundaries", () => {
it("rejects traversal and symlink escapes including nonexistent descendants", () => {
const p = make();
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
fs.mkdirSync(path.join(dir, "outside"));
fs.symlinkSync(
path.join(dir, "outside"),
path.join(profileRoot(p.id), "data", "link"),
);
expect(() => networkFilePath(p.id, "../../other")).toThrow("escapes");
expect(() => networkFilePath(p.id, "link/new/nested.yml")).toThrow(
"escapes",
);
fs.symlinkSync(
path.join(dir, "outside", "missing"),
path.join(profileRoot(p.id), "data", "dangling"),
);
expect(() => networkFilePath(p.id, "dangling")).toThrow();
expect(networkFilePath(p.id, "config/new.yml")).toBe(
path.join(profileRoot(p.id), "data", "config", "new.yml"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
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");
});
});
+52
View File
@@ -0,0 +1,52 @@
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");
});
});
+76
View File
@@ -0,0 +1,76 @@
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()
+6
View File
@@ -0,0 +1,6 @@
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"] },
});
+26 -82
View File
@@ -1,20 +1,29 @@
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:latest image: itzg/minecraft-server:java25@sha256:783d2712019a3996b4168752517a08d3448ef8995879b200316c3888f98dc394
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.2} VERSION: ${MC_VERSION:-26.3}
MEMORY: ${MC_MEMORY:-6G} MEMORY: ${MC_MEMORY:-6G}
USE_AIKAR_FLAGS: "true" FABRIC_LOADER_VERSION: "0.19.5"
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
@@ -36,35 +45,28 @@ 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}
# Server-side mods, auto-resolved for the current MC version on every start. # Reviewed exact Modrinth version IDs. Update these with Minecraft.
# ":beta" allows beta-typed builds for that project (EasyAuth publishes MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB}
# its 26.x builds as beta). MODRINTH_DOWNLOAD_DEPENDENCIES: none
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 image: itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383
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
@@ -90,6 +92,7 @@ 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:-}
@@ -98,10 +101,11 @@ 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: /mc-data MC_DATA_DIR: /server-data/mc
BACKUPS_DIR: /backups RESTORE_WORK_DIR: /server-data/restore
APP_DATA_DIR: /app-data BACKUPS_DIR: /server-data/backups
PROJECT_DIR: /project APP_DATA_DIR: /server-data/dashboard
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}
@@ -110,68 +114,8 @@ 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/mc:/mc-data - ./data:/server-data
- ./data/backups:/backups - ${PROJECT_DIR:?Set PROJECT_DIR}:${PROJECT_DIR}
- ./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
+105
View File
@@ -0,0 +1,105 @@
# Apple container for local tests; x86_64 Docker for production
The production target is an **x86_64 Linux host** running the normal Docker Compose
stack. The Dockerfile uses BuildKit's target architecture when installing the Docker
CLI and Compose plugin, so ARM64 local builds and x86_64 production builds each get
the correct binaries. No architecture is forced in the production Compose file.
The Mac development paths use Apple's official `container` CLI. Docker Desktop is
not required. The local data folders are isolated from production data and ignored by Git.
## Native world-server testing on macOS
Prerequisites: Apple silicon, supported macOS, Apple's `container` installed and its
system service running, Node 26, npm, Python 3.12+ (restore helper), and `npm ci` in
`dashboard/`.
```
./scripts/apple-container-dev.sh
```
The control process runs on the Mac at **http://localhost:8081/network**. Credentials
are generated once in `data/apple-native/panel.env` (username `admin`). That process
must run on macOS to invoke Apple's CLI. Minecraft, backups and Velocity run inside
Apple Linux containers. This is intentionally separate from the containerized UI preview.
With `CONTAINER_RUNTIME=apple`, Worlds & Network uses:
- `container image pull` and `container run` for runtime deployment;
- `container inspect`, `stats`, `logs`, `exec`, `stop` and `start` for management;
- a dedicated `craftdeck` virtual network, unless `APPLE_CONTAINER_NETWORK` overrides it;
- Minecraft protocol health checks before reporting servers ready;
- actual container IPs for backup RCON and Velocity backend routes;
- explicit DNS (1.1.1.1 by default; set `APPLE_CONTAINER_DNS` for your resolver);
- the same profile validation, forwarding authentication, backups and restore workflows.
Start all routed servers before initially applying the gateway. Native VM IPs can
change on every stop/start. Panel restart, deploy, snapshot and restore operations
refresh backup RCON and running gateway routes automatically. Updating those routes
restarts Velocity and disconnects its players, even if only one world changed.
Stopped worlds are removed from native routes; an available world temporarily serves
as the lobby if the chosen lobby is offline. If no routed world is ready, the gateway
stops; start a world and apply routing to bring it back. After changing containers
outside the panel, use Restart for each affected profile and apply routing again.
Apple CLI does not implement
Docker restart policies or Compose health scheduling: the adapter waits for startup,
but does not provide a continuous crash/reboot supervisor. Use the production Docker
stack for unattended hosting. A native snapshot uses macOS tar/Python and can be
slower on shared mounts. Apple maps host files into each VM; filesystem ownership
behavior can differ from the Linux production host.
The original primary-server pages still expect their configured `MC_CONTAINER` and
RCON endpoint. The local script creates no primary server; those pages remain offline
until one is explicitly configured. The new network page is the local test surface.
Primary `.env`/Compose configuration apply remains a Docker-only workflow.
Stop individual test servers through the network page (this also stops their backup
writer), or use `container stop --time 90 <name>`. Stop the gateway with
`container stop craftdeck-velocity`. Ctrl-C stops the native control process; it does
not stop already-running Minecraft containers. All test worlds persist under
`data/apple-native/network/`.
## Containerized panel preview
For image/UI/API smoke testing without server orchestration:
```
./scripts/apple-container-preview.sh build
./scripts/apple-container-preview.sh start
```
Open **http://localhost:8080/network**. The login is `admin`; the generated password is
in `data/apple-preview/preview.env`. Stop it with:
```
./scripts/apple-container-preview.sh stop
```
A Linux panel container cannot call the macOS Apple CLI and Apple container does not
supply a Docker Engine socket. Use the native host process above when testing actual
server controls. The image preview faithfully reports missing Docker infrastructure.
## x86_64 production image
Build the production architecture explicitly on the Mac:
```
container build --platform linux/amd64 --cpus 4 --memory 4G \
-t craftdeck:x86-local dashboard
```
Apple's Rosetta support can run that image for an additional local smoke test:
```
mkdir -p data/apple-x86
container run -d --name craftdeck-x86-preview --arch amd64 --rosetta \
--cpus 2 --memory 1G -p 127.0.0.1:8082:8080 \
--env-file data/apple-preview/preview.env \
-v "$PWD/data/apple-x86:/preview" craftdeck:x86-local
```
This verifies an x86_64 Linux process on the Mac; it is not a performance benchmark
of the real x86_64 server. On the Linux host, use the repository's normal setup and
`docker compose up -d --build`. Network orchestration defaults to Docker there.
Reference: [Apple container documentation](https://github.com/apple/container/tree/main/docs).
+10 -48
View File
@@ -1,57 +1,19 @@
# Backups & restore # Backups and restore
## What runs automatically 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.
The `backup` sidecar (itzg/mc-backup) tars the entire server data directory to 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.
`data/backups/world-<timestamp>.tgz`:
- flushes the world to disk first (`save-all`), safe while players are online ## Same-release world restore
- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
- excludes caches and logs
Safety backups are also taken automatically before config applies (when enabled) and 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`.
world resets.
## Manual backup 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.
- Panel: Backups page → **Back up now** 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.
- CLI: `docker exec mc-backup backup now`
## Restoring ## Complete recovery
Panel: Backups page → **Restore** → type `restore`. The panel stops the server, 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.
extracts the tar over the data directory, and starts the server again. Everything since
that backup is lost.
CLI fallback (panel down): 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.
```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.
+4
View File
@@ -54,3 +54,7 @@ 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.
+23
View File
@@ -0,0 +1,23 @@
# 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.
-174
View File
@@ -1,174 +0,0 @@
# 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.
+8 -77
View File
@@ -1,84 +1,15 @@
# TCPShield (DDoS protection + real player IPs) # TCPShield on Orion
[TCPShield](https://tcpshield.com) sits in front of the server: players connect to Use the maintained runbook in `homelab-infra/stacks/minecraft/TCPSHIELD.md` for the exact OPNsense and host firewall steps.
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.
TCPShield's own RealIP plugin is Bukkit/Bungee/Velocity-only — it does not exist 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.
for Fabric. The working path for this stack is **PROXY protocol v2** plus the
**Proxy Protocol Support** Fabric mod.
## Setup 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.
1. **TCPShield panel** → your backend → enable **Proxy Protocol**. 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.
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:
```bash 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.
docker compose restart mc
```
## Verifying it works 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.
Watch the startup log (`docker compose logs -f mc`). You must see all three: 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).
```
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.
+6 -34
View File
@@ -1,39 +1,11 @@
# Updating # Reviewed updates
## Upgrading Minecraft (e.g. 26.2 → 26.3) 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.
1. **Configuration** page → change `Minecraft version` → **Save** 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.
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
If a managed mod has no build for the new version yet, the server won't start — check 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.
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.
Command-line equivalent: 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.
```bash 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.
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.
+157
View File
@@ -0,0 +1,157 @@
# Worlds, maps, and a server network
CraftDeck now has **Worlds & Network**, **Live Maps**, and a **Capabilities** guide.
The original Overview, Players, Access, Performance, Schedules and other pages still
manage the primary server. The network page manages additional independent servers;
its Manage dialog scopes console, files, backups and restore to the selected server.
## Quick workflow
1. Open **Worlds & Network → Create server**. Create a Paper lobby, choose an exact
Minecraft version and matching Java runtime, and enter allowed account usernames.
2. Save, then **Deploy**. This downloads the runtime and waits for Minecraft health.
Progress and failures appear in Operations even if you leave the page.
3. Add another Paper or Folia world with a different seed. Both servers can run at once.
4. Select the lobby under Network gateway, save, and **Apply routing**.
5. Join the gateway's port, then use `/server <server-id>` to switch servers. IDs appear
on the cards. Optional hostname routes can enter a server directly through Velocity.
The gateway defaults to **25566**, preserving the primary server's **25565** binding.
To keep one public address at port 25565, your router can forward that external port
into the gateway's host port 25566. Do not bind two services to the same host port.
Changing the lobby and applying routing makes that world the network's entry point.
It restarts the proxy and disconnects current connections; it is not a live migration.
A proxy profile's game and RCON ports are not published. Velocity authenticates
Minecraft accounts and uses modern forwarding with a shared secret; Paper/Folia
are configured to verify that forwarding. All new servers enforce a whitelist.
These identities differ from the primary server's offline/EasyAuth accounts. The
panel does not migrate inventories or authentication databases between them.
## Runtime choices and isolated worlds
| Runtime | Connection | Content |
| --- | --- | --- |
| Paper | Velocity or direct | Paper-compatible plugins |
| Folia | Velocity or direct | Plugins explicitly compatible with Folia |
| Fabric / Forge / NeoForge / Quilt | Direct | Matching loader mods and clients |
| Vanilla | Direct | Vanilla clients and datapacks |
| Modrinth modpack | Direct | Exact pack/version, loader supplied by pack |
Automatic modern forwarding configuration targets Paper/Folia **1.19+**. Fabric and
modded proxy integrations require additional compatible mods and version-specific
setup; this panel deliberately does not auto-route those profiles. A proxy cannot
swap the client's installed modpack. Switching to an incompatible modpack requires
changing the client installation and reconnecting.
Each profile has a separate full data directory, including all dimensions, mods,
plugin configs, inventories and logs. The Copy settings button creates a **fresh
world**, not a clone of terrain. Change its seed or runtime before first deployment.
After initialization, runtime, Minecraft version, Java, modpack version, route and
seed are immutable in the profile editor. Create another profile to explore a new
version or pack without converting existing terrain accidentally. There is no
in-place downgrade or automatic world-conversion feature.
Java 8, 17, 21 and 25 images are available as choices; choose the release required by
your exact Minecraft/loader combination. Choosing a runtime does not verify that
all uploaded mods support it. Modrinth modpacks are checked against the selected
Minecraft version before deployment. Profile runtime images currently use Java tags;
for reproducible production deployments, review and pin the generated image digests.
Folia distributes independent regions across threads. It does not make every plugin
thread-safe or guarantee a player count/TPS. Players clustered in one region, CPU
contention and disk pressure can still cause lag. Allocate RAM for the sum of all
running heaps, at least 1 GiB per server overhead, the proxy, maps and the host OS.
## Maps
Choose BlueMap or Dynmap on a supported profile. Deployment resolves a stable release
advertising the exact Minecraft version and loader; it refuses deployment if no such
release is found. It does not fall back to an incompatible JAR. For a manually installed
renderer, leave automatic installation off and use its own configuration and ingress.
- BlueMap: internal web port **8100**, 3D terrain rendering.
- Dynmap: internal web port **8123**, 2D/isometric map rendering.
- Each maps to its own **host loopback** port. Publish through an HTTPS reverse proxy
or use an SSH tunnel. Ports are not exposed on every network interface by default.
- Save the resulting browser URL in the profile and open **Live Maps**.
- BlueMap requires its own asset-download consent in its generated config. Stop the
server, edit it through Manage → Files, and restart after reviewing that setting.
- Initial rendering can be expensive. Configure renderer threads, map dimensions,
player visibility, permissions, and privacy in the renderer's files.
- Claims need an addon compatible with the renderer and claim plugin. No claims data
is fabricated by CraftDeck. The viewer displays the renderer's real output.
The embedded viewer requires a working reachable URL; it does not prove map health.
HTTPS pages cannot embed insecure HTTP content. Renderer CSP/framing headers can
require opening the full map in its own tab.
## Resource packs and content
Set a direct ZIP URL, the file's SHA-1, an optional prompt and whether accepting the
pack is required. Deploy applies these server properties. Use a publicly reachable
static file compatible with the client version. The panel configures distribution;
it does not host the ZIP or install client mods. Compute a checksum on macOS with
`shasum -a 1 pack.zip`.
Use comma-separated Modrinth slugs (optionally `slug:version-id`) for managed mods or
plugins. Manage → Files can upload JARs into `mods/` or `plugins/`, and datapack ZIPs
into `world/datapacks/`. Config editing and uploads require the server to be stopped.
There is a 2 MB text editor limit and 100 MB upload limit. Uploads do not overwrite
existing files. Use the profile's Console to manage whitelist entries, gamerules,
weather, time, permissions and supported plugin commands. The saved profile whitelist
is authoritative at deployment, so also update it when adding permanent players.
## Backups, recovery and failures
Every server has a dedicated backup sidecar with an interval and retention policy.
The backup data and output directories belong to that profile only. Manual snapshots
briefly stop Minecraft and the sidecar, write to a partial archive, verify its contents,
then publish it as a completed snapshot. Writers resume afterward. Applying changes
to an existing world requires a verified snapshot first; backup failure cancels apply.
Restore verifies the archive before stopping writers, stages the data with the shared
restore helper, and retains previous contents under `recovery/`. The server remains
stopped after restore for inspection. Restart or deploy it once ready. Manual snapshots
are recovery points; monitor their disk usage in addition to automatic retention.
Operations are serialized with existing primary-server power/backup operations. Their
status is persisted. After a panel crash, unfinished jobs are marked failed and are
**not retried automatically**. Inspect actual container status and logs before retrying.
An unsuccessful deploy can leave the selected server stopped or unhealthy; it does
not claim success or automatically replace data. Proxy config retains a `.previous`
copy before applying changes.
Runtime storage (never commit):
```
data/dashboard/network.json # profiles, gateway settings, jobs
data/network/<id>/compose.json # generated Compose + RCON credential
data/network/<id>/data/ # complete Minecraft server
data/network/<id>/backups/ # this server's archives
data/network/<id>/recovery/ # retained pre-restore data
data/network/proxy/config/velocity.toml # routes
data/network/proxy/config/forwarding.secret
data/network/proxy/data/ # proxy runtime and plugins
```
Back up the registry and proxy configuration along with server archives. Local backups
are not off-host disaster recovery. On the production Docker host, deployment expects the standalone primary
server's bind-mounted `/data` directory and one user-defined Docker network. Komodo
managed installations stay read-only and should be extended through homelab-infra.
For native Mac testing using Apple’s container CLI, see [apple-container.md](apple-container.md).
## What else is possible?
The in-panel **Capabilities** guide distinguishes implemented features from addons:
claims/towns, economies, cross-server inventories, permissions, rollback, anti-cheat,
chunk pregeneration, voice chat, Discord bridges, Bedrock bridging, version translation,
custom dimensions, minigames and off-host backups. Those integrations need their own
compatible plugins, ports, databases and configuration; they are not preinstalled.
References: [Velocity compatibility](https://docs.papermc.io/velocity/server-compatibility/),
[modern forwarding](https://docs.papermc.io/velocity/player-information-forwarding/),
[Folia](https://docs.papermc.io/folia/),
[BlueMap installation](https://bluemap.bluecolored.de/wiki/getting-started/Installation.html),
[Docker server properties](https://docker-minecraft-server.readthedocs.io/en/latest/configuration/server-properties/),
[Modrinth modpacks](https://docker-minecraft-server.readthedocs.io/en/latest/types-and-platforms/mod-platforms/modrinth-modpacks/).
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Native macOS control process + Apple Linux containers for local server testing.
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
local_dir="$project_dir/data/apple-native"
command -v container >/dev/null
container system status >/dev/null
mkdir -p "$local_dir/project"
if [[ ! -f "$local_dir/panel.env" ]]; then
python3 - "$local_dir/panel.env" <<'PY'
import os, secrets, sys
fd=os.open(sys.argv[1],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as f:
for key in ('PANEL_SESSION_SECRET','PANEL_ADMIN_PASSWORD','RCON_PASSWORD'):
f.write(key+'='+secrets.token_hex(24)+'\n')
PY
fi
set -a
source "$local_dir/panel.env"
set +a
export CONTAINER_RUNTIME=apple
export APPLE_CONTAINER_CLI="$(command -v container)"
export APP_DATA_DIR="$local_dir/dashboard"
export MC_DATA_DIR="$local_dir/mc"
export BACKUPS_DIR="$local_dir/backups"
export PROJECT_DIR="$local_dir/project"
export RESTORE_HELPER="$project_dir/dashboard/scripts/restore_data.py"
export RESTORE_WORK_DIR="$local_dir/recovery"
export NEXT_TELEMETRY_DISABLED=1
export PORT="${CRAFTDECK_LOCAL_PORT:-8081}"
echo "Native Apple runtime panel: http://localhost:$PORT/network"
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $local_dir/panel.env"
cd "$project_dir/dashboard"
exec npm run dev -- --hostname 127.0.0.1 --port "$PORT"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Build and run the ARM64 panel using Apple's container CLI, without Docker Desktop.
# This is a local UI/API preview; Apple container does not expose a Docker Engine API.
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
preview_dir="$project_dir/data/apple-preview"
command -v container >/dev/null || { echo "Install Apple's container CLI first." >&2; exit 1; }
container system status >/dev/null
mkdir -p "$preview_dir"
if [[ ! -f "$preview_dir/preview.env" ]]; then
python3 - "$preview_dir/preview.env" <<'PY'
import os, secrets, sys
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as f:
f.write('PANEL_SESSION_SECRET=' + secrets.token_hex(32) + '\n')
f.write('PANEL_ADMIN_PASSWORD=' + secrets.token_urlsafe(20) + '\n')
f.write('APP_DATA_DIR=/preview/dashboard\nMC_DATA_DIR=/preview/mc\nBACKUPS_DIR=/preview/backups\nPROJECT_DIR=/preview/project\n')
PY
fi
case "${1:-start}" in
build) container build --platform linux/arm64 --cpus 4 --memory 4G -t craftdeck:network-local "$project_dir/dashboard" ;;
start)
if container inspect craftdeck-preview >/dev/null 2>&1; then
container start craftdeck-preview
else
container run -d --name craftdeck-preview --cpus 2 --memory 1G \
-p 127.0.0.1:8080:8080 --env-file "$preview_dir/preview.env" \
-v "$preview_dir:/preview" craftdeck:network-local
fi
echo "Panel: http://localhost:8080/network"
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $preview_dir/preview.env"
echo "Docker orchestration requires the regular Docker deployment; this is an Apple-container UI/API preview."
;;
stop) container stop craftdeck-preview ;;
*) echo "Usage: $0 {build|start|stop}" >&2; exit 1 ;;
esac
+27 -23
View File
@@ -1,25 +1,29 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Manual backup restore (fallback for when the dashboard is unavailable). # Clean restore fallback. Python 3.12+ required. Retains the prior world.
# The dashboard's Backups page does this same procedure with one click. # Standalone: scripts/restore.sh /absolute/backup.tgz
# # 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
cd "$(dirname "$0")/.." repo=$(cd "$(dirname "$0")/.." && pwd)
archive=${1:?Supply an existing archive}
archive="${1:-}" archive=$(realpath "$archive")
[[ -n "$archive" && -f "$archive" ]] || { project=${2:-$repo}
echo "Usage: $0 <backup .tgz file>" data=${3:-$repo/data}
echo "Available backups:" [[ -f "$archive" ]] || exit 1
ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)" helper="$repo/dashboard/scripts/restore_data.py"
exit 1 python3 "$helper" verify --archive "$archive"
} echo "Restore $archive into $data/mc; retain current data in $data/restore."
read -r -p "Type 'restore' to stop the stack and continue: " confirm
echo "This will STOP the server and overwrite the current world with:" [[ "$confirm" == restore ]] || exit 1
echo " $archive" cd "$project"
read -r -p "Type 'restore' to continue: " confirm export PROJECT_DIR="$project"
[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; } # Stop the panel as well: its in-process queue cannot coordinate with this script.
running=$(docker compose ps --status running --services)
docker compose stop mc docker compose stop dashboard backup mc
tar -xzf "$archive" -C data/mc args=(restore --archive "$archive" --data "$data/mc" --work "$data/restore")
docker compose start mc [[ ! -f "$project/release-lock.json" ]] || args+=(--lock-file "$project/release-lock.json")
echo "✔ Restore complete — server starting." # On failure, leave writers stopped for inspection.
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.'
+3 -1
View File
@@ -21,7 +21,9 @@ 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
mkdir -p data/mc data/backups data/dashboard printf '\nPROJECT_DIR=%s\n' "$PWD" >> .env
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"