Compare commits
1
Commits
No files matched your search
+6
-5
@@ -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
|
||||||
|
|||||||
@@ -12,3 +12,7 @@ dashboard/next-env.d.ts
|
|||||||
|
|
||||||
# OS noise
|
# OS noise
|
||||||
.DS_Store
|
.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
|
- **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
|
||||||
@@ -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) |
|
| 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 |
|
||||||
@@ -76,7 +78,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,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=amd64
|
||||||
ARG DOCKER_CLI_VERSION=27.5.1
|
ARG DOCKER_CLI_VERSION=27.5.1
|
||||||
ARG COMPOSE_VERSION=v2.32.4
|
ARG COMPOSE_VERSION=v2.32.4
|
||||||
|
|
||||||
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip \
|
&& 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,6 +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
|
||||||
|
COPY scripts/restore_data.py /app/restore_data.py
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
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": {
|
"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,7 +16,7 @@
|
|||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@uiw/react-codemirror": "4.25.11",
|
"@uiw/react-codemirror": "4.25.11",
|
||||||
"lucide-react": "1.24.0",
|
"lucide-react": "1.24.0",
|
||||||
"next": "16.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"
|
||||||
@@ -24,8 +26,9 @@
|
|||||||
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
] 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"
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Shell } from "@/components/shell";
|
import { Shell } from "@/components/shell";
|
||||||
|
import { ManagedNotice } from "@/components/managed-notice";
|
||||||
|
|
||||||
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
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.",
|
"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`)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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 }); }
|
||||||
@@ -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) });
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DockerError } from "./docker";
|
import { DockerError } from "./docker";
|
||||||
import { RconError } from "./rcon";
|
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
|
||||||
@@ -21,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
const file = backupPath(name);
|
return withServerOperation(async () => {
|
||||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
const file = backupPath(name);
|
||||||
|
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||||
const state = await inspectContainer(env.mcContainer);
|
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
|
||||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
{ timeout: 30 * 60 * 1000 });
|
||||||
|
const [mc, backup] = await Promise.all([
|
||||||
await new Promise<void>((resolve, reject) => {
|
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
|
||||||
execFile(
|
]);
|
||||||
"tar",
|
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
||||||
["-xpzf", file, "-C", env.mcDataDir],
|
if (mc.running) await powerContainer(env.mcContainer, "stop");
|
||||||
{ timeout: 30 * 60 * 1000 },
|
// Failure deliberately leaves the writers stopped for inspection.
|
||||||
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
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 {
|
export function deleteBackup(name: string): Promise<void> {
|
||||||
fs.unlinkSync(backupPath(name));
|
return withServerOperation(async () => {
|
||||||
logActivity("backup_delete", null, name);
|
fs.unlinkSync(backupPath(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,22 @@ 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();
|
||||||
|
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). */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
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).
|
||||||
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise<Record<string, string>
|
|||||||
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||||
|
|
||||||
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||||
|
return withServerOperation(() => powerContainerUnlocked(name, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
||||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -292,5 +297,6 @@ 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 };
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,11 @@ function str(key: string, fallback: string): string {
|
|||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
dev,
|
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" : ""),
|
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||||
|
|
||||||
|
|||||||
@@ -1,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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 { 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));
|
||||||
}
|
}
|
||||||
@@ -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 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");
|
||||||
|
|||||||
@@ -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
|
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,10 +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
|
||||||
+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
|
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.
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
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
@@ -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.
|
|
||||||
+27
-23
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user