Pin Minecraft 26.3 and add safe Orion managed deployment support

This commit is contained in:
Aryan Singh committed 2026-09-29 20:02:31 -05:00
1 parent dff2b5a9c4
commit 008120dbad
40 files changed
+1877 -490

No files matched your search

+5 -4
View File
@@ -1,25 +1,25 @@
# syntax=docker/dockerfile:1
FROM node:26-slim AS deps
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:26-slim AS build
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:26-slim AS runtime
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
ARG TARGETARCH=amd64
ARG DOCKER_CLI_VERSION=27.5.1
ARG COMPOSE_VERSION=v2.32.4
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip python3 \
&& rm -rf /var/lib/apt/lists/*
# Docker CLI + compose plugin so the panel can recreate containers after
@@ -46,6 +46,7 @@ ENV NODE_ENV=production \
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY scripts/restore_data.py /app/restore_data.py
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
+1279 -201
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-json": "6.0.2",
@@ -14,7 +16,7 @@
"@codemirror/theme-one-dark": "6.1.3",
"@uiw/react-codemirror": "4.25.11",
"lucide-react": "1.24.0",
"next": "16.2.10",
"next": "16.3.7",
"node-cron": "4.6.0",
"react": "19.2.7",
"react-dom": "19.2.7"
@@ -24,8 +26,9 @@
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"postcss": "8.5.16",
"postcss": "8.5.28",
"tailwindcss": "4.3.2",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "5.0.2"
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Validate a Minecraft archive and replace data contents while preserving the bind root.
The caller MUST stop Minecraft and its backup writer before `restore`.
Only regular files/directories are accepted. No existing data is deleted.
"""
import argparse
import fcntl
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
def members(archive):
result = []
seen = set()
for member in archive.getmembers():
name = PurePosixPath(member.name)
if name.is_absolute() or ".." in name.parts or "\\" in member.name:
raise ValueError("Archive contains an unsafe path")
if not (member.isdir() or member.isfile()):
raise ValueError("Archive links and special files are not supported")
key = str(name)
if key in seen:
raise ValueError("Archive contains duplicate paths")
seen.add(key)
result.append(member)
if not any(m.isfile() and PurePosixPath(m.name).name == "level.dat" for m in result):
raise ValueError("Archive does not contain a Minecraft world (level.dat)")
return result
def verify(archive_path):
# Reading each member also verifies the gzip footer, rather than only headers.
with tarfile.open(archive_path, "r:gz") as archive:
entries = members(archive)
for member in entries:
if member.isfile():
with archive.extractfile(member) as source:
while source.read(1024 * 1024):
pass
import gzip
with gzip.open(archive_path, "rb") as source:
while source.read(1024 * 1024):
pass
return entries
def restore(archive_path, data_path, work_path, lock_file=None):
data = Path(data_path).resolve(strict=True)
work = Path(work_path).resolve()
if data == work or data in work.parents or work in data.parents:
raise ValueError("Restore workspace must be separate from the data tree")
work.mkdir(parents=True, exist_ok=True)
if data.stat().st_dev != work.stat().st_dev:
raise ValueError("Data and restore workspace must be on the same filesystem")
with (work / ".restore.lock").open("a") as operation_lock:
fcntl.flock(operation_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
entries = verify(archive_path)
required = sum(m.size for m in entries if m.isfile())
if shutil.disk_usage(work).free < required + 64 * 1024 * 1024:
raise ValueError("Insufficient free space for a staged restore")
run = Path(tempfile.mkdtemp(prefix="restore-", dir=work))
staged, previous = run / "staged", run / "previous"
staged.mkdir()
previous.mkdir()
with tarfile.open(archive_path, "r:gz") as archive:
archive.extractall(staged, members=members(archive), filter="data")
# tarfile's data filter intentionally discards archive ownership.
# Give the staged tree to the actual server data owner before activation.
owner = data.stat()
if os.geteuid() == 0:
for child in staged.rglob("*"):
os.chown(child, owner.st_uid, owner.st_gid)
elif os.geteuid() != owner.st_uid:
raise ValueError("Restore must run as root or the server data owner")
if lock_file:
marker = staged / ".deployment-lock.json"
if not marker.is_file() or json.loads(marker.read_text()) != json.loads(Path(lock_file).read_text()):
raise ValueError("Backup deployment lock differs. Restore the matching release via the host runbook.")
# Moving children keeps the /data bind mount inode alive in existing containers.
moved_old, moved_new = [], []
try:
for child in data.iterdir():
child.rename(previous / child.name)
moved_old.append(child.name)
for child in staged.iterdir():
child.rename(data / child.name)
moved_new.append(child.name)
except BaseException:
for name in reversed(moved_new):
(data / name).rename(staged / name)
for name in reversed(moved_old):
(previous / name).rename(data / name)
raise
return {"previous_data": str(previous), "restored_from": str(Path(archive_path).resolve())}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["verify", "restore"])
parser.add_argument("--archive", required=True)
parser.add_argument("--data")
parser.add_argument("--work")
parser.add_argument("--lock-file")
args = parser.parse_args()
if args.action == "verify":
verify(args.archive)
print(json.dumps({"valid": True}))
else:
if not args.data or not args.work:
parser.error("restore requires --data and --work")
print(json.dumps(restore(args.archive, args.data, args.work, args.lock_file)))
+5 -2
View File
@@ -39,6 +39,8 @@ const SECTIONS = [
] as const;
export default function ConfigPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment", 0);
const managed = deployment?.managed ?? true;
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
const [values, setValues] = useState<ConfigValues>({});
const [saving, setSaving] = useState(false);
@@ -103,10 +105,10 @@ export default function ConfigPage() {
</p>
</div>
<div className="flex gap-2">
<Button onClick={save} busy={saving} disabled={!dirty}>
<Button onClick={save} busy={saving} disabled={managed || !dirty}>
Save
</Button>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
<Rocket size={14} /> Apply (restarts server)
</Button>
</div>
@@ -128,6 +130,7 @@ export default function ConfigPage() {
<div key={f.key}>
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
<Input
disabled={managed}
value={values[f.key] ?? ""}
onChange={(e) => set(f.key, e.target.value)}
className="w-full font-mono"
+9 -4
View File
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import { api, toast } from "@/lib/api";
import { api, toast, useApi } from "@/lib/api";
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime } from "@/lib/format";
@@ -32,6 +32,8 @@ function langFor(name: string) {
}
export default function FilesPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
const managed = deployment?.managed ?? true;
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -124,10 +126,10 @@ export default function FilesPage() {
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => uploadRef.current?.click()}>
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
<Upload size={13} /> Upload here
</Button>
<Button size="sm" onClick={() => setMkdirOpen(true)}>
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlus size={13} /> New folder
</Button>
</div>
@@ -187,6 +189,7 @@ export default function FilesPage() {
</a>
)}
<button
disabled={managed}
onClick={() => setConfirmDelete(e)}
className="rounded p-1 text-muted hover:text-crit"
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
@@ -213,7 +216,7 @@ export default function FilesPage() {
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={managed || !editing.dirty || editing.truncated}>
<Save size={13} /> Save
</Button>
<Button size="sm" onClick={() => setEditing(null)}>
@@ -223,6 +226,8 @@ export default function FilesPage() {
</header>
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
<CodeMirror
editable={!managed}
readOnly={managed}
value={editing.content}
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
extensions={langFor(editing.path)}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Shell } from "@/components/shell";
import { ManagedNotice } from "@/components/managed-notice";
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return <Shell>{children}</Shell>;
return <Shell><ManagedNotice />{children}</Shell>;
}
+14 -10
View File
@@ -60,15 +60,17 @@ const FEATURED = [
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
},
{
slug: "proxy-protocol-support",
slug: "fabricproxy-lite",
emoji: "🛡",
title: "Proxy Protocol Support",
title: "FabricProxy-Lite",
description:
"Using TCPShield (or another proxy)? This restores players' real IPs so the Security page and IP bans work. Config is auto-enforced — see docs/tcpshield.md.",
"Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
},
];
export default function ModsPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
const managed = deployment?.managed ?? true;
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
@@ -127,10 +129,10 @@ export default function ModsPage() {
<div>
<h1 className="text-xl font-bold">Mods</h1>
<p className="mt-0.5 text-xs text-ink2">
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
{managed ? "Mod versions are locked by the reviewed deployment." : "Pinned Modrinth version IDs remain fixed when the server restarts."}
</p>
</div>
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<Button disabled={managed} variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
</Button>
</div>
@@ -150,7 +152,7 @@ export default function ModsPage() {
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
disabled={managed || already}
busy={busy === `add-${f.slug}`}
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
>
@@ -170,7 +172,7 @@ export default function ModsPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void search()}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.3"}…`}
className="w-full"
/>
<Button onClick={() => void search()} busy={searching}>
@@ -208,7 +210,7 @@ export default function ModsPage() {
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
disabled={managed || already}
busy={busy === `add-${h.slug}`}
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
>
@@ -223,8 +225,7 @@ export default function ModsPage() {
{/* managed list */}
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
<p className="mb-3 text-[11px] leading-relaxed text-muted">
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
newest compatible build on every start, so updating Minecraft also updates these automatically.
Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
</p>
<ul className="space-y-1.5">
{(data?.managed ?? []).map((slug) => (
@@ -240,6 +241,7 @@ export default function ModsPage() {
<Button
size="sm"
variant="danger"
disabled={managed}
busy={busy === `remove-${slug}`}
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
>
@@ -285,6 +287,7 @@ export default function ModsPage() {
<div className="flex justify-end gap-1.5">
<Button
size="sm"
disabled={managed}
busy={busy === `toggle-${m.file}`}
onClick={() =>
void manage(
@@ -300,6 +303,7 @@ export default function ModsPage() {
<Button
size="sm"
variant="danger"
disabled={managed}
busy={busy === `delete-${m.file}`}
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
>
+1 -1
View File
@@ -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 });
});
}
+6 -3
View File
@@ -1,3 +1,5 @@
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
import { safetyBackup } from "@/server/backups";
@@ -5,9 +7,10 @@ import { notify } from "@/server/notify";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
if (backupFirst) await safetyBackup("config apply");
if (service !== "backup" || backupFirst) await safetyBackup("config apply");
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
if (!res.ok) {
notify("error", "Config apply failed", res.output.slice(-800));
@@ -15,5 +18,5 @@ export async function POST(req: NextRequest) {
}
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
return ok({ output: res.output.slice(-1000) });
});
}));
}
@@ -0,0 +1,5 @@
import { env } from "@/server/env";
import { ok } from "@/server/api";
export const dynamic = "force-dynamic";
export function GET() { return ok({ managed: env.managed }); }
+7 -2
View File
@@ -1,10 +1,15 @@
import { safetyBackup } from "@/server/backups";
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { applyMcConfig } from "@/server/compose";
import { 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) });
});
}));
}
+8 -2
View File
@@ -1,3 +1,5 @@
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
@@ -18,7 +20,8 @@ export async function GET() {
/** Danger zone: archive the current world and generate a fresh one. */
export async function POST(req: NextRequest) {
return handle(async () => {
return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
if (op !== "reset") return fail("Unknown operation");
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
@@ -27,6 +30,8 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(worldDir)) return fail("No world folder found");
await safetyBackup("world reset");
const backup = await inspectContainer(env.backupContainer);
if (backup.running) await powerContainer(env.backupContainer, "stop");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
@@ -34,8 +39,9 @@ export async function POST(req: NextRequest) {
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
await powerContainer(env.mcContainer, "start");
if (backup.running) await powerContainer(env.backupContainer, "start");
logActivity("world_reset", null, `old world kept as ${archived}`);
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
return ok({ archived });
});
}));
}
@@ -0,0 +1,11 @@
"use client";
import { useApi } from "@/lib/api";
export function ManagedNotice() {
const { data } = useApi<{ managed: boolean }>("/api/deployment", 0);
if (!data?.managed) return null;
return <div className="mb-4 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm">
Managed deployment: versions, mods and files are read-only here. Update the reviewed
deployment through Komodo. Console, player controls and backups remain available.
</div>;
}
+2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { DockerError } from "./docker";
import { RconError } from "./rcon";
import { ManagedDeploymentError } from "./managed";
/**
* Small helpers for route handlers. Authentication is enforced globally in
@@ -21,6 +22,7 @@ export function handle(fn: () => Promise<NextResponse> | NextResponse) {
try {
return await fn();
} catch (err) {
if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
if (err instanceof DockerError && err.kind === "unavailable") {
return fail("Docker is unreachable from the panel", 503);
}
+52 -29
View File
@@ -2,9 +2,12 @@ import fs from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { execInContainer, powerContainer, inspectContainer } from "./docker";
import { db, logActivity } from "./db";
import { logActivity } from "./db";
import { notify } from "./notify";
import { env } from "./env";
import { withServerOperation } from "./operation";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
.sort((a, b) => b.mtime - a.mtime);
}
export async function triggerBackup(): Promise<string> {
export function triggerBackup(): Promise<string> {
return withServerOperation(triggerBackupUnlocked);
}
async function triggerBackupUnlocked(): Promise<string> {
const before = new Set(listBackups().map((b) => b.name));
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
if (res.exitCode !== 0) {
notify("error", "Backup failed", res.output.slice(-800));
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
}
const created = listBackups().find((b) => !before.has(b.name));
logActivity("backup", null, created?.name ?? "manual backup");
notify("success", "Backup completed", created ? created.name : "", { discord: false });
return created?.name ?? "backup completed";
// Some sidecar versions acknowledge the request before writing the archive.
// Wait for a new complete gzip/tar, never treat the request as a finished backup.
const deadline = Date.now() + 30 * 60 * 1000;
while (Date.now() < deadline) {
for (const created of listBackups().filter((b) => !before.has(b.name) && b.size > 0)) {
try {
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", backupPath(created.name)],
{ timeout: 10 * 60 * 1000, maxBuffer: 1024 * 1024 });
logActivity("backup", null, created.name);
notify("success", "Backup completed", created.name, { discord: false });
return created.name;
} catch { /* Still being written, or invalid: do not report success. */ }
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error("No new complete Minecraft backup appeared within 30 minutes");
}
export async function restoreBackup(name: string): Promise<void> {
const file = backupPath(name);
if (!fs.existsSync(file)) throw new Error("Backup file not found");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
await new Promise<void>((resolve, reject) => {
execFile(
"tar",
["-xpzf", file, "-C", env.mcDataDir],
{ timeout: 30 * 60 * 1000 },
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
);
export function restoreBackup(name: string): Promise<void> {
return withServerOperation(async () => {
const file = backupPath(name);
if (!fs.existsSync(file)) throw new Error("Backup file not found");
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
{ timeout: 30 * 60 * 1000 });
const [mc, backup] = await Promise.all([
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
]);
if (backup.running) await powerContainer(env.backupContainer, "stop");
if (mc.running) await powerContainer(env.mcContainer, "stop");
// Failure deliberately leaves the writers stopped for inspection.
const args = [env.restoreHelper, "restore", "--archive", file,
"--data", env.mcDataDir, "--work", env.restoreWorkDir];
if (env.managed) args.push("--lock-file", path.join(env.projectDir, "release-lock.json"));
const result = await execFileAsync("python3", args, { timeout: 30 * 60 * 1000 });
if (mc.running) await powerContainer(env.mcContainer, "start");
if (backup.running) await powerContainer(env.backupContainer, "start");
logActivity("restore", null, `${name}: ${result.stdout.trim()}`);
notify("success", "Backup restored", "Previous data retained in the restore workspace.");
});
await powerContainer(env.mcContainer, "start");
logActivity("restore", null, name);
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
}
export function deleteBackup(name: string): void {
fs.unlinkSync(backupPath(name));
logActivity("backup_delete", null, name);
export function deleteBackup(name: string): Promise<void> {
return withServerOperation(async () => {
fs.unlinkSync(backupPath(name));
logActivity("backup_delete", null, name);
});
}
export function backupsDirSize(): number {
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise<void> {
try {
await triggerBackup();
} catch (err) {
db();
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
throw err;
}
}
+8 -4
View File
@@ -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). */
+7 -1
View File
@@ -1,5 +1,6 @@
import http from "node:http";
import { env } from "./env";
import { withServerOperation } from "./operation";
/**
* Minimal Docker Engine API client over the unix socket (no dependencies).
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise<Record<string, string>
export type PowerAction = "start" | "stop" | "restart" | "kill";
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
return withServerOperation(() => powerContainerUnlocked(name, action));
}
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
method: "POST",
@@ -292,5 +297,6 @@ export async function execInContainer(
});
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
return { exitCode: info.ExitCode ?? 0, output };
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
return { exitCode: info.ExitCode, output };
}
+5
View File
@@ -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" : ""),
+6 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
/**
* Read/write the project .env file while preserving comments and ordering.
@@ -18,16 +19,20 @@ export function readEnvFile(): Record<string, string> {
try {
text = fs.readFileSync(envFilePath(), "utf8");
} catch {
return out;
return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : out;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
if (m) out[m[1]] = m[2].trim();
}
if (env.managed) {
for (const key of EDITABLE_ENV_KEYS) out[key] = process.env[key] ?? out[key] ?? "";
}
return out;
}
export function writeEnvFile(updates: Record<string, string>): void {
assertDeploymentEditable();
const file = envFilePath();
let lines: string[];
try {
+5
View File
@@ -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);
+11
View File
@@ -0,0 +1,11 @@
import { env } from "./env";
export class ManagedDeploymentError extends Error {
constructor() {
super("This deployment is managed by Komodo. Change its reviewed configuration in homelab-infra.");
}
}
export function assertDeploymentEditable(): void {
if (env.managed) throw new ManagedDeploymentError();
}
+4 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { readEnvFile, writeEnvFile } from "./envfile";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
/**
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
@@ -24,7 +25,7 @@ export type ModSearchResult = {
};
export function currentMcVersion(): string {
return readEnvFile()["MC_VERSION"] || "26.2";
return readEnvFile()["MC_VERSION"] || "26.3";
}
export async function searchMods(query: string): Promise<ModSearchResult[]> {
@@ -143,11 +144,13 @@ function safeModFile(file: string): string {
}
export function toggleMod(file: string): void {
assertDeploymentEditable();
const full = safeModFile(file);
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
fs.renameSync(full, target);
}
export function deleteModFile(file: string): void {
assertDeploymentEditable();
fs.unlinkSync(safeModFile(file));
}
+16
View File
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
// One reentrant queue for power, backup, restore and Compose operations.
const state = globalThis as unknown as {
__craftdeckOperation?: { tail: Promise<unknown>; context: AsyncLocalStorage<boolean> };
};
const queue = state.__craftdeckOperation ??= {
tail: Promise.resolve(), context: new AsyncLocalStorage<boolean>(),
};
export function withServerOperation<T>(operation: () => Promise<T>): Promise<T> {
if (queue.context.getStore()) return operation();
const result = queue.tail.then(() => queue.context.run(true, operation));
queue.tail = result.catch(() => undefined);
return result;
}
+3 -4
View File
@@ -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");
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { withServerOperation } from "../src/server/operation";
describe("server operation queue", () => {
it("serializes destructive operations while allowing nested backups", async () => {
const events: string[] = [];
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const first = withServerOperation(async () => {
events.push("restore-start");
await withServerOperation(async () => { events.push("nested-backup"); });
await gate;
events.push("restore-finish");
});
const second = withServerOperation(async () => { events.push("power"); });
await new Promise(resolve => setImmediate(resolve));
expect(events).toEqual(["restore-start", "nested-backup"]);
release();
await Promise.all([first, second]);
expect(events).toEqual(["restore-start", "nested-backup", "restore-finish", "power"]);
});
it("a failure does not poison later operations", async () => {
await expect(withServerOperation(async () => { throw new Error("failed"); })).rejects.toThrow("failed");
await expect(withServerOperation(async () => "next")).resolves.toBe("next");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("../src/server/env", () => ({ env: { managed: false, backupsDir: "/missing-test-backups", backupContainer: "backup", projectDir: "/missing-project" } }));
vi.mock("../src/server/docker", () => ({
execInContainer: vi.fn(async () => ({ exitCode: 1, output: "backup disk full" })),
powerContainer: vi.fn(), inspectContainer: vi.fn(), DockerError: class extends Error {},
}));
vi.mock("../src/server/db", () => ({ logActivity: vi.fn() }));
vi.mock("../src/server/notify", () => ({ notify: vi.fn() }));
vi.mock("../src/server/compose", () => ({ applyMcConfig: vi.fn(), applyBackupConfig: vi.fn() }));
import { env } from "../src/server/env";
import { safetyBackup } from "../src/server/backups";
import { execInContainer } from "../src/server/docker";
import { applyMcConfig } from "../src/server/compose";
import { writeEnvFile } from "../src/server/envfile";
import { writeTextFile, writeBinaryFile, deletePath, makeDir } from "../src/server/files";
import { POST } from "../src/app/api/config/apply/route";
import { POST as applyMods } from "../src/app/api/mods/apply/route";
beforeEach(() => { env.managed = false; vi.clearAllMocks(); });
describe("required backups and managed deployment", () => {
it("does not swallow a failed safety backup", async () => {
await expect(safetyBackup("upgrade")).rejects.toThrow("disk full");
});
it("aborts a config apply even when a client asks to skip backup", async () => {
const response = await POST(new NextRequest("http://localhost/api/config/apply", {
method: "POST", body: JSON.stringify({ backupFirst: false }),
}));
expect(response.status).toBe(500);
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("also aborts mod apply after a failed backup", async () => {
expect((await applyMods()).status).toBe(500);
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("rejects managed config apply before executing anything", async () => {
env.managed = true;
const response = await POST(new NextRequest("http://localhost/api/config/apply", { method: "POST", body: "{}" }));
expect(response.status).toBe(409);
expect(execInContainer).not.toHaveBeenCalled();
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("guards direct config and file mutation helpers", () => {
env.managed = true;
for (const change of [
() => writeEnvFile({ MC_VERSION: "latest" }),
() => writeTextFile("server.properties", "bad"),
() => writeBinaryFile("mods/unreviewed.jar", Buffer.from("bad")),
() => deletePath("world"), () => makeDir("mods"),
]) expect(change).toThrow("managed by Komodo");
});
});
+76
View File
@@ -0,0 +1,76 @@
import importlib.util
import io
import json
from pathlib import Path
import tarfile
import tempfile
import unittest
spec = importlib.util.spec_from_file_location("restore_data", Path(__file__).parents[1] / "scripts/restore_data.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class RestoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.data = self.root / "data"
self.data.mkdir()
(self.data / "newer-only.dat").write_text("preserve me")
self.archive = self.root / "backup.tgz"
def make_archive(self, files, link=False):
with tarfile.open(self.archive, "w:gz") as tar:
for name, content in files.items():
info = tarfile.TarInfo(name)
payload = content.encode()
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
if link:
info = tarfile.TarInfo("world/escape")
info.type, info.linkname = tarfile.SYMTYPE, "/etc"
tar.addfile(info)
def test_clean_restore_keeps_previous_and_bind_root(self):
self.make_archive({"./world/level.dat": "old world"})
inode = self.data.stat().st_ino
result = module.restore(self.archive, self.data, self.root / "restore")
self.assertEqual(self.data.stat().st_ino, inode)
self.assertFalse((self.data / "newer-only.dat").exists())
self.assertEqual((self.data / "world/level.dat").read_text(), "old world")
self.assertEqual((Path(result["previous_data"]) / "newer-only.dat").read_text(), "preserve me")
def test_traversal_rejected_before_mutation(self):
self.make_archive({"world/level.dat": "old", "../outside": "bad"})
with self.assertRaises(ValueError): module.restore(self.archive, self.data, self.root / "restore")
self.assertTrue((self.data / "newer-only.dat").exists())
def test_link_rejected(self):
self.make_archive({"world/level.dat": "old"}, link=True)
with self.assertRaises(ValueError): module.verify(self.archive)
def test_release_mismatch_keeps_current_world(self):
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": '{"minecraft":"26.2"}'})
lock = self.root / "lock.json"
lock.write_text('{"minecraft":"26.3"}')
with self.assertRaisesRegex(ValueError, "lock differs"):
module.restore(self.archive, self.data, self.root / "restore", lock)
self.assertTrue((self.data / "newer-only.dat").exists())
def test_matching_release_can_restore(self):
lock = self.root / "lock.json"
lock.write_text('{"minecraft":"26.3"}')
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": lock.read_text()})
module.restore(self.archive, self.data, self.root / "restore", lock)
self.assertTrue((self.data / "world/level.dat").exists())
def test_truncated_archive_rejected(self):
self.make_archive({"world/level.dat": "old"})
self.archive.write_bytes(self.archive.read_bytes()[:-12])
with self.assertRaises((EOFError, tarfile.TarError)):
module.verify(self.archive)
if __name__ == "__main__": unittest.main()
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: { environment: "node", include: ["tests/**/*.test.ts"] },
});