-
+ Save
setEditing(null)}>
@@ -223,6 +226,8 @@ export default function FilesPage() {
setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
extensions={langFor(editing.path)}
diff --git a/dashboard/src/app/(panel)/layout.tsx b/dashboard/src/app/(panel)/layout.tsx
index 4ce0025..92a8c18 100644
--- a/dashboard/src/app/(panel)/layout.tsx
+++ b/dashboard/src/app/(panel)/layout.tsx
@@ -1,5 +1,6 @@
import { Shell } from "@/components/shell";
+import { ManagedNotice } from "@/components/managed-notice";
export default function PanelLayout({ children }: { children: React.ReactNode }) {
- return {children};
+ return {children};
}
diff --git a/dashboard/src/app/(panel)/mods/page.tsx b/dashboard/src/app/(panel)/mods/page.tsx
index 6f0bac0..12a55cf 100644
--- a/dashboard/src/app/(panel)/mods/page.tsx
+++ b/dashboard/src/app/(panel)/mods/page.tsx
@@ -60,15 +60,17 @@ const FEATURED = [
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
},
{
- slug: "proxy-protocol-support",
+ slug: "fabricproxy-lite",
emoji: "🛡",
- title: "Proxy Protocol Support",
+ title: "FabricProxy-Lite",
description:
- "Using TCPShield (or another proxy)? This restores players' real IPs so the Security page and IP bans work. Config is auto-enforced — see docs/tcpshield.md.",
+ "Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
},
];
export default function ModsPage() {
+ const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
+ const managed = deployment?.managed ?? true;
const { data, refresh } = useApi("/api/mods", 0);
const [query, setQuery] = useState("");
const [hits, setHits] = useState(null);
@@ -127,10 +129,10 @@ export default function ModsPage() {
Mods
- 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."}
- These Modrinth projects are pinned in .env (MODRINTH_PROJECTS). The server resolves the
- newest compatible build on every start, so updating Minecraft also updates these automatically.
+ Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
void manage(
@@ -300,6 +303,7 @@ export default function ModsPage() {
void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
>
diff --git a/dashboard/src/app/api/backups/file/route.ts b/dashboard/src/app/api/backups/file/route.ts
index d4a76c0..d32d8f9 100644
--- a/dashboard/src/app/api/backups/file/route.ts
+++ b/dashboard/src/app/api/backups/file/route.ts
@@ -26,7 +26,7 @@ export async function DELETE(req: NextRequest) {
return handle(async () => {
const name = req.nextUrl.searchParams.get("name");
if (!name) return fail("Missing backup name");
- deleteBackup(name);
+ await deleteBackup(name);
return ok({ deleted: name });
});
}
diff --git a/dashboard/src/app/api/config/apply/route.ts b/dashboard/src/app/api/config/apply/route.ts
index 653f16c..d11a24e 100644
--- a/dashboard/src/app/api/config/apply/route.ts
+++ b/dashboard/src/app/api/config/apply/route.ts
@@ -1,3 +1,5 @@
+import { assertDeploymentEditable } from "@/server/managed";
+import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
import { safetyBackup } from "@/server/backups";
@@ -5,9 +7,10 @@ import { notify } from "@/server/notify";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
- return handle(async () => {
+ return handle(() => withServerOperation(async () => {
+ assertDeploymentEditable();
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
- if (backupFirst) await safetyBackup("config apply");
+ if (service !== "backup" || backupFirst) await safetyBackup("config apply");
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
if (!res.ok) {
notify("error", "Config apply failed", res.output.slice(-800));
@@ -15,5 +18,5 @@ export async function POST(req: NextRequest) {
}
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
return ok({ output: res.output.slice(-1000) });
- });
+ }));
}
diff --git a/dashboard/src/app/api/deployment/route.ts b/dashboard/src/app/api/deployment/route.ts
new file mode 100644
index 0000000..3852ba3
--- /dev/null
+++ b/dashboard/src/app/api/deployment/route.ts
@@ -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 }); }
diff --git a/dashboard/src/app/api/mods/apply/route.ts b/dashboard/src/app/api/mods/apply/route.ts
index a418acf..7077e22 100644
--- a/dashboard/src/app/api/mods/apply/route.ts
+++ b/dashboard/src/app/api/mods/apply/route.ts
@@ -1,10 +1,15 @@
+import { safetyBackup } from "@/server/backups";
+import { assertDeploymentEditable } from "@/server/managed";
+import { withServerOperation } from "@/server/operation";
import { applyMcConfig } from "@/server/compose";
import { notify } from "@/server/notify";
import { ok, fail, handle } from "@/server/api";
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
export async function POST() {
- return handle(async () => {
+ return handle(() => withServerOperation(async () => {
+ assertDeploymentEditable();
+ await safetyBackup("mod apply");
const res = await applyMcConfig();
if (!res.ok) {
notify("error", "Mod apply failed", res.output.slice(-800));
@@ -12,5 +17,5 @@ export async function POST() {
}
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
return ok({ output: res.output.slice(-1000) });
- });
+ }));
}
diff --git a/dashboard/src/app/api/world/route.ts b/dashboard/src/app/api/world/route.ts
index 29da043..507e553 100644
--- a/dashboard/src/app/api/world/route.ts
+++ b/dashboard/src/app/api/world/route.ts
@@ -1,3 +1,5 @@
+import { assertDeploymentEditable } from "@/server/managed";
+import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
@@ -18,7 +20,8 @@ export async function GET() {
/** Danger zone: archive the current world and generate a fresh one. */
export async function POST(req: NextRequest) {
- return handle(async () => {
+ return handle(() => withServerOperation(async () => {
+ assertDeploymentEditable();
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
if (op !== "reset") return fail("Unknown operation");
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
@@ -27,6 +30,8 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(worldDir)) return fail("No world folder found");
await safetyBackup("world reset");
+ const backup = await inspectContainer(env.backupContainer);
+ if (backup.running) await powerContainer(env.backupContainer, "stop");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
@@ -34,8 +39,9 @@ export async function POST(req: NextRequest) {
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
await powerContainer(env.mcContainer, "start");
+ if (backup.running) await powerContainer(env.backupContainer, "start");
logActivity("world_reset", null, `old world kept as ${archived}`);
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
return ok({ archived });
- });
+ }));
}
diff --git a/dashboard/src/components/managed-notice.tsx b/dashboard/src/components/managed-notice.tsx
new file mode 100644
index 0000000..4402015
--- /dev/null
+++ b/dashboard/src/components/managed-notice.tsx
@@ -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
+ Managed deployment: versions, mods and files are read-only here. Update the reviewed
+ deployment through Komodo. Console, player controls and backups remain available.
+
;
+}
diff --git a/dashboard/src/server/api.ts b/dashboard/src/server/api.ts
index 649470a..55fe5d4 100644
--- a/dashboard/src/server/api.ts
+++ b/dashboard/src/server/api.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { DockerError } from "./docker";
import { RconError } from "./rcon";
+import { ManagedDeploymentError } from "./managed";
/**
* Small helpers for route handlers. Authentication is enforced globally in
@@ -21,6 +22,7 @@ export function handle(fn: () => Promise | NextResponse) {
try {
return await fn();
} catch (err) {
+ if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
if (err instanceof DockerError && err.kind === "unavailable") {
return fail("Docker is unreachable from the panel", 503);
}
diff --git a/dashboard/src/server/backups.ts b/dashboard/src/server/backups.ts
index 8569021..33d9c3b 100644
--- a/dashboard/src/server/backups.ts
+++ b/dashboard/src/server/backups.ts
@@ -2,9 +2,12 @@ import fs from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { execInContainer, powerContainer, inspectContainer } from "./docker";
-import { db, logActivity } from "./db";
+import { logActivity } from "./db";
import { notify } from "./notify";
import { env } from "./env";
+import { withServerOperation } from "./operation";
+import { promisify } from "node:util";
+const execFileAsync = promisify(execFile);
/**
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
.sort((a, b) => b.mtime - a.mtime);
}
-export async function triggerBackup(): Promise {
+export function triggerBackup(): Promise {
+ return withServerOperation(triggerBackupUnlocked);
+}
+
+async function triggerBackupUnlocked(): Promise {
const before = new Set(listBackups().map((b) => b.name));
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
if (res.exitCode !== 0) {
notify("error", "Backup failed", res.output.slice(-800));
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
}
- const created = listBackups().find((b) => !before.has(b.name));
- logActivity("backup", null, created?.name ?? "manual backup");
- notify("success", "Backup completed", created ? created.name : "", { discord: false });
- return created?.name ?? "backup completed";
+ // Some sidecar versions acknowledge the request before writing the archive.
+ // Wait for a new complete gzip/tar, never treat the request as a finished backup.
+ const deadline = Date.now() + 30 * 60 * 1000;
+ while (Date.now() < deadline) {
+ for (const created of listBackups().filter((b) => !before.has(b.name) && b.size > 0)) {
+ try {
+ await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", backupPath(created.name)],
+ { timeout: 10 * 60 * 1000, maxBuffer: 1024 * 1024 });
+ logActivity("backup", null, created.name);
+ notify("success", "Backup completed", created.name, { discord: false });
+ return created.name;
+ } catch { /* Still being written, or invalid: do not report success. */ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+ }
+ throw new Error("No new complete Minecraft backup appeared within 30 minutes");
}
-export async function restoreBackup(name: string): Promise {
- const file = backupPath(name);
- if (!fs.existsSync(file)) throw new Error("Backup file not found");
-
- const state = await inspectContainer(env.mcContainer);
- if (state.running) await powerContainer(env.mcContainer, "stop");
-
- await new Promise((resolve, reject) => {
- execFile(
- "tar",
- ["-xpzf", file, "-C", env.mcDataDir],
- { timeout: 30 * 60 * 1000 },
- (err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
- );
+export function restoreBackup(name: string): Promise {
+ return withServerOperation(async () => {
+ const file = backupPath(name);
+ if (!fs.existsSync(file)) throw new Error("Backup file not found");
+ await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
+ { timeout: 30 * 60 * 1000 });
+ const [mc, backup] = await Promise.all([
+ inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
+ ]);
+ if (backup.running) await powerContainer(env.backupContainer, "stop");
+ if (mc.running) await powerContainer(env.mcContainer, "stop");
+ // Failure deliberately leaves the writers stopped for inspection.
+ const args = [env.restoreHelper, "restore", "--archive", file,
+ "--data", env.mcDataDir, "--work", env.restoreWorkDir];
+ if (env.managed) args.push("--lock-file", path.join(env.projectDir, "release-lock.json"));
+ const result = await execFileAsync("python3", args, { timeout: 30 * 60 * 1000 });
+ if (mc.running) await powerContainer(env.mcContainer, "start");
+ if (backup.running) await powerContainer(env.backupContainer, "start");
+ logActivity("restore", null, `${name}: ${result.stdout.trim()}`);
+ notify("success", "Backup restored", "Previous data retained in the restore workspace.");
});
-
- await powerContainer(env.mcContainer, "start");
- logActivity("restore", null, name);
- notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
}
-export function deleteBackup(name: string): void {
- fs.unlinkSync(backupPath(name));
- logActivity("backup_delete", null, name);
+export function deleteBackup(name: string): Promise {
+ return withServerOperation(async () => {
+ fs.unlinkSync(backupPath(name));
+ logActivity("backup_delete", null, name);
+ });
}
export function backupsDirSize(): number {
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise {
try {
await triggerBackup();
} catch (err) {
- db();
- notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
+ notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
+ throw err;
}
}
diff --git a/dashboard/src/server/compose.ts b/dashboard/src/server/compose.ts
index 27b020d..4480bdd 100644
--- a/dashboard/src/server/compose.ts
+++ b/dashboard/src/server/compose.ts
@@ -1,5 +1,8 @@
import { execFile } from "node:child_process";
import { env } from "./env";
+import { assertDeploymentEditable } from "./managed";
+import { withServerOperation } from "./operation";
+import path from "node:path";
/**
* Apply configuration changes by re-running docker compose for a service.
@@ -9,21 +12,22 @@ import { env } from "./env";
*/
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
- return new Promise((resolve) => {
+ assertDeploymentEditable();
+ return withServerOperation(() => new Promise((resolve) => {
execFile(
"docker",
- ["compose", "--project-directory", env.projectDir, ...args],
+ ["compose", "--project-directory", env.projectDir, "-f", path.join(env.projectDir, env.composeFile), ...args],
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
(err, stdout, stderr) => {
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
}
);
- });
+ }));
}
/** Recreate the Minecraft container so new .env values take effect. */
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
- return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
+ return runCompose(["up", "-d", "--no-deps", env.mcService]);
}
/** Recreate the backup sidecar (after backup schedule changes). */
diff --git a/dashboard/src/server/docker.ts b/dashboard/src/server/docker.ts
index a83477f..66bf7f1 100644
--- a/dashboard/src/server/docker.ts
+++ b/dashboard/src/server/docker.ts
@@ -1,5 +1,6 @@
import http from "node:http";
import { env } from "./env";
+import { withServerOperation } from "./operation";
/**
* Minimal Docker Engine API client over the unix socket (no dependencies).
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise
export type PowerAction = "start" | "stop" | "restart" | "kill";
export async function powerContainer(name: string, action: PowerAction): Promise {
+ return withServerOperation(() => powerContainerUnlocked(name, action));
+}
+
+async function powerContainerUnlocked(name: string, action: PowerAction): Promise {
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
method: "POST",
@@ -292,5 +297,6 @@ export async function execInContainer(
});
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
- return { exitCode: info.ExitCode ?? 0, output };
+ if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
+ return { exitCode: info.ExitCode, output };
}
diff --git a/dashboard/src/server/env.ts b/dashboard/src/server/env.ts
index e7558f1..db56058 100644
--- a/dashboard/src/server/env.ts
+++ b/dashboard/src/server/env.ts
@@ -14,6 +14,11 @@ function str(key: string, fallback: string): string {
export const env = {
dev,
+ managed: process.env.PANEL_MANAGED === "true",
+ mcService: str("MC_SERVICE", "mc"),
+ composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
+ restoreWorkDir: path.resolve(str("RESTORE_WORK_DIR", dev ? "./dev-data/restore" : "/restore-work")),
+ restoreHelper: str("RESTORE_HELPER", "/app/restore_data.py"),
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
diff --git a/dashboard/src/server/envfile.ts b/dashboard/src/server/envfile.ts
index a172165..2b242c5 100644
--- a/dashboard/src/server/envfile.ts
+++ b/dashboard/src/server/envfile.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
+import { assertDeploymentEditable } from "./managed";
/**
* Read/write the project .env file while preserving comments and ordering.
@@ -18,16 +19,20 @@ export function readEnvFile(): Record {
try {
text = fs.readFileSync(envFilePath(), "utf8");
} catch {
- return out;
+ return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : out;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
if (m) out[m[1]] = m[2].trim();
}
+ if (env.managed) {
+ for (const key of EDITABLE_ENV_KEYS) out[key] = process.env[key] ?? out[key] ?? "";
+ }
return out;
}
export function writeEnvFile(updates: Record): void {
+ assertDeploymentEditable();
const file = envFilePath();
let lines: string[];
try {
diff --git a/dashboard/src/server/files.ts b/dashboard/src/server/files.ts
index 18ee0f3..16f51e5 100644
--- a/dashboard/src/server/files.ts
+++ b/dashboard/src/server/files.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
+import { assertDeploymentEditable } from "./managed";
/**
* Sandboxed file access for the file browser / editors. Every path is
@@ -80,18 +81,21 @@ export function readTextFile(rel: string): { content: string; truncated: boolean
}
export function writeTextFile(rel: string, content: string): void {
+ assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.writeFileSync(abs, content);
fixOwnership(abs);
}
export function writeBinaryFile(rel: string, data: Buffer): void {
+ assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.writeFileSync(abs, data);
fixOwnership(abs);
}
export function deletePath(rel: string): void {
+ assertDeploymentEditable();
const abs = resolveSafe(rel);
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
const st = fs.statSync(abs);
@@ -103,6 +107,7 @@ export function deletePath(rel: string): void {
}
export function makeDir(rel: string): void {
+ assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.mkdirSync(abs, { recursive: true });
fixOwnership(abs);
diff --git a/dashboard/src/server/managed.ts b/dashboard/src/server/managed.ts
new file mode 100644
index 0000000..10af305
--- /dev/null
+++ b/dashboard/src/server/managed.ts
@@ -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();
+}
diff --git a/dashboard/src/server/modrinth.ts b/dashboard/src/server/modrinth.ts
index 0a23e47..da5a3ae 100644
--- a/dashboard/src/server/modrinth.ts
+++ b/dashboard/src/server/modrinth.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { readEnvFile, writeEnvFile } from "./envfile";
import { env } from "./env";
+import { assertDeploymentEditable } from "./managed";
/**
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
@@ -24,7 +25,7 @@ export type ModSearchResult = {
};
export function currentMcVersion(): string {
- return readEnvFile()["MC_VERSION"] || "26.2";
+ return readEnvFile()["MC_VERSION"] || "26.3";
}
export async function searchMods(query: string): Promise {
@@ -143,11 +144,13 @@ function safeModFile(file: string): string {
}
export function toggleMod(file: string): void {
+ assertDeploymentEditable();
const full = safeModFile(file);
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
fs.renameSync(full, target);
}
export function deleteModFile(file: string): void {
+ assertDeploymentEditable();
fs.unlinkSync(safeModFile(file));
}
diff --git a/dashboard/src/server/operation.ts b/dashboard/src/server/operation.ts
new file mode 100644
index 0000000..88a578e
--- /dev/null
+++ b/dashboard/src/server/operation.ts
@@ -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; context: AsyncLocalStorage };
+};
+const queue = state.__craftdeckOperation ??= {
+ tail: Promise.resolve(), context: new AsyncLocalStorage(),
+};
+
+export function withServerOperation(operation: () => Promise): Promise {
+ if (queue.context.getStore()) return operation();
+ const result = queue.tail.then(() => queue.context.run(true, operation));
+ queue.tail = result.catch(() => undefined);
+ return result;
+}
diff --git a/dashboard/src/server/scheduler.ts b/dashboard/src/server/scheduler.ts
index a0cc28f..17db9bb 100644
--- a/dashboard/src/server/scheduler.ts
+++ b/dashboard/src/server/scheduler.ts
@@ -1,10 +1,11 @@
import cron, { type ScheduledTask } from "node-cron";
import { db } from "./db";
import { notify } from "./notify";
-import { powerContainer, execInContainer } from "./docker";
+import { powerContainer } from "./docker";
import { rcon } from "./rcon";
import { say } from "./mc";
import { env } from "./env";
+import { triggerBackup } from "./backups";
/**
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
@@ -47,9 +48,7 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
return "Restarted";
}
case "backup": {
- const res = await execInContainer(env.backupContainer, ["backup", "now"]);
- if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
- return "Backup completed";
+ return await triggerBackup();
}
case "command": {
if (!payload) throw new Error("No command configured");
diff --git a/dashboard/tests/operations.test.ts b/dashboard/tests/operations.test.ts
new file mode 100644
index 0000000..24e1a9e
--- /dev/null
+++ b/dashboard/tests/operations.test.ts
@@ -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(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");
+ });
+});
diff --git a/dashboard/tests/safety.test.ts b/dashboard/tests/safety.test.ts
new file mode 100644
index 0000000..d968492
--- /dev/null
+++ b/dashboard/tests/safety.test.ts
@@ -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");
+ });
+});
diff --git a/dashboard/tests/test_restore.py b/dashboard/tests/test_restore.py
new file mode 100644
index 0000000..497c096
--- /dev/null
+++ b/dashboard/tests/test_restore.py
@@ -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()
diff --git a/dashboard/vitest.config.mts b/dashboard/vitest.config.mts
new file mode 100644
index 0000000..f4f6722
--- /dev/null
+++ b/dashboard/vitest.config.mts
@@ -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"] },
+});
diff --git a/docker-compose.yml b/docker-compose.yml
index 4e59f67..c7925b6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,20 +1,29 @@
name: minecraft
+x-logging: &logging
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
services:
# ── Minecraft server (Fabric via itzg/minecraft-server) ─────────────────
mc:
- image: itzg/minecraft-server:latest
+ image: itzg/minecraft-server:java25@sha256:783d2712019a3996b4168752517a08d3448ef8995879b200316c3888f98dc394
container_name: mc
restart: unless-stopped
+ <<: *logging
stop_grace_period: 2m
tty: false
stdin_open: false
environment:
EULA: "TRUE"
TYPE: ${SERVER_TYPE:-FABRIC}
- VERSION: ${MC_VERSION:-26.2}
+ VERSION: ${MC_VERSION:-26.3}
MEMORY: ${MC_MEMORY:-6G}
- USE_AIKAR_FLAGS: "true"
+ FABRIC_LOADER_VERSION: "0.19.5"
+ FABRIC_LAUNCHER_VERSION: "1.1.2"
# Offline mode + whitelist as the approval gate (see docs/auth-flow.md).
# enforce-secure-profile must be off or offline-launcher players are
@@ -36,35 +45,28 @@ services:
ENABLE_RCON: "true"
RCON_PASSWORD: ${RCON_PASSWORD:?generate with scripts/setup.sh}
- # Server-side mods, auto-resolved for the current MC version on every start.
- # ":beta" allows beta-typed builds for that project (EasyAuth publishes
- # its 26.x builds as beta).
- MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api,easyauth:beta,spark,lithium,ferrite-core}
- MODRINTH_DOWNLOAD_DEPENDENCIES: required
+ # Reviewed exact Modrinth version IDs. Update these with Minecraft.
+ MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB}
+ MODRINTH_DOWNLOAD_DEPENDENCIES: none
# Modpack support (leave empty for lean Fabric; see docs/mods-and-modpacks.md)
MODRINTH_MODPACK: ${MODRINTH_MODPACK:-}
- # Enforce mod configs on every boot (proxy-protocol/TCPShield settings —
- # see docs/tcpshield.md). Mods that rewrite their own config files can't
- # clobber these: patches re-apply before each server start.
- PATCH_DEFINITIONS: /patches/patch-set.json
+
TZ: ${TZ:-UTC}
ports:
- "${MC_PORT:-25565}:25565"
- # BlueMap web map (only active if the bluemap mod is installed)
- - "${MAP_PORT:-8100}:8100"
volumes:
- ./data/mc:/data
- - ./config/patches:/patches:ro
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}
# ── Scheduled world backups (itzg/mc-backup) ─────────────────────────────
backup:
- image: itzg/mc-backup:latest
+ image: itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383
container_name: mc-backup
restart: unless-stopped
+ <<: *logging
environment:
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
INITIAL_DELAY: 10m
@@ -90,6 +92,7 @@ services:
image: minecraft-dashboard:local
container_name: mc-dashboard
restart: unless-stopped
+ <<: *logging
environment:
PANEL_SESSION_SECRET: ${PANEL_SESSION_SECRET:?generate with scripts/setup.sh}
PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-}
@@ -98,10 +101,11 @@ services:
RCON_PASSWORD: ${RCON_PASSWORD}
MC_CONTAINER: mc
BACKUP_CONTAINER: mc-backup
- MC_DATA_DIR: /mc-data
- BACKUPS_DIR: /backups
- APP_DATA_DIR: /app-data
- PROJECT_DIR: /project
+ MC_DATA_DIR: /server-data/mc
+ RESTORE_WORK_DIR: /server-data/restore
+ BACKUPS_DIR: /server-data/backups
+ APP_DATA_DIR: /server-data/dashboard
+ PROJECT_DIR: ${PROJECT_DIR:?Set PROJECT_DIR to the absolute host checkout path}
REGISTRATION_WINDOW_MINUTES: ${REGISTRATION_WINDOW_MINUTES:-2}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
TZ: ${TZ:-UTC}
@@ -110,10 +114,8 @@ services:
- "${DASHBOARD_PORT:-8080}:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- - ./data/mc:/mc-data
- - ./data/backups:/backups
- - ./data/dashboard:/app-data
- - ./:/project
+ - ./data:/server-data
+ - ${PROJECT_DIR:?Set PROJECT_DIR}:${PROJECT_DIR}
mem_limit: 512m
depends_on:
- mc
diff --git a/docs/backups-and-restore.md b/docs/backups-and-restore.md
index 50cb433..4f71419 100644
--- a/docs/backups-and-restore.md
+++ b/docs/backups-and-restore.md
@@ -1,57 +1,19 @@
-# Backups & restore
+# Backups and restore
-## What runs automatically
+The backup sidecar archives server data every 24 hours by default and keeps seven days locally. It flushes the world through RCON first and excludes temporary files, caches and logs. Manual panel backups wait for a newly created archive to pass complete gzip/tar validation. A request acknowledgment alone is not backup success.
-The `backup` sidecar (itzg/mc-backup) tars the entire server data directory to
-`data/backups/world-.tgz`:
+Config and mod Apply require a successful safety backup; failure cancels the operation. Standalone world reset does too. Orion managed mode rejects configuration changes and world reset entirely; upgrades are reviewed in homelab-infra.
-- flushes the world to disk first (`save-all`), safe while players are online
-- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
-- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
-- excludes caches and logs
+## Same-release world restore
-Safety backups are also taken automatically before config applies (when enabled) and
-world resets.
+Use the Backups page, or `scripts/restore.sh /absolute/path/backup.tgz` as the standalone fallback. Orion's fallback takes the extra arguments `/opt/minecraft/project /opt/minecraft/data`.
-## Manual backup
+The panel stops Minecraft and the backup writer, validates archive paths and the managed release lock, extracts a clean staging tree, and moves the existing data into a retained `restore-*/previous` directory. It preserves the bind-mount root and correct server ownership. Files that only existed in the newer world are not left active. On failure the writers remain stopped for inspection. The shell fallback also stops the panel to avoid competing requests.
-- Panel: Backups page → **Back up now**
-- CLI: `docker exec mc-backup backup now`
+Do not overlay an archive with `tar -xzf` onto a populated world. Keep the world and staging workspace under a common parent mount, on the same filesystem. Reserve disk for the active world, staged copy and retained previous data, and remove retained copies only after confirming a successful recovery.
-## Restoring
+## Complete recovery
-Panel: Backups page → **Restore** → type `restore`. The panel stops the server,
-extracts the tar over the data directory, and starts the server again. Everything since
-that backup is lost.
+A world archive contains Minecraft and EasyAuth state. It does not contain CraftDeck's database, deployment config, proxy secret or panel credentials. For Orion use `homelab-infra/stacks/minecraft/cold-backup.sh`: it stops the stack and captures the complete deployment plus an export of the pinned panel image. Copy the secret-bearing backup off-host with private permissions.
-CLI fallback (panel down):
-
-```bash
-./scripts/restore.sh data/backups/world-20260710-050000.tgz
-```
-
-## Off-site copies (recommended)
-
-`data/backups/` is just files — sync it anywhere:
-
-```bash
-# example: nightly rsync to another machine (add to the host's crontab)
-0 6 * * * rsync -a --delete /path/to/minecraft/data/backups/ backup-host:/srv/mc-backups/
-```
-
-You can also download any backup from the panel (⬇ button) before risky experiments.
-
-## Disaster recovery (new machine)
-
-```bash
-git clone 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 .tgz data/backups/
-docker compose up -d # let it initialize once
-./scripts/restore.sh data/backups/.tgz
-```
-
-The backup contains the world, mods, configs, and the EasyAuth database, so player
-registrations survive. Panel state (approval history, metrics) lives in
-`data/dashboard/` — include it in your off-site sync if you want it back too.
+Restore a different release only with its matching complete backup, release lock and image. Keep the failed state recoverable, restore into a clean directory and recreate containers when replacing mount roots. Do not downgrade Minecraft against a newer world. Test the complete restore in isolation before relying on it. See [orion.md](orion.md) and the infrastructure runbook for the release workflow.
diff --git a/docs/networking.md b/docs/networking.md
index 6266a1e..3bd806a 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -54,3 +54,7 @@ setup and add a `caddy` service; the panel works fine behind a reverse proxy
Performance & Lag page. Server-side lag shows as low TPS / high MSPT (affects everyone
equally); player-side lag shows as high ping for that player only. The page gives a
plain-English verdict and per-player ping history so you can settle the argument.
+
+## Orion integration
+
+Orion uses the dedicated managed Compose file in homelab-infra, with TCPShield and OPNsense. Follow [orion.md](orion.md) and [tcpshield.md](tcpshield.md); standalone direct-port examples above do not describe that ingress path.
diff --git a/docs/orion.md b/docs/orion.md
new file mode 100644
index 0000000..a009a7d
--- /dev/null
+++ b/docs/orion.md
@@ -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.
diff --git a/docs/tcpshield.md b/docs/tcpshield.md
index baf154c..b755097 100644
--- a/docs/tcpshield.md
+++ b/docs/tcpshield.md
@@ -1,84 +1,15 @@
-# TCPShield (DDoS protection + real player IPs)
+# TCPShield on Orion
-[TCPShield](https://tcpshield.com) sits in front of the server: players connect to
-your TCPShield domain, and your real server IP stays hidden and DDoS-protected.
-The catch: without extra setup, every connection appears to come from TCPShield's
-proxies, so the Security page, IP bans, and connection logs are useless.
+Use the maintained runbook in `homelab-infra/stacks/minecraft/TCPSHIELD.md` for the exact OPNsense and host firewall steps.
-TCPShield's own RealIP plugin is Bukkit/Bungee/Velocity-only — it does not exist
-for Fabric. The working path for this stack is **PROXY protocol v2** plus the
-**Proxy Protocol Support** Fabric mod.
+Traffic flows: player → TCPShield → OPNsense source-restricted TCP 25565 DNAT → Orion Velocity → private Fabric backend. Enable PROXY protocol in TCPShield and `haproxy-protocol = true` in Velocity. Velocity then uses modern forwarding, with the same secret as `FABRIC_PROXY_SECRET` in FabricProxy-Lite.
-## Setup
+The reviewed deployment pins Minecraft 26.3 and Velocity 4.2.0 build 30. The direct Fabric `proxy-protocol-support` mod did not advertise 26.3 support at review time, so it is not used. The old `config/patches` files are legacy and are no longer mounted by the standalone Compose file. Do not combine that legacy direct-server guide with the Velocity deployment.
-1. **TCPShield panel** → your backend → enable **Proxy Protocol**.
-2. **Mods page** → Recommended → add **Proxy Protocol Support** → Apply
- (or append `proxy-protocol-support` to `MODRINTH_PROJECTS` in `.env` and
- `docker compose up -d mc`).
-3. Nothing else — the mod's configuration is **enforced automatically on every
- boot** by `config/patches/patch-set.json` (see below). Restart once after
- installing so the patch applies:
+OPNsense must preserve the TCPShield source address and permit only the official IPv4 ranges from https://tcpshield.com/v4/. Host DOCKER-USER rules enforce the same restriction before trusted-LAN exceptions. Do not add game port 25565 to unrestricted public ports. Do not expose the backend, RCON, panel or a map port.
- ```bash
- docker compose restart mc
- ```
+The public Minecraft hostname is a DNS-only CNAME to the hostname assigned by TCPShield. An ordinary Cloudflare orange-cloud HTTP proxy is not a Minecraft proxy. Use the public protected hostname even from home; direct LAN joins intentionally fail on the protected proxy port.
-## Verifying it works
+Verify an external join, real player IP, EasyAuth registration, whitelist/UUID continuity and direct-origin rejection before inviting players. Configure TCPShield for the exact protected hostname and backend; account/plan controls and any account-specific verification steps must be completed in its dashboard.
-Watch the startup log (`docker compose logs -f mc`). You must see all three:
-
-```
-Proxy Protocol Support is enabled!
-TCPShield integration enabled! Fetching official IPs...
-Successfully added 2 TCPShield IPs to the trusted proxy list.
-```
-
-Then join through your TCPShield domain — the console shows your **real IP**, and
-`docker compose ps` shows `mc` **healthy**.
-
-## How the config is managed (don't hand-edit!)
-
-The mod rewrites `config/proxy_protocol_support.json` at every startup, which
-silently clobbers hand edits. Instead, the server image applies
-`config/patches/patch-set.json` from this repo **before every server start**,
-enforcing:
-
-| Setting | Value | Why |
-|---|---|---|
-| `enableProxyProtocol` | `true` | decode PROXY protocol headers |
-| `whitelistTCPShieldServers` | `true` | auto-fetch TCPShield's proxy IP ranges — no hardcoding, survives their infra changes |
-| `proxyServerIPs` | `127.0.0.1` | (TCPShield's ranges are added automatically at runtime) |
-| `directAccessIPs` | localhost (`127.0.0.1`, `::1`) + private ranges (`10/8`, `172.16/12`, `192.168/16`) | container healthcheck + LAN/panel access |
-
-To change a value, edit the patch file in the repo and `docker compose up -d mc`.
-
-**First-boot note:** on a brand-new install the mod's config file doesn't exist
-yet when patches run, so the very first boot uses mod defaults; every boot after
-that is patched. One `docker compose restart mc` after first install settles it.
-
-## Security model
-
-PROXY protocol headers carry no signature, so anyone who finds your raw server IP
-could spoof client IPs — the mod prevents this with default-deny:
-
-- PROXY headers are only accepted from **TCPShield's published ranges** (auto-fetched)
-- Direct, header-less connections are only accepted from **localhost + LAN**
-- Everything else → `REJECTED unauthorized direct connection` in the log
-
-So occasional `REJECTED` lines from random public IPs are the wall working —
-that's scanner bots hitting your raw IP and bouncing. A `REJECTED ... /0:0:0:0:0:0:0:1`
-(IPv6 localhost) every few seconds means the container healthcheck is being
-blocked — that's exactly what the `::1` entry in the patch prevents; if you see
-it, the patch didn't apply (check `docker compose logs mc | head -50`).
-
-For belt-and-braces, also enable TCPShield's Advanced Firewall (panel → Network →
-Firewall), and consider host-firewalling port 25565 to TCPShield's ranges
-(`curl tcpshield.com/v4`) once everything works.
-
-## Gotchas
-
-- **Friends must connect via the TCPShield domain** — direct-IP connections from
- outside your LAN are rejected by design once this is active.
-- If real players start bouncing with `REJECTED`, TCPShield may have changed
- their IP ranges *and* the auto-fetch failed at boot — restart the server so it
- re-fetches.
+Sources: [TCPShield documentation](https://docs.tcpshield.com/), [Velocity forwarding](https://docs.papermc.io/velocity/player-information-forwarding/), [OPNsense NAT](https://docs.opnsense.org/manual/nat.html).
diff --git a/docs/updating.md b/docs/updating.md
index 0cbd043..7fd1260 100644
--- a/docs/updating.md
+++ b/docs/updating.md
@@ -1,39 +1,11 @@
-# Updating
+# Reviewed updates
-## Upgrading Minecraft (e.g. 26.2 → 26.3)
+For Orion, edit `homelab-infra/stacks/minecraft/release-lock.json` and Compose together. Verify the Minecraft, Java, Fabric, mod and Velocity compatibility matrix against upstream releases. Build and test the exact CraftDeck commit; record its immutable local image ID. No production setting should use `latest` without an image digest or an unversioned mod slug.
-1. **Configuration** page → change `Minecraft version` → **Save**
-2. **Apply** with *"Take a safety backup first"* enabled
-3. The server container is recreated: the new server jar, a matching Fabric loader, and
- fresh builds of every managed mod are downloaded automatically
+Before an update, make a complete stopped backup with the infrastructure `cold-backup.sh`, copy it off-host, and verify a restore in an isolated instance. Start the candidate on a disposable world and a copy of existing state first. Confirm authentication UUIDs, whitelist, registration, proxy IP forwarding and resource headroom.
-If a managed mod has no build for the new version yet, the server won't start — check
-the Console page for which one, remove it from the Mods page (or wait a few days), and
-Apply again. Worlds upgrade forward automatically; **there is no downgrade** — that's
-what the safety backup is for.
+After approval, stop through Komodo, materialize the reviewed files, preserve the previous project/lock and image, and update the world's `.deployment-lock.json` to the candidate release. Do not use the initial preparation script to overwrite an existing installation. Deploy manually and run the acceptance checks in the infrastructure README.
-Command-line equivalent:
+Rollback means restoring the matching complete data backup, panel database, secrets, proxy configuration and previous image/lock. Downgrading a version setting against a newer world is not a rollback. Managed panel restores deliberately refuse mismatched release locks.
-```bash
-sed -i 's/^MC_VERSION=.*/MC_VERSION=26.3/' .env
-docker exec mc-backup backup now
-docker compose up -d mc
-```
-
-## Updating mods only
-
-Mods page → **Update all & restart**. (Any server restart re-resolves managed mods.)
-
-## Updating the panel / images
-
-```bash
-git pull
-docker compose pull # new itzg server + backup images
-docker compose build dashboard
-docker compose up -d
-```
-
-## Snapshots / release candidates
-
-`MC_VERSION` accepts any version the launcher knows (`26.3-rc-1`, `26.3-snapshot-3`…).
-Mods rarely support snapshots — expect to trim the mod list if you try them.
+For the standalone setup, `PROJECT_DIR` must equal the absolute host checkout. Update the explicit image, Minecraft, loader and Modrinth pins together. Config/mod Apply takes a required backup and aborts on failure. Newly added unversioned mod slugs remain a standalone convenience; pin their reviewed Modrinth version IDs before treating that installation as a locked release.
diff --git a/scripts/restore.sh b/scripts/restore.sh
index 7dcb7a6..d446f03 100755
--- a/scripts/restore.sh
+++ b/scripts/restore.sh
@@ -1,25 +1,29 @@
#!/usr/bin/env bash
-# Manual backup restore (fallback for when the dashboard is unavailable).
-# The dashboard's Backups page does this same procedure with one click.
-#
-# Usage: ./scripts/restore.sh data/backups/world-20260710-120000.tgz
+# Clean restore fallback. Python 3.12+ required. Retains the prior world.
+# Standalone: scripts/restore.sh /absolute/backup.tgz
+# Orion: scripts/restore.sh /absolute/backup.tgz /opt/minecraft/project /opt/minecraft/data
set -euo pipefail
-cd "$(dirname "$0")/.."
-
-archive="${1:-}"
-[[ -n "$archive" && -f "$archive" ]] || {
- echo "Usage: $0 "
- echo "Available backups:"
- ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)"
- exit 1
-}
-
-echo "This will STOP the server and overwrite the current world with:"
-echo " $archive"
-read -r -p "Type 'restore' to continue: " confirm
-[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; }
-
-docker compose stop mc
-tar -xzf "$archive" -C data/mc
-docker compose start mc
-echo "✔ Restore complete — server starting."
+repo=$(cd "$(dirname "$0")/.." && pwd)
+archive=${1:?Supply an existing archive}
+archive=$(realpath "$archive")
+project=${2:-$repo}
+data=${3:-$repo/data}
+[[ -f "$archive" ]] || exit 1
+helper="$repo/dashboard/scripts/restore_data.py"
+python3 "$helper" verify --archive "$archive"
+echo "Restore $archive into $data/mc; retain current data in $data/restore."
+read -r -p "Type 'restore' to stop the stack and continue: " confirm
+[[ "$confirm" == restore ]] || exit 1
+cd "$project"
+export PROJECT_DIR="$project"
+# Stop the panel as well: its in-process queue cannot coordinate with this script.
+running=$(docker compose ps --status running --services)
+docker compose stop dashboard backup mc
+args=(restore --archive "$archive" --data "$data/mc" --work "$data/restore")
+[[ ! -f "$project/release-lock.json" ]] || args+=(--lock-file "$project/release-lock.json")
+# On failure, leave writers stopped for inspection.
+python3 "$helper" "${args[@]}"
+for service in mc backup dashboard; do
+ if [[ $'\n'"$running"$'\n' == *$'\n'"$service"$'\n'* ]]; then docker compose start "$service"; fi
+done
+echo 'Restore complete. Previous data was retained in the restore workspace.'
diff --git a/scripts/setup.sh b/scripts/setup.sh
index c7ea1bd..94f3d29 100755
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -21,7 +21,9 @@ sed -e "s|^RCON_PASSWORD=.*|RCON_PASSWORD=${rcon_pw}|" \
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
.env.example > .env
-mkdir -p data/mc data/backups data/dashboard
+printf '\nPROJECT_DIR=%s\n' "$PWD" >> .env
+chmod 600 .env
+mkdir -p data/mc data/backups data/dashboard data/restore
echo
echo " ✔ .env created with generated secrets"