Merge pull request #1 from geneticglitch1/codex/orion-managed-tcpshield
Pin Minecraft 26.3 and add safe Orion managed deployment support
This commit is contained in:
40 files changed
+1877
-490
No files matched your search
+6
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
# ── Minecraft ────────────────────────────────────────────────────────────
|
||||
# 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, ...
|
||||
SERVER_TYPE=FABRIC
|
||||
# JVM heap for the server. Host has 10 GB → 6G heap / 7g container limit.
|
||||
@@ -25,10 +25,8 @@ MC_SIMULATION_DISTANCE=8
|
||||
MC_SEED=
|
||||
|
||||
# Server-side mods (Modrinth project slugs, comma-separated).
|
||||
# Auto-resolved to the newest compatible release on every container start.
|
||||
# Append ":beta" to allow beta-typed builds for a project (EasyAuth ships
|
||||
# its 26.x builds as beta on Modrinth).
|
||||
MODRINTH_PROJECTS=fabric-api,easyauth:beta,spark,lithium,ferrite-core
|
||||
# Exact version IDs are locked. Review all IDs before upgrading Minecraft.
|
||||
MODRINTH_PROJECTS=fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB
|
||||
# To run a full modpack instead, set its Modrinth slug (e.g. adrenaline)
|
||||
# and see docs/mods-and-modpacks.md before enabling.
|
||||
MODRINTH_MODPACK=
|
||||
@@ -40,6 +38,9 @@ PANEL_SESSION_SECRET=CHANGE_ME
|
||||
# you are forced to change it in the UI afterwards.
|
||||
PANEL_ADMIN_PASSWORD=CHANGE_ME
|
||||
|
||||
# Absolute checkout path on the Docker host (setup.sh fills this in).
|
||||
PROJECT_DIR=
|
||||
|
||||
# ── Dashboard ────────────────────────────────────────────────────────────
|
||||
DASHBOARD_PORT=8080
|
||||
# Minutes an approved player has to /register before approval expires
|
||||
|
||||
@@ -12,3 +12,7 @@ dashboard/next-env.d.ts
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
|
||||
# Local validation artifacts
|
||||
__pycache__/
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
(including TLauncher) can join, no client mods needed
|
||||
@@ -13,7 +15,7 @@ A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| Server mods | Fabric API, EasyAuth, spark, Lithium, FerriteCore — auto-updated |
|
||||
| Panel | Next.js, port `8080`, LAN/VPN only |
|
||||
@@ -76,7 +78,7 @@ they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
|
||||
|
||||
```
|
||||
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)
|
||||
├── backup itzg/mc-backup (scheduled world tars + retention pruning)
|
||||
└── dashboard ./dashboard (CraftDeck: Next.js + node:sqlite; talks to the
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:26-slim AS deps
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:26-slim AS build
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-slim AS runtime
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
|
||||
ARG TARGETARCH=amd64
|
||||
ARG DOCKER_CLI_VERSION=27.5.1
|
||||
ARG COMPOSE_VERSION=v2.32.4
|
||||
|
||||
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
||||
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/*
|
||||
|
||||
# Docker CLI + compose plugin so the panel can recreate containers after
|
||||
@@ -46,6 +46,7 @@ ENV NODE_ENV=production \
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
COPY scripts/restore_data.py /app/restore_data.py
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
|
||||
Generated
+1279
-201
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
@@ -14,7 +16,7 @@
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@uiw/react-codemirror": "4.25.11",
|
||||
"lucide-react": "1.24.0",
|
||||
"next": "16.2.10",
|
||||
"next": "16.3.7",
|
||||
"node-cron": "4.6.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
@@ -24,8 +26,9 @@
|
||||
"@types/node": "26.1.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"postcss": "8.5.16",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.2",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "5.0.2"
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
@@ -39,6 +39,8 @@ const SECTIONS = [
|
||||
] as const;
|
||||
|
||||
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 [values, setValues] = useState<ConfigValues>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -103,10 +105,10 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} busy={saving} disabled={!dirty}>
|
||||
<Button onClick={save} busy={saving} disabled={managed || !dirty}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
|
||||
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
|
||||
<Rocket size={14} /> Apply (restarts server)
|
||||
</Button>
|
||||
</div>
|
||||
@@ -128,6 +130,7 @@ export default function ConfigPage() {
|
||||
<div key={f.key}>
|
||||
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
|
||||
<Input
|
||||
disabled={managed}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(e) => set(f.key, e.target.value)}
|
||||
className="w-full font-mono"
|
||||
|
||||
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
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 { fmtBytes, fmtDateTime } from "@/lib/format";
|
||||
|
||||
@@ -32,6 +32,8 @@ function langFor(name: string) {
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
||||
const managed = deployment?.managed ?? true;
|
||||
const [path, setPath] = useState("");
|
||||
const [entries, setEntries] = useState<Entry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,10 +126,10 @@ export default function FilesPage() {
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Upload size={13} /> Upload here
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
<FolderPlus size={13} /> New folder
|
||||
</Button>
|
||||
</div>
|
||||
@@ -187,6 +189,7 @@ export default function FilesPage() {
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
disabled={managed}
|
||||
onClick={() => setConfirmDelete(e)}
|
||||
className="rounded p-1 text-muted hover:text-crit"
|
||||
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>}
|
||||
</div>
|
||||
<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
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(null)}>
|
||||
@@ -223,6 +226,8 @@ export default function FilesPage() {
|
||||
</header>
|
||||
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
|
||||
<CodeMirror
|
||||
editable={!managed}
|
||||
readOnly={managed}
|
||||
value={editing.content}
|
||||
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
|
||||
extensions={langFor(editing.path)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Shell } from "@/components/shell";
|
||||
import { ManagedNotice } from "@/components/managed-notice";
|
||||
|
||||
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Shell>{children}</Shell>;
|
||||
return <Shell><ManagedNotice />{children}</Shell>;
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
{
|
||||
slug: "proxy-protocol-support",
|
||||
slug: "fabricproxy-lite",
|
||||
emoji: "🛡",
|
||||
title: "Proxy Protocol Support",
|
||||
title: "FabricProxy-Lite",
|
||||
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() {
|
||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
||||
const managed = deployment?.managed ?? true;
|
||||
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
@@ -127,10 +129,10 @@ export default function ModsPage() {
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Mods</h1>
|
||||
<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>
|
||||
</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"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,7 +152,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "primary"}
|
||||
disabled={already}
|
||||
disabled={managed || already}
|
||||
busy={busy === `add-${f.slug}`}
|
||||
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}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<Button onClick={() => void search()} busy={searching}>
|
||||
@@ -208,7 +210,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "primary"}
|
||||
disabled={already}
|
||||
disabled={managed || already}
|
||||
busy={busy === `add-${h.slug}`}
|
||||
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
||||
>
|
||||
@@ -223,8 +225,7 @@ export default function ModsPage() {
|
||||
{/* managed list */}
|
||||
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
||||
<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
|
||||
newest compatible build on every start, so updating Minecraft also updates these automatically.
|
||||
Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{(data?.managed ?? []).map((slug) => (
|
||||
@@ -240,6 +241,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={managed}
|
||||
busy={busy === `remove-${slug}`}
|
||||
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">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={managed}
|
||||
busy={busy === `toggle-${m.file}`}
|
||||
onClick={() =>
|
||||
void manage(
|
||||
@@ -300,6 +303,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={managed}
|
||||
busy={busy === `delete-${m.file}`}
|
||||
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function DELETE(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name) return fail("Missing backup name");
|
||||
deleteBackup(name);
|
||||
await deleteBackup(name);
|
||||
return ok({ deleted: name });
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertDeploymentEditable } from "@/server/managed";
|
||||
import { withServerOperation } from "@/server/operation";
|
||||
import { NextRequest } from "next/server";
|
||||
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
||||
import { safetyBackup } from "@/server/backups";
|
||||
@@ -5,9 +7,10 @@ import { notify } from "@/server/notify";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
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();
|
||||
if (!res.ok) {
|
||||
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 });
|
||||
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 }); }
|
||||
@@ -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 { notify } from "@/server/notify";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
await safetyBackup("mod apply");
|
||||
const res = await applyMcConfig();
|
||||
if (!res.ok) {
|
||||
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 });
|
||||
return ok({ output: res.output.slice(-1000) });
|
||||
});
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertDeploymentEditable } from "@/server/managed";
|
||||
import { withServerOperation } from "@/server/operation";
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -18,7 +20,8 @@ export async function GET() {
|
||||
|
||||
/** Danger zone: archive the current world and generate a fresh one. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
||||
if (op !== "reset") return fail("Unknown operation");
|
||||
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");
|
||||
|
||||
await safetyBackup("world reset");
|
||||
const backup = await inspectContainer(env.backupContainer);
|
||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
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));
|
||||
|
||||
await powerContainer(env.mcContainer, "start");
|
||||
if (backup.running) await powerContainer(env.backupContainer, "start");
|
||||
logActivity("world_reset", null, `old world kept as ${archived}`);
|
||||
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${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>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DockerError } from "./docker";
|
||||
import { RconError } from "./rcon";
|
||||
import { ManagedDeploymentError } from "./managed";
|
||||
|
||||
/**
|
||||
* Small helpers for route handlers. Authentication is enforced globally in
|
||||
@@ -21,6 +22,7 @@ export function handle(fn: () => Promise<NextResponse> | NextResponse) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
|
||||
if (err instanceof DockerError && err.kind === "unavailable") {
|
||||
return fail("Docker is unreachable from the panel", 503);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
||||
import { db, logActivity } from "./db";
|
||||
import { logActivity } from "./db";
|
||||
import { notify } from "./notify";
|
||||
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)
|
||||
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
|
||||
.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 res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
||||
if (res.exitCode !== 0) {
|
||||
notify("error", "Backup failed", res.output.slice(-800));
|
||||
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
||||
}
|
||||
const created = listBackups().find((b) => !before.has(b.name));
|
||||
logActivity("backup", null, created?.name ?? "manual backup");
|
||||
notify("success", "Backup completed", created ? created.name : "", { discord: false });
|
||||
return created?.name ?? "backup completed";
|
||||
// Some sidecar versions acknowledge the request before writing the archive.
|
||||
// Wait for a new complete gzip/tar, never treat the request as a finished backup.
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
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> {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
"tar",
|
||||
["-xpzf", file, "-C", env.mcDataDir],
|
||||
{ timeout: 30 * 60 * 1000 },
|
||||
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
||||
);
|
||||
export function restoreBackup(name: string): Promise<void> {
|
||||
return withServerOperation(async () => {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
|
||||
{ timeout: 30 * 60 * 1000 });
|
||||
const [mc, backup] = await Promise.all([
|
||||
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
|
||||
]);
|
||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
||||
if (mc.running) await powerContainer(env.mcContainer, "stop");
|
||||
// Failure deliberately leaves the writers stopped for inspection.
|
||||
const args = [env.restoreHelper, "restore", "--archive", file,
|
||||
"--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 {
|
||||
fs.unlinkSync(backupPath(name));
|
||||
logActivity("backup_delete", null, name);
|
||||
export function deleteBackup(name: string): Promise<void> {
|
||||
return withServerOperation(async () => {
|
||||
fs.unlinkSync(backupPath(name));
|
||||
logActivity("backup_delete", null, name);
|
||||
});
|
||||
}
|
||||
|
||||
export function backupsDirSize(): number {
|
||||
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise<void> {
|
||||
try {
|
||||
await triggerBackup();
|
||||
} catch (err) {
|
||||
db();
|
||||
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
|
||||
notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { execFile } from "node:child_process";
|
||||
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.
|
||||
@@ -9,21 +12,22 @@ import { env } from "./env";
|
||||
*/
|
||||
|
||||
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
assertDeploymentEditable();
|
||||
return withServerOperation(() => new Promise((resolve) => {
|
||||
execFile(
|
||||
"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 },
|
||||
(err, stdout, stderr) => {
|
||||
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
||||
}
|
||||
);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/** Recreate the Minecraft container so new .env values take effect. */
|
||||
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). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "node:http";
|
||||
import { env } from "./env";
|
||||
import { withServerOperation } from "./operation";
|
||||
|
||||
/**
|
||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise<Record<string, string>
|
||||
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||
|
||||
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||
return withServerOperation(() => powerContainerUnlocked(name, action));
|
||||
}
|
||||
|
||||
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||
method: "POST",
|
||||
@@ -292,5 +297,6 @@ export async function execInContainer(
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -14,6 +14,11 @@ function str(key: string, fallback: string): string {
|
||||
|
||||
export const env = {
|
||||
dev,
|
||||
managed: process.env.PANEL_MANAGED === "true",
|
||||
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" : ""),
|
||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* Read/write the project .env file while preserving comments and ordering.
|
||||
@@ -18,16 +19,20 @@ export function readEnvFile(): Record<string, string> {
|
||||
try {
|
||||
text = fs.readFileSync(envFilePath(), "utf8");
|
||||
} 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")) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||
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;
|
||||
}
|
||||
|
||||
export function writeEnvFile(updates: Record<string, string>): void {
|
||||
assertDeploymentEditable();
|
||||
const file = envFilePath();
|
||||
let lines: string[];
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, content);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function writeBinaryFile(rel: string, data: Buffer): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, data);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function deletePath(rel: string): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
||||
const st = fs.statSync(abs);
|
||||
@@ -103,6 +107,7 @@ export function deletePath(rel: string): void {
|
||||
}
|
||||
|
||||
export function makeDir(rel: string): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
fixOwnership(abs);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readEnvFile, writeEnvFile } from "./envfile";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
||||
@@ -24,7 +25,7 @@ export type ModSearchResult = {
|
||||
};
|
||||
|
||||
export function currentMcVersion(): string {
|
||||
return readEnvFile()["MC_VERSION"] || "26.2";
|
||||
return readEnvFile()["MC_VERSION"] || "26.3";
|
||||
}
|
||||
|
||||
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
||||
@@ -143,11 +144,13 @@ function safeModFile(file: string): string {
|
||||
}
|
||||
|
||||
export function toggleMod(file: string): void {
|
||||
assertDeploymentEditable();
|
||||
const full = safeModFile(file);
|
||||
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
||||
fs.renameSync(full, target);
|
||||
}
|
||||
|
||||
export function deleteModFile(file: string): void {
|
||||
assertDeploymentEditable();
|
||||
fs.unlinkSync(safeModFile(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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import cron, { type ScheduledTask } from "node-cron";
|
||||
import { db } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { powerContainer, execInContainer } from "./docker";
|
||||
import { powerContainer } from "./docker";
|
||||
import { rcon } from "./rcon";
|
||||
import { say } from "./mc";
|
||||
import { env } from "./env";
|
||||
import { triggerBackup } from "./backups";
|
||||
|
||||
/**
|
||||
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
||||
@@ -47,9 +48,7 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
|
||||
return "Restarted";
|
||||
}
|
||||
case "backup": {
|
||||
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
|
||||
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
|
||||
return "Backup completed";
|
||||
return await triggerBackup();
|
||||
}
|
||||
case "command": {
|
||||
if (!payload) throw new Error("No command configured");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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
-24
@@ -1,20 +1,29 @@
|
||||
name: minecraft
|
||||
|
||||
x-logging: &logging
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
# ── Minecraft server (Fabric via itzg/minecraft-server) ─────────────────
|
||||
mc:
|
||||
image: itzg/minecraft-server:latest
|
||||
image: itzg/minecraft-server:java25@sha256:783d2712019a3996b4168752517a08d3448ef8995879b200316c3888f98dc394
|
||||
container_name: mc
|
||||
restart: unless-stopped
|
||||
<<: *logging
|
||||
stop_grace_period: 2m
|
||||
tty: false
|
||||
stdin_open: false
|
||||
environment:
|
||||
EULA: "TRUE"
|
||||
TYPE: ${SERVER_TYPE:-FABRIC}
|
||||
VERSION: ${MC_VERSION:-26.2}
|
||||
VERSION: ${MC_VERSION:-26.3}
|
||||
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).
|
||||
# enforce-secure-profile must be off or offline-launcher players are
|
||||
@@ -36,35 +45,28 @@ services:
|
||||
ENABLE_RCON: "true"
|
||||
RCON_PASSWORD: ${RCON_PASSWORD:?generate with scripts/setup.sh}
|
||||
|
||||
# Server-side mods, auto-resolved for the current MC version on every start.
|
||||
# ":beta" allows beta-typed builds for that project (EasyAuth publishes
|
||||
# its 26.x builds as beta).
|
||||
MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api,easyauth:beta,spark,lithium,ferrite-core}
|
||||
MODRINTH_DOWNLOAD_DEPENDENCIES: required
|
||||
# Reviewed exact Modrinth version IDs. Update these with Minecraft.
|
||||
MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB}
|
||||
MODRINTH_DOWNLOAD_DEPENDENCIES: none
|
||||
|
||||
# Modpack support (leave empty for lean Fabric; see docs/mods-and-modpacks.md)
|
||||
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}
|
||||
ports:
|
||||
- "${MC_PORT:-25565}:25565"
|
||||
# BlueMap web map (only active if the bluemap mod is installed)
|
||||
- "${MAP_PORT:-8100}:8100"
|
||||
volumes:
|
||||
- ./data/mc:/data
|
||||
- ./config/patches:/patches:ro
|
||||
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}
|
||||
|
||||
# ── Scheduled world backups (itzg/mc-backup) ─────────────────────────────
|
||||
backup:
|
||||
image: itzg/mc-backup:latest
|
||||
image: itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383
|
||||
container_name: mc-backup
|
||||
restart: unless-stopped
|
||||
<<: *logging
|
||||
environment:
|
||||
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
|
||||
INITIAL_DELAY: 10m
|
||||
@@ -90,6 +92,7 @@ services:
|
||||
image: minecraft-dashboard:local
|
||||
container_name: mc-dashboard
|
||||
restart: unless-stopped
|
||||
<<: *logging
|
||||
environment:
|
||||
PANEL_SESSION_SECRET: ${PANEL_SESSION_SECRET:?generate with scripts/setup.sh}
|
||||
PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-}
|
||||
@@ -98,10 +101,11 @@ services:
|
||||
RCON_PASSWORD: ${RCON_PASSWORD}
|
||||
MC_CONTAINER: mc
|
||||
BACKUP_CONTAINER: mc-backup
|
||||
MC_DATA_DIR: /mc-data
|
||||
BACKUPS_DIR: /backups
|
||||
APP_DATA_DIR: /app-data
|
||||
PROJECT_DIR: /project
|
||||
MC_DATA_DIR: /server-data/mc
|
||||
RESTORE_WORK_DIR: /server-data/restore
|
||||
BACKUPS_DIR: /server-data/backups
|
||||
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}
|
||||
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
|
||||
TZ: ${TZ:-UTC}
|
||||
@@ -110,10 +114,8 @@ services:
|
||||
- "${DASHBOARD_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data/mc:/mc-data
|
||||
- ./data/backups:/backups
|
||||
- ./data/dashboard:/app-data
|
||||
- ./:/project
|
||||
- ./data:/server-data
|
||||
- ${PROJECT_DIR:?Set PROJECT_DIR}:${PROJECT_DIR}
|
||||
mem_limit: 512m
|
||||
depends_on:
|
||||
- mc
|
||||
+10
-48
@@ -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
|
||||
`data/backups/world-<timestamp>.tgz`:
|
||||
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.
|
||||
|
||||
- flushes the world to disk first (`save-all`), safe while players are online
|
||||
- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
|
||||
- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
|
||||
- excludes caches and logs
|
||||
## Same-release world restore
|
||||
|
||||
Safety backups are also taken automatically before config applies (when enabled) and
|
||||
world resets.
|
||||
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`.
|
||||
|
||||
## 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**
|
||||
- CLI: `docker exec mc-backup backup 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.
|
||||
|
||||
## Restoring
|
||||
## Complete recovery
|
||||
|
||||
Panel: Backups page → **Restore** → type `restore`. The panel stops the server,
|
||||
extracts the tar over the data directory, and starts the server again. Everything since
|
||||
that backup is lost.
|
||||
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.
|
||||
|
||||
CLI fallback (panel down):
|
||||
|
||||
```bash
|
||||
./scripts/restore.sh data/backups/world-20260710-050000.tgz
|
||||
```
|
||||
|
||||
## Off-site copies (recommended)
|
||||
|
||||
`data/backups/` is just files — sync it anywhere:
|
||||
|
||||
```bash
|
||||
# example: nightly rsync to another machine (add to the host's crontab)
|
||||
0 6 * * * rsync -a --delete /path/to/minecraft/data/backups/ backup-host:/srv/mc-backups/
|
||||
```
|
||||
|
||||
You can also download any backup from the panel (⬇ button) before risky experiments.
|
||||
|
||||
## Disaster recovery (new machine)
|
||||
|
||||
```bash
|
||||
git clone <repo> minecraft && cd minecraft
|
||||
./scripts/setup.sh # fresh secrets — friends keep their passwords,
|
||||
# those live in the world data, not in .env
|
||||
mkdir -p data/backups && cp <your-backup>.tgz data/backups/
|
||||
docker compose up -d # let it initialize once
|
||||
./scripts/restore.sh data/backups/<your-backup>.tgz
|
||||
```
|
||||
|
||||
The backup contains the world, mods, configs, and the EasyAuth database, so player
|
||||
registrations survive. Panel state (approval history, metrics) lives in
|
||||
`data/dashboard/` — include it in your off-site sync if you want it back too.
|
||||
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.
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
+8
-77
@@ -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
|
||||
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.
|
||||
Use the maintained runbook in `homelab-infra/stacks/minecraft/TCPSHIELD.md` for the exact OPNsense and host firewall steps.
|
||||
|
||||
TCPShield's own RealIP plugin is Bukkit/Bungee/Velocity-only — it does not exist
|
||||
for Fabric. The working path for this stack is **PROXY protocol v2** plus the
|
||||
**Proxy Protocol Support** Fabric mod.
|
||||
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.
|
||||
|
||||
## 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**.
|
||||
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:
|
||||
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.
|
||||
|
||||
```bash
|
||||
docker compose restart mc
|
||||
```
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
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.
|
||||
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).
|
||||
+6
-34
@@ -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**
|
||||
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
|
||||
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.
|
||||
|
||||
If a managed mod has no build for the new version yet, the server won't start — check
|
||||
the Console page for which one, remove it from the Mods page (or wait a few days), and
|
||||
Apply again. Worlds upgrade forward automatically; **there is no downgrade** — that's
|
||||
what the safety backup is for.
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
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.
|
||||
+27
-23
@@ -1,25 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manual backup restore (fallback for when the dashboard is unavailable).
|
||||
# The dashboard's Backups page does this same procedure with one click.
|
||||
#
|
||||
# Usage: ./scripts/restore.sh data/backups/world-20260710-120000.tgz
|
||||
# Clean restore fallback. Python 3.12+ required. Retains the prior world.
|
||||
# Standalone: scripts/restore.sh /absolute/backup.tgz
|
||||
# Orion: scripts/restore.sh /absolute/backup.tgz /opt/minecraft/project /opt/minecraft/data
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
archive="${1:-}"
|
||||
[[ -n "$archive" && -f "$archive" ]] || {
|
||||
echo "Usage: $0 <backup .tgz file>"
|
||||
echo "Available backups:"
|
||||
ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "This will STOP the server and overwrite the current world with:"
|
||||
echo " $archive"
|
||||
read -r -p "Type 'restore' to continue: " confirm
|
||||
[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; }
|
||||
|
||||
docker compose stop mc
|
||||
tar -xzf "$archive" -C data/mc
|
||||
docker compose start mc
|
||||
echo "✔ Restore complete — server starting."
|
||||
repo=$(cd "$(dirname "$0")/.." && pwd)
|
||||
archive=${1:?Supply an existing archive}
|
||||
archive=$(realpath "$archive")
|
||||
project=${2:-$repo}
|
||||
data=${3:-$repo/data}
|
||||
[[ -f "$archive" ]] || exit 1
|
||||
helper="$repo/dashboard/scripts/restore_data.py"
|
||||
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
|
||||
[[ "$confirm" == restore ]] || exit 1
|
||||
cd "$project"
|
||||
export PROJECT_DIR="$project"
|
||||
# Stop the panel as well: its in-process queue cannot coordinate with this script.
|
||||
running=$(docker compose ps --status running --services)
|
||||
docker compose stop dashboard backup mc
|
||||
args=(restore --archive "$archive" --data "$data/mc" --work "$data/restore")
|
||||
[[ ! -f "$project/release-lock.json" ]] || args+=(--lock-file "$project/release-lock.json")
|
||||
# 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
@@ -21,7 +21,9 @@ sed -e "s|^RCON_PASSWORD=.*|RCON_PASSWORD=${rcon_pw}|" \
|
||||
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
|
||||
.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 " ✔ .env created with generated secrets"
|
||||
|
||||
Reference in new issue
Block a user