59 changed files with 6101 additions and 494 deletions

No files matched your search

+6 -5
View File
@@ -6,7 +6,7 @@
# ── Minecraft ────────────────────────────────────────────────────────────
# Game version. Change and re-run `docker compose up -d mc` to upgrade.
MC_VERSION=26.2
MC_VERSION=26.3
# Loader: FABRIC (default), NEOFORGE, FORGE, VANILLA, PAPER, ...
SERVER_TYPE=FABRIC
# JVM heap for the server. Host has 10 GB → 6G heap / 7g container limit.
@@ -25,10 +25,8 @@ MC_SIMULATION_DISTANCE=8
MC_SEED=
# Server-side mods (Modrinth project slugs, comma-separated).
# Auto-resolved to the newest compatible release on every container start.
# Append ":beta" to allow beta-typed builds for a project (EasyAuth ships
# its 26.x builds as beta on Modrinth).
MODRINTH_PROJECTS=fabric-api,easyauth:beta,spark,lithium,ferrite-core
# Exact version IDs are locked. Review all IDs before upgrading Minecraft.
MODRINTH_PROJECTS=fabric-api:bNnaTiuM,easyauth:Qd0CWVQP,spark:e3hsPc1o,lithium:xS0Q8LSi,ferrite-core:d5ddUdiB
# To run a full modpack instead, set its Modrinth slug (e.g. adrenaline)
# and see docs/mods-and-modpacks.md before enabling.
MODRINTH_MODPACK=
@@ -40,6 +38,9 @@ PANEL_SESSION_SECRET=CHANGE_ME
# you are forced to change it in the UI afterwards.
PANEL_ADMIN_PASSWORD=CHANGE_ME
# Absolute checkout path on the Docker host (setup.sh fills this in).
PROJECT_DIR=
# ── Dashboard ────────────────────────────────────────────────────────────
DASHBOARD_PORT=8080
# Minutes an approved player has to /register before approval expires
+4
View File
@@ -12,3 +12,7 @@ dashboard/next-env.d.ts
# OS noise
.DS_Store
# Local validation artifacts
__pycache__/
*.tsbuildinfo
+12 -4
View File
@@ -1,6 +1,8 @@
# Minecraft 26.2 Fabric Server + CraftDeck Panel
# Minecraft 26.3 Fabric Server + CraftDeck Panel
A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
For Orion, use the managed deployment in **homelab-infra**, described in [docs/orion.md](docs/orion.md). The root Compose file is the standalone LAN setup; it is not the TCPShield ingress configuration.
A production-quality, self-hosted Minecraft **Java 26.3** server for friends:
- **Fabric** loader with invisible server-side performance mods — any vanilla client
(including TLauncher) can join, no client mods needed
@@ -9,11 +11,14 @@ A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
- **CraftDeck**, a full web management panel: live console, players & stats, lag
diagnostics, performance graphs, mod manager with Modrinth search, backups & restore,
file browser/editor, config editor, scheduled tasks, Discord alerts
- **Worlds & Network**: isolated concurrent server profiles, Velocity lobby routing,
Paper/Folia and modded runtimes, live map integrations, resource pack settings,
per-server consoles/files and verified backup/restore — [guide](docs/worlds-and-network.md)
- **Automatic backups** with retention, **one-command deployment**, everything in Docker
| | |
|---|---|
| Minecraft | `26.2` (change one line to upgrade) |
| Minecraft | `26.3` (review loader and all mod pins together) |
| Loader | Fabric (NeoForge/Forge/vanilla supported via config) |
| Server mods | Fabric API, EasyAuth, spark, Lithium, FerriteCore — auto-updated |
| Panel | Next.js, port `8080`, LAN/VPN only |
@@ -65,6 +70,9 @@ they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
## Documentation
- [docs/worlds-and-network.md](docs/worlds-and-network.md) — multiple servers, lobby routing, maps and resource packs
- [docs/apple-container.md](docs/apple-container.md) — native Mac tests and x86_64 image builds with Apple’s container CLI
- [docs/auth-flow.md](docs/auth-flow.md) — how the approval/registration system works
- [docs/mods-and-modpacks.md](docs/mods-and-modpacks.md) — adding mods, switching to modpacks or other loaders
- [docs/updating.md](docs/updating.md) — upgrading Minecraft, mods, and the panel
@@ -76,7 +84,7 @@ they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
```
docker-compose.yml
├── mc itzg/minecraft-server (Fabric 26.2, offline mode, whitelist enforced,
├── mc itzg/minecraft-server (Fabric 26.3, offline mode, whitelist enforced,
│ RCON on the internal network only, mods resolved from Modrinth at start)
├── backup itzg/mc-backup (scheduled world tars + retention pruning)
└── dashboard ./dashboard (CraftDeck: Next.js + node:sqlite; talks to the
+2
View File
@@ -2,3 +2,5 @@ node_modules
.next
Dockerfile
.dockerignore
dev-data
*.tsbuildinfo
+6 -5
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
ARG TARGETARCH=amd64
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
ARG TARGETARCH
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 \
+1296 -202
View File
File diff suppressed because it is too large. Load diff
+9 -5
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,18 +16,20 @@
"@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"
"react-dom": "19.2.7",
"yaml": "2.9.1"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.2",
"@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"
+262
View File
@@ -0,0 +1,262 @@
import Link from "next/link";
import {
ArrowUpRight,
Boxes,
Cpu,
Globe2,
Shield,
Sparkles,
Wrench,
} from "lucide-react";
import { Badge, Card } from "@/components/ui";
const groups = [
{
title: "Worlds & networks",
icon: Globe2,
items: [
[
"Independent world servers",
"Built in",
"Concurrent profiles with separate seeds, versions, Java runtimes, mods, configs, player data, and backups.",
],
[
"Lobby & server switching",
"Built in",
"Velocity gateway, default lobby, fallback routes, and hostname routing for Paper/Folia. Use /server to switch compatible servers.",
],
[
"Several worlds inside one server",
"Addon",
"Multiverse-style plugins or modded dimensions. These share one runtime, so use separate profiles for incompatible modpacks.",
],
[
"Cross-server inventories & chat",
"Addon",
"A compatible sync plugin and shared database can connect inventories, economies, permissions, and chat. Decide what each game mode should share.",
],
],
},
{
title: "Performance & scale",
icon: Cpu,
items: [
[
"Region-based ticking",
"Runtime option",
"Folia runs independent regions in parallel. It needs suitable hardware and Folia-aware plugins; crowded regions can still lag.",
],
[
"Fabric / Forge / NeoForge / Quilt",
"Built in",
"Dedicated runtime profiles preserve mod compatibility. Match the Java release and client modpack to the selected server version.",
],
[
"Profiling & tuning",
"Primary panel",
"Performance charts and spark diagnostics remain in the primary server panel. Tune view/simulation distances per network profile.",
],
[
"Chunk pregeneration",
"Addon",
"Pregenerate a bounded play area with a compatible tool to reduce exploration spikes. Budget disk space and schedule it outside peak hours.",
],
],
},
{
title: "Maps & client experience",
icon: Sparkles,
items: [
[
"BlueMap / Dynmap",
"Integration",
"Compatible release resolution, isolated renderer ports, and an embedded viewer for your HTTPS map URL. Rendering runs on the server.",
],
[
"Resource pack downloads",
"Built in",
"Per-server ZIP URL, SHA-1, download prompt, and required/optional mode. Textures, models, sounds, and fonts; client mods are separate.",
],
[
"Claims, towns & economies",
"Addon",
"Install compatible protection, town, economy, shop, and map-marker plugins. Plugin compatibility depends on the runtime and version.",
],
[
"Bedrock crossplay",
"Addon",
"Geyser and optionally Floodgate can bridge supported clients. Configure identity handling and a separate UDP port; this panel does not deploy it automatically.",
],
],
},
{
title: "Safety & operations",
icon: Shield,
items: [
[
"Backups & recovery",
"Built in",
"Per-server backup sidecars, retention policies, verified cold snapshots, and staged restores that retain previous data.",
],
[
"Access & isolation",
"Built in",
"New profiles require verified accounts and a whitelist. Proxy backends are private and use a forwarding secret. Primary offline authentication remains separate.",
],
[
"Console & file management",
"Built in",
"Profile-specific commands, log tails, stopped-server config editing, mod/plugin uploads, and datapack uploads.",
],
[
"Remote backups & disaster recovery",
"External setup",
"Replicate archives to another host or object store and test restores. Local backups do not survive loss of the host disk.",
],
],
},
{
title: "Gameplay extensions",
icon: Boxes,
items: [
[
"Custom terrain & dimensions",
"Addon",
"World-generation datapacks and mods, structure packs, biomes, and dimensions. Install generation content before exploring new terrain.",
],
[
"Minigames & events",
"Addon",
"Create separate arena profiles for PvP, parkour, creative, adventure, or seasonal worlds and route through the gateway.",
],
[
"Voice chat & Discord",
"Addon",
"Compatible voice mods need an extra UDP port. The primary panel already supports Discord operational alerts; in-game bridges need plugins.",
],
[
"Permissions, moderation & rollback",
"Addon",
"Use compatible permissions, audit, anti-cheat, and rollback tools. Folia requires explicit support; a Paper plugin label is insufficient.",
],
],
},
{
title: "Advanced infrastructure",
icon: Wrench,
items: [
[
"Custom domains & ingress",
"External setup",
"DNS/SRV records, HTTPS map reverse proxies, and Minecraft-aware TCP protection can provide a consistent entry point.",
],
[
"Version translation",
"Addon",
"ViaVersion-family plugins translate supported protocol differences. They cannot make incompatible client modpacks interchangeable.",
],
[
"Multiple physical hosts",
"External setup",
"A private network and per-host orchestration can spread servers across machines. This implementation manages one Docker host.",
],
[
"Maintenance & automation",
"Primary panel",
"The existing scheduler, metrics, notifications, and access flows target the primary server. Network profiles have independent automatic backups.",
],
],
},
];
export default function FeaturesPage() {
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
CraftDeck / Possibilities
</div>
<h1 className="text-2xl font-bold">Build the server you want.</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-ink2">
A practical capability guide, from a small survival world to a network
of game modes. The labels show what this panel manages and what needs
additional software or infrastructure.
</p>
<Link
href="/network"
className="mt-4 inline-flex items-center gap-2 text-sm text-accent"
>
Open your network
<ArrowUpRight size={15} />
</Link>
</div>
<div className="grid gap-5 lg:grid-cols-2">
{groups.map((g) => (
<Card
key={g.title}
title={
<span className="flex items-center gap-2">
<g.icon size={16} className="text-accent" />
{g.title}
</span>
}
>
<div className="space-y-5">
{g.items.map(([title, status, body]) => (
<div key={title}>
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium">{title}</h3>
<Badge
color={
status === "Built in"
? "good"
: status === "Addon"
? "muted"
: "info"
}
>
{status}
</Badge>
</div>
<p className="text-xs leading-relaxed text-ink2">{body}</p>
</div>
))}
</div>
</Card>
))}
</div>
<p className="text-xs text-muted">
Compatibility changes by Minecraft release. Verify the exact mod/plugin
versions and test changes on a fresh profile before moving players.
</p>
<div className="flex flex-wrap gap-4 text-xs text-accent">
<a
href="https://docs.papermc.io/folia/"
target="_blank"
rel="noreferrer"
>
Folia documentation ↗
</a>
<a
href="https://docs.papermc.io/velocity/server-compatibility/"
target="_blank"
rel="noreferrer"
>
Velocity compatibility ↗
</a>
<a
href="https://bluemap.bluecolored.de/wiki/"
target="_blank"
rel="noreferrer"
>
BlueMap documentation ↗
</a>
<a
href="https://docker-minecraft-server.readthedocs.io/"
target="_blank"
rel="noreferrer"
>
Runtime documentation ↗
</a>
</div>
</div>
);
}
+9 -4
View File
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { 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>;
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Map, ExternalLink, RefreshCw, Layers } from "lucide-react";
import { useApi } from "@/lib/api";
import { Badge, Button, Card, EmptyState, Select } from "@/components/ui";
import type { NetworkView } from "@/lib/network";
export default function MapsPage() {
const { data, error, loading } = useApi<NetworkView>("/api/network", 15000);
const [id, setId] = useState("");
const [reload, setReload] = useState(0);
const maps = data?.profiles.filter((p) => p.map !== "none") ?? [];
const selected = maps.find((p) => p.id === id) ?? maps[0];
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
CraftDeck / Atlas
</div>
<h1 className="text-2xl font-bold">Live world maps</h1>
<p className="mt-2 text-sm text-ink2">
Explore terrain, follow players, and view the overlays supplied by
your map renderer.
</p>
</div>
<Link href="/network" className="text-sm text-accent">
Configure maps →
</Link>
</div>
{error && (
<p role="alert" className="text-sm text-warn">
{error}
</p>
)}
{loading && <p className="text-sm text-muted">Loading maps…</p>}
{!!maps.length && (
<div className="flex flex-wrap items-center gap-3">
<Layers size={16} className="text-accent" />
<Select
value={selected?.id ?? ""}
onChange={setId}
options={maps.map((p) => ({ value: p.id, label: p.name }))}
/>
<Badge color="info">
{selected?.map === "bluemap"
? "BlueMap · 3D"
: "Dynmap · 2D / isometric"}
</Badge>
<span className="text-xs text-muted">
{selected?.version} · {selected?.loader}
</span>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={() => setReload((x) => x + 1)}>
<RefreshCw size={13} />
Reload
</Button>
{selected?.mapUrl && (
<a
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1 text-xs"
href={selected.mapUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink size={13} />
Open full map
</a>
)}
</div>
</div>
)}
{selected?.mapUrl ? (
<>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<iframe
key={`${selected.id}-${reload}`}
title={`${selected.name} live map`}
src={selected.mapUrl}
className="h-[70vh] min-h-96 w-full"
sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-popups"
referrerPolicy="no-referrer"
allowFullScreen
/>
</div>
<p className="text-xs text-muted">
The map is served by {selected.map}. If the frame is blank, open the
full map and check HTTPS, renderer startup, reverse proxy settings,
and framing policy. A loaded frame alone does not verify renderer
health.
</p>
</>
) : (
<Card>
<EmptyState
icon={<Map size={40} />}
title={
selected
? "Connect this map to its web address"
: "Your worlds, from a different perspective"
}
hint={
selected
? `Publish loopback port ${selected.mapPort} through an HTTPS reverse proxy, then save the browser URL in this server’s Live web map settings.`
: "Create a world server with BlueMap or Dynmap, deploy a compatible renderer, and add its browser URL."
}
/>
</Card>
)}
<div className="grid gap-4 md:grid-cols-3">
{[
{
title: "Terrain & dimensions",
body: "The renderer builds real tiles or 3D geometry from your server’s worlds. Initial rendering takes time and uses CPU, disk, and memory.",
},
{
title: "Live player locations",
body: "Enable player visibility in the renderer and configure permissions. Players, caves, and hidden areas follow its privacy settings.",
},
{
title: "Claims & markers",
body: "Claim boundaries need a compatible integration for your claims plugin. Configure the addon on the server; its overlays appear here automatically.",
},
].map((x) => (
<Card key={x.title} title={x.title}>
<p className="text-xs leading-relaxed text-ink2">{x.body}</p>
</Card>
))}
</div>
</div>
);
}
+14 -10
View File
@@ -60,15 +60,17 @@ const FEATURED = [
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
},
{
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`)}
>
File diff suppressed because it is too large. Load diff
+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) });
});
}));
}
@@ -0,0 +1,88 @@
import fs from "node:fs";
import path from "node:path";
import { NextRequest } from "next/server";
import { ok, fail, handle, readJson } from "@/server/api";
import { assertDeploymentEditable } from "@/server/managed";
import { readNetwork, profileById } from "@/server/network-config";
import { networkFilePath } from "@/server/network-files";
import { assertNetworkIdle } from "@/server/network";
import { inspectContainer } from "@/server/docker";
import { withServerOperation } from "@/server/operation";
import { serverName } from "@/server/network-render";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return handle(async () => {
const p = profileById(readNetwork(), req.nextUrl.searchParams.get("id"));
const relative = req.nextUrl.searchParams.get("path") || "";
const file = networkFilePath(p.id, relative);
if (fs.statSync(file).isDirectory())
return ok({
entries: fs
.readdirSync(file, { withFileTypes: true })
.map((e) => ({
name: e.name,
directory: e.isDirectory(),
link: e.isSymbolicLink(),
})),
});
if (fs.statSync(file).size > 2 * 1024 * 1024)
return fail("File exceeds the 2 MB editor limit");
const data = fs.readFileSync(file);
if (data.includes(0)) return fail("Binary files cannot be edited as text");
return ok({ content: data.toString("utf8") });
});
}
export async function POST(req: NextRequest) {
return handle(() =>
withServerOperation(async () => {
assertDeploymentEditable();
assertNetworkIdle();
const isUpload = req.headers
.get("content-type")
?.includes("multipart/form-data");
if (Number(req.headers.get("content-length") || 0) > 100 * 1024 * 1024)
return fail("Upload limit is 100 MB");
const form = isUpload ? await req.formData() : null;
const body = form
? { id: form.get("id"), path: form.get("path"), content: undefined }
: await readJson<{ id: string; path: string; content: string }>(req);
const p = profileById(readNetwork(), body.id);
if ((await inspectContainer(serverName(p.id))).running)
return fail("Stop this server before editing or uploading files", 409);
const file = networkFilePath(p.id, body.path as string);
// Terrain, player state, executable scripts and registry files are not text-editable.
const allowed = /\.(json|ya?ml|toml|conf|properties|txt|cfg)$/i;
let data: Buffer;
if (form) {
const upload = form.get("file");
if (!(upload instanceof File) || upload.size > 100 * 1024 * 1024)
return fail("Choose a file under 100 MB");
const rel = path.relative(networkFilePath(p.id, ""), file);
if (
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) &&
!/^world\/datapacks\/[^/]+\.zip$/.test(rel)
)
return fail(
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
);
if (fs.existsSync(file))
return fail("A file already exists with this name");
data = Buffer.from(await upload.arrayBuffer());
} else {
if (
!allowed.test(file) ||
typeof body.content !== "string" ||
Buffer.byteLength(body.content) > 2 * 1024 * 1024
)
return fail("Choose a supported configuration file under 2 MB");
data = Buffer.from(body.content);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data, { flag: form ? "wx" : "w" });
try {
fs.chownSync(file, 1000, 1000);
} catch {}
return ok({ saved: true });
}),
);
}
+162
View File
@@ -0,0 +1,162 @@
import { NextRequest } from "next/server";
import { ok, fail, handle, readJson } from "@/server/api";
import { env } from "@/server/env";
import { readEnvFile } from "@/server/envfile";
import { assertDeploymentEditable } from "@/server/managed";
import { inspectContainer, powerContainer } from "@/server/docker";
import {
readNetwork,
writeNetwork,
validateProfile,
validateProxy,
profileById,
NetworkInputError,
} from "@/server/network-config";
import { serverName, proxyName } from "@/server/network-render";
import {
networkBusy,
assertNetworkIdle,
reconcileJobs,
checkPorts,
queueNetworkJob,
deployProfile,
deployProxy,
stopProfile,
restartProfile,
snapshotProfile,
restoreProfile,
profileConsole,
profileLogs,
profileBackups,
} from "@/server/network";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function networkHandle(fn: () => Promise<ReturnType<typeof ok>>) {
return handle(async () => {
try {
return await fn();
} catch (e) {
if (e instanceof NetworkInputError) return fail(e.message, 400);
throw e;
}
});
}
export async function GET(req: NextRequest) {
return networkHandle(async () => {
const config = reconcileJobs(readNetwork());
const id = req.nextUrl.searchParams.get("id");
const kind = req.nextUrl.searchParams.get("kind");
if (id) {
profileById(config, id);
if (kind === "logs") return ok(await profileLogs(id));
if (kind === "backups") return ok(profileBackups(id));
}
const entries = await Promise.all(
[
...config.profiles.map((p) => [p.id, serverName(p.id)]),
["proxy", proxyName],
].map(async ([id, name]) => {
try {
return [id, await inspectContainer(name)] as const;
} catch (e) {
return [
id,
{
exists: false,
running: false,
status: "unavailable",
error: (e as Error).message,
},
] as const;
}
}),
);
return ok({
...config,
managed: env.managed,
runtime: env.containerRuntime,
states: Object.fromEntries(entries),
primaryPort: Number(readEnvFile().MC_PORT || 25565),
busy: networkBusy(),
});
});
}
export async function POST(req: NextRequest) {
return networkHandle(async () => {
assertDeploymentEditable();
assertNetworkIdle();
const body = await readJson<Record<string, unknown>>(req);
if (!body || typeof body !== "object") return fail("Invalid request");
const config = readNetwork();
if (body.op === "save") {
const existing = body.id ? profileById(config, body.id) : undefined;
if (!existing && config.profiles.length >= 32)
return fail("Maximum 32 profiles per panel");
const p = validateProfile(body.profile, existing);
config.profiles = [...config.profiles.filter((s) => s.id !== p.id), p];
if (!config.proxy.lobby && p.route === "proxy") config.proxy.lobby = p.id;
checkPorts(config);
writeNetwork(config);
return ok(p);
}
if (body.op === "proxy-save") {
config.proxy = validateProxy(body.proxy, config);
checkPorts(config);
writeNetwork(config);
return ok(config.proxy);
}
if (body.op === "proxy-apply") {
if (body.confirm !== "apply proxy")
return fail(
"Confirm applying the proxy; existing connections will close",
);
return ok(queueNetworkJob("proxy", "apply proxy", deployProxy));
}
if (body.op === "proxy-stop") {
if (body.confirm !== "stop proxy")
return fail("Confirm stopping the gateway");
return ok(
queueNetworkJob("proxy", "stop proxy", async () => {
if ((await inspectContainer(proxyName)).running)
await powerContainer(proxyName, "stop");
return "Gateway stopped. World servers and their data remain available.";
}),
);
}
const p = profileById(config, body.id);
if (body.op === "command")
return ok(await profileConsole(p.id, body.command as string));
if (body.confirm !== p.name)
return fail(`Confirm by entering the server name: ${p.name}`);
switch (body.op) {
case "deploy":
return ok(
queueNetworkJob(p.id, "deploy", (progress) =>
deployProfile(p.id, progress),
),
);
case "stop":
return ok(queueNetworkJob(p.id, "stop", () => stopProfile(p.id)));
case "restart":
return ok(queueNetworkJob(p.id, "restart", () => restartProfile(p.id)));
case "snapshot":
return ok(
queueNetworkJob(p.id, "snapshot", (progress) =>
snapshotProfile(p.id, progress),
),
);
case "restore": {
if (typeof body.backup !== "string") return fail("Choose a backup");
const name = body.backup;
return ok(
queueNetworkJob(p.id, "restore", (progress) =>
restoreProfile(p.id, name, progress),
),
);
}
default:
return fail("Unknown network action");
}
});
}
+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>;
}
+7 -1
View File
@@ -19,6 +19,9 @@ import {
Bell,
LogOut,
CircleDot,
Network,
Map,
Compass,
} from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Badge, Toaster } from "@/components/ui";
@@ -26,6 +29,9 @@ import { timeAgo } from "@/lib/format";
const NAV = [
{ href: "/", label: "Overview", icon: Gauge },
{ href: "/network", label: "Worlds & Network", icon: Network },
{ href: "/maps", label: "Live Maps", icon: Map },
{ href: "/features", label: "Capabilities", icon: Compass },
{ href: "/console", label: "Console", icon: TerminalSquare },
{ href: "/players", label: "Players", icon: Users },
{ href: "/access", label: "Access & Auth", icon: ShieldCheck },
@@ -139,7 +145,7 @@ export function Shell({ children }: { children: React.ReactNode }) {
}`}
>
<CircleDot size={12} />
{isRunning ? "Online" : "Offline"}
Primary · {isRunning ? "Online" : "Offline"}
</span>
{isRunning && (
<span className="text-xs text-ink2">
+115
View File
@@ -0,0 +1,115 @@
/** Shared, serializable network configuration. No credentials belong here. */
export const LOADERS = [
"PAPER",
"FOLIA",
"FABRIC",
"FORGE",
"NEOFORGE",
"QUILT",
"VANILLA",
"MODRINTH",
] as const;
export type Loader = (typeof LOADERS)[number];
export type WorldProfile = {
id: string;
name: string;
version: string;
loader: Loader;
java: "8" | "17" | "21" | "25";
seed: string;
memory: number;
maxPlayers: number;
difficulty: string;
mode: string;
viewDistance: number;
simulationDistance: number;
pvp: boolean;
hardcore: boolean;
route: "proxy" | "direct";
port: number;
hostname: string;
mods: string;
modpack: string;
modpackVersion: string;
loaderVersion: string;
map: "none" | "bluemap" | "dynmap";
mapPort: number;
mapUrl: string;
resourcePack: string;
resourcePackSha1: string;
resourcePackRequired: boolean;
resourcePackPrompt: string;
whitelist: string;
backupHours: number;
retentionDays: number;
createdAt: number;
};
export type ProxySettings = {
port: number;
lobby: string;
motd: string;
version: string;
};
export type NetworkJob = {
id: string;
profileId: string;
action: string;
state: "queued" | "running" | "succeeded" | "failed";
message: string;
startedAt: number;
finishedAt?: number;
};
export type NetworkConfig = {
schema: 1;
profiles: WorldProfile[];
proxy: ProxySettings;
jobs: NetworkJob[];
};
export type RuntimeState = {
exists: boolean;
running: boolean;
status: string;
health?: string;
error?: string;
};
export type NetworkView = NetworkConfig & {
managed: boolean;
runtime: string;
states: Record<string, RuntimeState>;
primaryPort: number;
busy: boolean;
};
export const defaultProfile: Omit<WorldProfile, "id" | "createdAt"> = {
name: "",
version: "1.21.11",
loader: "PAPER",
java: "21",
seed: "",
memory: 4,
maxPlayers: 20,
difficulty: "normal",
mode: "survival",
viewDistance: 10,
simulationDistance: 6,
pvp: true,
hardcore: false,
route: "proxy",
port: 25570,
hostname: "",
mods: "",
modpack: "",
modpackVersion: "",
loaderVersion: "",
map: "none",
mapPort: 8101,
mapUrl: "",
resourcePack: "",
resourcePackSha1: "",
resourcePackRequired: false,
resourcePackPrompt: "",
whitelist: "",
backupHours: 24,
retentionDays: 7,
};
export const proxyCompatible = (loader: Loader) =>
loader === "PAPER" || loader === "FOLIA";
+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);
}
+338
View File
@@ -0,0 +1,338 @@
/** Apple's native macOS container CLI. The panel process must run on the Mac host. */
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
import { atomicWrite } from "./network-config";
import type {
ContainerState,
ContainerStats,
LogStreamHandle,
PowerAction,
} from "./docker";
const exec = promisify(execFile);
export type AppleInspect = {
configuration: {
id: string;
image: { reference: string };
initProcess: { environment: string[] };
mounts: Array<{ source: string; destination: string }>;
resources: { memoryInBytes: number };
};
status: {
state: string;
startedDate?: string;
networks: Array<{ ipv4Address: string; network: string }>;
};
};
function cli() {
if (process.platform !== "darwin")
throw new Error(
"Apple container control must run on the macOS host. Use scripts/apple-container-dev.sh.",
);
return process.env.APPLE_CONTAINER_CLI || "/opt/homebrew/bin/container";
}
export async function appleCommand(args: string[], timeout = 30_000) {
try {
return await exec(cli(), args, { timeout, maxBuffer: 4 * 1024 * 1024 });
} catch (error) {
// execFile's generic message includes all argv: report stderr rather than secrets/commands.
const e = error as Error & { stderr?: string; code?: string | number };
if (e.code === "ENOENT")
throw new Error(
"Apple container CLI is unavailable; check APPLE_CONTAINER_CLI",
);
throw new Error(
e.stderr?.trim() ||
`Apple container ${args[0]} failed (${e.code ?? "timeout"})`,
);
}
}
export async function appleInspectRaw(
name: string,
): Promise<AppleInspect | null> {
try {
const r = await appleCommand(["inspect", name]);
return (JSON.parse(r.stdout) as AppleInspect[])[0] ?? null;
} catch (e) {
if (
/not found|does not exist|no such container/i.test((e as Error).message)
)
return null;
throw e;
}
}
const health = new Map<string, { time: number; value: string }>();
export async function appleInspect(name: string): Promise<ContainerState> {
const info = await appleInspectRaw(name);
if (!info) return { exists: false, running: false, status: "missing" };
const running = info.status.state === "running";
let ready: string | undefined;
if (running && !name.endsWith("-backup")) {
const cached = health.get(name);
if (cached && Date.now() - cached.time < 8000) ready = cached.value;
else {
try {
await appleCommand(
[
"exec",
name,
"mc-monitor",
"status",
"--host",
"127.0.0.1",
"--port",
"25565",
],
8000,
);
ready = "healthy";
} catch {
ready = "starting";
}
health.set(name, { time: Date.now(), value: ready });
}
}
return {
exists: true,
running,
status: info.status.state,
health: ready,
startedAt: info.status.startedDate,
image: info.configuration.image.reference,
};
}
export async function applePower(name: string, action: PowerAction) {
health.delete(name);
if (action === "restart") {
const current = await appleInspectRaw(name);
if (current?.status.state === "running")
await appleCommand(["stop", "--time", "90", name], 150000);
await appleCommand(["start", name], 120000);
} else
await appleCommand(
action === "stop" ? ["stop", "--time", "90", name] : [action, name],
150000,
);
}
export async function appleExec(name: string, args: string[], timeout: number) {
try {
const r = await appleCommand(["exec", name, ...args], timeout);
return { exitCode: 0, output: r.stdout + r.stderr };
} catch (e) {
return { exitCode: 1, output: (e as Error).message };
}
}
export async function appleEnvironment(name: string) {
const info = await appleInspectRaw(name);
if (!info) throw new Error("Container not found");
return Object.fromEntries(
info.configuration.initProcess.environment.map((s) => {
const i = s.indexOf("=");
return [s.slice(0, i), s.slice(i + 1)];
}),
);
}
const cpuSamples = new Map<string, { time: number; usage: number }>();
export async function appleStats(name: string): Promise<ContainerStats> {
const result = await appleCommand([
"stats",
name,
"--no-stream",
"--format",
"json",
]);
const s = JSON.parse(result.stdout)[0] as {
cpuUsageUsec: number;
memoryUsageBytes: number;
memoryLimitBytes: number;
networkRxBytes: number;
networkTxBytes: number;
};
if (!s) throw new Error("No container stats available");
const now = Date.now();
const prev = cpuSamples.get(name);
cpuSamples.set(name, { time: now, usage: s.cpuUsageUsec });
return {
cpuPercent:
prev && now > prev.time
? Math.max(
0,
((s.cpuUsageUsec - prev.usage) / ((now - prev.time) * 1000)) * 100,
)
: 0,
memUsed: s.memoryUsageBytes,
memLimit: s.memoryLimitBytes,
netRx: s.networkRxBytes,
netTx: s.networkTxBytes,
};
}
export function appleLogStream(
name: string,
opts: { tail: number; follow: boolean },
onLine: (s: string) => void,
onEnd?: (e?: Error) => void,
): LogStreamHandle {
let child: ReturnType<typeof spawn>;
try {
child = spawn(cli(), [
"logs",
"-n",
String(opts.tail),
...(opts.follow ? ["--follow"] : []),
name,
]);
} catch (e) {
onEnd?.(e as Error);
return { stop() {} };
}
let carry = "";
let stopped = false;
const receive = (chunk: Buffer) => {
carry += chunk.toString();
let i;
while ((i = carry.indexOf("\n")) >= 0) {
onLine(carry.slice(0, i));
carry = carry.slice(i + 1);
}
};
child.stdout?.on("data", receive);
child.stderr?.on("data", receive);
child.on("error", (e) => {
if (!stopped) onEnd?.(e);
});
child.on("close", () => {
if (!stopped) onEnd?.();
});
return {
stop() {
stopped = true;
child.kill();
},
};
}
export async function appleInfrastructure() {
await appleCommand(["system", "status"]);
const network = process.env.APPLE_CONTAINER_NETWORK || "craftdeck";
try {
await appleCommand(["network", "inspect", network]);
} catch (e) {
if (!/not found|does not exist/i.test((e as Error).message)) throw e;
await appleCommand(["network", "create", network]);
}
return { hostDataDir: env.mcDataDir, network };
}
export async function appleAddress(name: string) {
const info = await appleInspectRaw(name);
const address = info?.status.networks[0]?.ipv4Address?.split("/")[0];
if (!address)
throw new Error(`Start ${name} before applying gateway routing`);
return address;
}
type Service = {
image: string;
container_name: string;
environment: Record<string, string>;
mem_limit?: string;
volumes: string[];
ports?: string[];
};
type ComposePlan = {
services: Record<string, Service>;
networks: { stack: { name: string } };
};
/** Translate only CraftDeck's own generated service definition, never arbitrary Compose input. */
export function appleRunArguments(
service: Service,
network: string,
envFile: string,
) {
return [
"run",
"-d",
"--name",
service.container_name,
"--cpus",
service.container_name.endsWith("-backup") ? "1" : "2",
"--memory",
(service.mem_limit ?? "512m").toUpperCase(),
"--network",
network,
"--dns",
process.env.APPLE_CONTAINER_DNS || "1.1.1.1",
...(service.container_name === "craftdeck-velocity"
? [
"--user",
`${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
]
: []),
"--env-file",
envFile,
...service.volumes.flatMap((v) => ["--volume", v]),
...(service.ports ?? []).flatMap((p) => ["--publish", p]),
service.image,
];
}
async function runService(service: Service, network: string, dir: string) {
// Pull first so a registry failure leaves the existing container available.
await appleCommand(["image", "pull", service.image], 15 * 60_000);
const previous = await appleInspectRaw(service.container_name);
if (previous) {
if (previous.status.state === "running")
await applePower(service.container_name, "stop");
await appleCommand(["delete", service.container_name]);
}
health.delete(service.container_name);
const file = path.join(dir, `${service.container_name}.env`);
atomicWrite(
file,
Object.entries(service.environment)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n",
);
await appleCommand(appleRunArguments(service, network, file), 120_000);
}
export async function appleWaitReady(name: string, seconds = 600) {
const deadline = Date.now() + seconds * 1000;
while (Date.now() < deadline) {
const info = await appleInspect(name);
if (info.health === "healthy") return;
if (!info.running)
throw new Error(
`${name} exited before Minecraft became ready; inspect its console`,
);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`${name} did not become ready within ${seconds} seconds`);
}
function readPlan(file: string): ComposePlan {
// Undo Compose's escaped dollar signs before passing literal values via env-file.
return JSON.parse(
fs.readFileSync(file, "utf8").replace(/\$\$/g, "$"),
) as ComposePlan;
}
/** A stopped/started Apple VM can acquire a new IP, including after snapshots. */
export async function appleRefreshBackup(file: string) {
const plan = readPlan(file);
const server = plan.services.server;
if (!server) throw new Error("Missing server deployment plan");
await appleWaitReady(server.container_name);
const backup = plan.services.backup;
if (backup) {
backup.environment.RCON_HOST = await appleAddress(server.container_name);
await runService(backup, plan.networks.stack.name, path.dirname(file));
}
}
export async function appleApply(file: string) {
const plan = readPlan(file);
const network = plan.networks.stack.name;
const dir = path.dirname(file);
if (plan.services.server) {
await runService(plan.services.server, network, dir);
await appleRefreshBackup(file);
} else if (plan.services.proxy) {
await runService(plan.services.proxy, network, dir);
await appleWaitReady(plan.services.proxy.container_name, 180);
} else throw new Error("Unsupported Apple container deployment plan");
}
+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;
}
}
+9 -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,23 @@ import { env } from "./env";
*/
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
assertDeploymentEditable();
if (env.containerRuntime === "apple") throw new Error("Primary Compose configuration applies on the production Docker host. Use Worlds & Network for native Apple-container profiles.");
return withServerOperation(() => new Promise((resolve) => {
execFile(
"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). */
+24 -1
View File
@@ -1,5 +1,7 @@
import http from "node:http";
import { appleInspect, applePower, appleExec, appleEnvironment, appleStats, appleLogStream, appleInfrastructure } from "./apple-container";
import { env } from "./env";
import { withServerOperation } from "./operation";
/**
* Minimal Docker Engine API client over the unix socket (no dependencies).
@@ -96,6 +98,7 @@ type InspectResponse = {
};
export async function inspectContainer(name: string): Promise<ContainerState> {
if (env.containerRuntime === "apple") return appleInspect(name);
try {
const data = await json<InspectResponse>(`/containers/${name}/json`);
return {
@@ -116,6 +119,7 @@ export async function inspectContainer(name: string): Promise<ContainerState> {
}
export async function containerEnv(name: string): Promise<Record<string, string>> {
if (env.containerRuntime === "apple") return appleEnvironment(name);
const data = await json<InspectResponse>(`/containers/${name}/json`);
const out: Record<string, string> = {};
for (const kv of data.Config.Env ?? []) {
@@ -130,6 +134,11 @@ 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> {
if (env.containerRuntime === "apple") return applePower(name, action);
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
method: "POST",
@@ -167,6 +176,7 @@ type StatsResponse = {
};
export async function containerStats(name: string): Promise<ContainerStats> {
if (env.containerRuntime === "apple") return appleStats(name);
// one-shot=false + stream=false makes the daemon take two samples so the
// precpu fields are populated and CPU% is computable from a single call
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
@@ -226,6 +236,7 @@ export function streamContainerLogs(
onLine: (line: string) => void,
onEnd?: (err?: Error) => void
): LogStreamHandle {
if (env.containerRuntime === "apple") return appleLogStream(name, opts, onLine, onEnd);
const req = http.request({
socketPath: env.dockerSocket,
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
@@ -262,6 +273,7 @@ export async function execInContainer(
cmd: string[],
timeoutMs = 15 * 60 * 1000
): Promise<{ exitCode: number; output: string }> {
if (env.containerRuntime === "apple") return appleExec(name, cmd, timeoutMs);
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
method: "POST",
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
@@ -292,5 +304,16 @@ 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 };
}
/** Network profiles join the primary stack's Docker network and data mount. */
export async function networkInfrastructure() {
if (env.containerRuntime === "apple") return appleInfrastructure();
const data = await json<{ Mounts: Array<{ Type: string; Source: string; Destination: string }>; NetworkSettings: { Networks: Record<string, unknown> } }>(`/containers/${env.mcContainer}/json`);
const mount = data.Mounts.find(m => m.Destination === "/data" && m.Type === "bind");
const networks = Object.keys(data.NetworkSettings.Networks).filter(n => n !== "host" && n !== "none" && n !== "bridge");
if (!mount || networks.length !== 1) throw new Error("Network deployment requires the primary server's /data bind mount and one user-defined Docker network");
return { hostDataDir: mount.Source, network: networks[0] };
}
+6
View File
@@ -14,6 +14,12 @@ function str(key: string, fallback: string): string {
export const env = {
dev,
containerRuntime: str("CONTAINER_RUNTIME", "docker"),
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));
}
+312
View File
@@ -0,0 +1,312 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { env } from "./env";
import {
LOADERS,
defaultProfile,
proxyCompatible,
type NetworkConfig,
type WorldProfile,
type ProxySettings,
} from "@/lib/network";
export class NetworkInputError extends Error {}
export function networkFile() {
return path.join(env.appDataDir, "network.json");
}
export function readNetwork(): NetworkConfig {
if (!fs.existsSync(networkFile()))
return {
schema: 1,
profiles: [],
proxy: {
port: 25566,
lobby: "",
motd: "A CraftDeck network",
version: "latest",
},
jobs: [],
};
const value = JSON.parse(
fs.readFileSync(networkFile(), "utf8"),
) as NetworkConfig;
if (
value.schema !== 1 ||
!Array.isArray(value.profiles) ||
!Array.isArray(value.jobs)
)
throw new Error(
"Invalid network registry; restore network.json from backup",
);
return value;
}
export function atomicWrite(file: string, content: string, mode = 0o600) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${randomUUID()}.tmp`;
fs.writeFileSync(tmp, content, { mode });
fs.renameSync(tmp, file);
}
export function writeNetwork(config: NetworkConfig) {
atomicWrite(networkFile(), JSON.stringify(config, null, 2));
}
export function profileById(config: NetworkConfig, id: unknown) {
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
throw new NetworkInputError("Invalid server ID");
const p = config.profiles.find((p) => p.id === id);
if (!p) throw new NetworkInputError("Server profile not found");
return p;
}
export function profileRoot(id: string) {
if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
throw new NetworkInputError("Invalid server ID");
return path.join(path.dirname(env.mcDataDir), "network", id);
}
function string(value: unknown, label: string, max = 200): string {
if (
typeof value !== "string" ||
value.length > max ||
/[\x00-\x1f\x7f]/.test(value)
)
throw new NetworkInputError(`Invalid ${label}`);
return value.trim();
}
function integer(
value: unknown,
label: string,
min: number,
max: number,
): number {
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max)
throw new NetworkInputError(`${label} must be ${min}–${max}`);
return Number(value);
}
function choice<T extends string>(
value: unknown,
choices: readonly T[],
label: string,
): T {
if (!choices.includes(value as T))
throw new NetworkInputError(`Invalid ${label}`);
return value as T;
}
function bool(value: unknown, label: string) {
if (typeof value !== "boolean")
throw new NetworkInputError(`Invalid ${label}`);
return value;
}
function url(value: unknown, label: string) {
const s = string(value, label, 2000);
if (!s) return "";
let u: URL;
try {
u = new URL(s);
} catch {
throw new NetworkInputError(`${label} must be an HTTP(S) URL`);
}
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password)
throw new NetworkInputError(`Invalid ${label}`);
return s;
}
export function validateProfile(
input: unknown,
existing?: WorldProfile,
): WorldProfile {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new NetworkInputError("Missing profile");
const v = { ...defaultProfile, ...input } as Record<string, unknown>;
const name = string(v.name, "name", 60);
if (!name) throw new NetworkInputError("Name is required");
const version = string(v.version, "Minecraft version", 40);
if (!/^[0-9][a-zA-Z0-9._-]*$/.test(version))
throw new NetworkInputError(
"Use an exact Minecraft release or snapshot version",
);
const p: WorldProfile = {
id:
existing?.id ??
`${
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 28) || "world"
}-${randomUUID().slice(0, 8)}`,
createdAt: existing?.createdAt ?? Date.now(),
name,
version,
loader: choice(v.loader, LOADERS, "loader"),
java: choice(v.java, ["8", "17", "21", "25"], "Java runtime"),
seed: string(v.seed, "seed", 128),
memory: integer(v.memory, "Heap in GiB", 1, 128),
maxPlayers: integer(v.maxPlayers, "Player slots", 1, 10000),
difficulty: choice(
v.difficulty,
["peaceful", "easy", "normal", "hard"],
"difficulty",
),
mode: choice(
v.mode,
["survival", "creative", "adventure", "spectator"],
"game mode",
),
viewDistance: integer(v.viewDistance, "View distance", 2, 32),
simulationDistance: integer(
v.simulationDistance,
"Simulation distance",
2,
32,
),
pvp: bool(v.pvp, "PVP"),
hardcore: bool(v.hardcore, "hardcore"),
route: choice(v.route, ["proxy", "direct"], "connection mode"),
port: integer(v.port, "Game port", 1024, 65535),
hostname: string(v.hostname, "hostname", 253).toLowerCase(),
mods: string(v.mods, "mods", 4000),
modpack: string(v.modpack, "modpack", 100),
modpackVersion: string(v.modpackVersion, "modpack version", 100),
loaderVersion: string(v.loaderVersion, "loader version", 60),
map: choice(v.map, ["none", "bluemap", "dynmap"], "map"),
mapPort: integer(v.mapPort, "Map port", 1024, 65535),
mapUrl: url(v.mapUrl, "Map URL"),
resourcePack: url(v.resourcePack, "Resource pack URL"),
resourcePackSha1: string(
v.resourcePackSha1,
"Resource pack SHA-1",
40,
).toLowerCase(),
resourcePackRequired: bool(v.resourcePackRequired, "Required pack"),
resourcePackPrompt: string(v.resourcePackPrompt, "Pack prompt", 300),
whitelist: string(v.whitelist, "whitelist", 8000),
backupHours: integer(v.backupHours, "Backup interval", 1, 168),
retentionDays: integer(v.retentionDays, "Backup retention", 1, 365),
};
for (const field of [p.modpack, p.modpackVersion, p.loaderVersion])
if (field && !/^[a-zA-Z0-9._+-]+$/.test(field))
throw new NetworkInputError(
"Use loader/modpack IDs or versions, not URLs",
);
if (
p.mods &&
!p.mods.split(",").every((s) => /^[\w-]+(?::[\w.+-]+)?$/.test(s.trim()))
)
throw new NetworkInputError(
"Mods must be comma-separated Modrinth slugs with optional :version IDs",
);
if (
p.whitelist &&
!p.whitelist.split(",").every((s) => /^[a-zA-Z0-9_]{3,16}$/.test(s.trim()))
)
throw new NetworkInputError(
"Whitelist must contain comma-separated Minecraft usernames",
);
if (
p.hostname &&
!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(p.hostname)
)
throw new NetworkInputError("Invalid hostname");
if (p.route === "proxy" && !proxyCompatible(p.loader))
throw new NetworkInputError(
"Automatic secure proxy routing supports Paper and Folia; use a direct port for this loader",
);
if (
p.route === "proxy" &&
/^1\.(\d+)/.test(p.version) &&
Number(p.version.split(".")[1]) < 19
)
throw new NetworkInputError(
"Managed proxy profiles require Paper/Folia 1.19 or newer",
);
if (p.loader === "MODRINTH" && (!p.modpack || !p.modpackVersion))
throw new NetworkInputError(
"Choose a Modrinth pack and an exact pack version ID",
);
if (p.loader !== "MODRINTH" && p.modpack)
throw new NetworkInputError(
"Select Modrinth modpack as the runtime to install a pack",
);
if (p.map !== "none" && ["VANILLA", "MODRINTH"].includes(p.loader))
throw new NetworkInputError(
"Automatic map installation needs an explicit mod/plugin loader",
);
if (p.resourcePackSha1 && !/^[a-f0-9]{40}$/.test(p.resourcePackSha1))
throw new NetworkInputError(
"Resource pack SHA-1 must be 40 hexadecimal characters",
);
if (p.resourcePack && !p.resourcePackSha1)
throw new NetworkInputError(
"Provide the resource pack SHA-1 so clients can verify downloads",
);
if (p.resourcePackRequired && !p.resourcePack)
throw new NetworkInputError("Required resource packs need a download URL");
if (existing)
for (const key of [
"loader",
"version",
"java",
"modpack",
"modpackVersion",
"seed",
"route",
] as const) {
if (
existing[key] !== p[key] &&
fs.existsSync(
path.join(profileRoot(existing.id), "data", "server.properties"),
)
)
throw new NetworkInputError(
"Create a new profile for seed, version, runtime, pack or authentication changes; existing world data stays intact",
);
}
return p;
}
export function validateProxy(
input: unknown,
config: NetworkConfig,
): ProxySettings {
if (!input || typeof input !== "object")
throw new NetworkInputError("Missing proxy configuration");
const v = input as Record<string, unknown>;
const p = {
port: integer(v.port, "Proxy port", 1024, 65535),
lobby: string(v.lobby, "lobby", 48),
motd: string(v.motd, "MOTD", 200),
version: string(v.version, "Velocity version", 40),
};
if (!/^[a-zA-Z0-9._+-]+$/.test(p.version))
throw new NetworkInputError("Invalid Velocity version");
if (!config.profiles.some((s) => s.id === p.lobby && s.route === "proxy"))
throw new NetworkInputError(
"Choose a Paper/Folia proxy profile as the lobby",
);
return p;
}
export function validatePorts(
config: NetworkConfig,
primaryPort: number,
panelPort: number,
) {
const used = new Map<number, string>([
[primaryPort, "primary server"],
[panelPort, "panel"],
]);
const reserve = (port: number, name: string) => {
if (used.has(port))
throw new NetworkInputError(
`Port ${port} is already assigned to ${used.get(port)}`,
);
used.set(port, name);
};
reserve(config.proxy.port, "proxy");
const hosts = new Set<string>();
for (const p of config.profiles) {
if (p.route === "direct") reserve(p.port, p.name);
if (p.map !== "none") reserve(p.mapPort, `${p.name} map`);
if (p.route === "proxy" && p.hostname) {
if (hosts.has(p.hostname))
throw new NetworkInputError(`Duplicate hostname: ${p.hostname}`);
hosts.add(p.hostname);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { profileRoot, NetworkInputError } from "./network-config";
export function networkFilePath(id: string, relative: string) {
const root = path.join(profileRoot(id), "data");
if (!fs.existsSync(root))
throw new NetworkInputError("Deploy this server before browsing files");
if (typeof relative !== "string" || relative.includes("\0"))
throw new NetworkInputError("Invalid file path");
const resolved = path.resolve(root, relative || ".");
if (resolved !== root && !resolved.startsWith(root + path.sep))
throw new NetworkInputError("Path escapes server directory");
let parent = resolved;
while (true) {
try {
fs.lstatSync(/* turbopackIgnore: true */ parent);
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
parent = path.dirname(parent);
}
}
const real = fs.realpathSync(/* turbopackIgnore: true */ parent);
const realRoot = fs.realpathSync(root);
if (real !== realRoot && !real.startsWith(realRoot + path.sep))
throw new NetworkInputError("Symlink escapes server directory");
return resolved;
}
+164
View File
@@ -0,0 +1,164 @@
import path from "node:path";
import type { NetworkConfig, WorldProfile } from "@/lib/network";
export const serverName = (id: string) => `craftdeck-${id}`;
export const backupName = (id: string) => `craftdeck-${id}-backup`;
export const proxyName = "craftdeck-velocity";
export function serverEnvironment(
p: WorldProfile,
password: string,
mapProject = "",
) {
const mods = [p.mods, mapProject].filter(Boolean).join(",");
return {
EULA: "TRUE",
TYPE: p.loader,
VERSION: p.version,
MEMORY: `${p.memory}G`,
ONLINE_MODE: p.route === "proxy" ? "FALSE" : "TRUE",
ENFORCE_SECURE_PROFILE: p.route === "proxy" ? "FALSE" : "TRUE",
ENABLE_WHITELIST: "TRUE",
ENFORCE_WHITELIST: "TRUE",
WHITELIST: p.whitelist,
EXISTING_WHITELIST_FILE: "SYNCHRONIZE",
ENABLE_RCON: "TRUE",
RCON_PASSWORD: password,
RCON_PORT: "25575",
MOTD: p.name,
SEED: p.seed,
LEVEL: "world",
DIFFICULTY: p.difficulty,
MODE: p.mode,
MAX_PLAYERS: String(p.maxPlayers),
VIEW_DISTANCE: String(p.viewDistance),
SIMULATION_DISTANCE: String(p.simulationDistance),
PVP: String(p.pvp),
HARDCORE: String(p.hardcore),
SPAWN_PROTECTION: "0",
MODRINTH_PROJECTS: mods,
MODRINTH_DOWNLOAD_DEPENDENCIES: "required",
...(p.loader === "MODRINTH"
? { MODRINTH_MODPACK: p.modpack, MODRINTH_VERSION: p.modpackVersion }
: {}),
...(p.loaderVersion
? {
[(
{
FABRIC: "FABRIC_LOADER_VERSION",
FORGE: "FORGE_VERSION",
NEOFORGE: "NEOFORGE_VERSION",
QUILT: "QUILT_LOADER_VERSION",
} as Record<string, string>
)[p.loader] ?? "PAPER_BUILD"]: p.loaderVersion,
}
: {}),
RESOURCE_PACK: p.resourcePack,
RESOURCE_PACK_SHA1: p.resourcePackSha1,
RESOURCE_PACK_ENFORCE: String(p.resourcePackRequired),
RESOURCE_PACK_PROMPT: p.resourcePackPrompt,
};
}
const logging = {
driver: "json-file",
options: { "max-size": "10m", "max-file": "3" },
};
export function renderProfileCompose(
p: WorldProfile,
infra: { hostDataDir: string; network: string },
password: string,
mapProject: string,
) {
const hostRoot = path.join(path.dirname(infra.hostDataDir), "network", p.id);
return {
name: `craftdeck-${p.id}`,
services: {
server: {
image: `itzg/minecraft-server:java${p.java}`,
container_name: serverName(p.id),
restart: "unless-stopped",
stop_grace_period: "2m",
logging,
environment: serverEnvironment(p, password, mapProject),
mem_limit: `${p.memory + 1}g`,
volumes: [`${hostRoot}/data:/data`],
networks: ["stack"],
ports: [
...(p.route === "direct" ? [`${p.port}:25565`] : []),
...(p.map !== "none"
? [`127.0.0.1:${p.mapPort}:${p.map === "bluemap" ? 8100 : 8123}`]
: []),
],
},
backup: {
image:
"itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383",
container_name: backupName(p.id),
restart: "unless-stopped",
logging,
mem_limit: "512m",
depends_on: { server: { condition: "service_healthy" } },
networks: ["stack"],
environment: {
BACKUP_INTERVAL: `${p.backupHours}h`,
INITIAL_DELAY: "10m",
PRUNE_BACKUPS_DAYS: String(p.retentionDays),
BACKUP_NAME: "world",
TAR_COMPRESS_METHOD: "gzip",
PAUSE_IF_NO_PLAYERS: "false",
RCON_HOST: serverName(p.id),
RCON_PORT: "25575",
RCON_PASSWORD: password,
EXCLUDES: "*.tmp,cache,logs",
},
volumes: [`${hostRoot}/data:/data:ro`, `${hostRoot}/backups:/backups`],
},
},
networks: { stack: { external: true, name: infra.network } },
};
}
export function renderVelocity(
config: NetworkConfig,
addresses: Record<string, string> = {},
) {
const routes = config.profiles.filter((p) => p.route === "proxy");
// JSON strings are valid TOML basic strings for the validated single-line inputs.
const q = JSON.stringify;
return `config-version = "2.8"\nbind = "0.0.0.0:25565"\nmotd = ${q(config.proxy.motd)}\nshow-max-players = ${routes.reduce((n, p) => n + p.maxPlayers, 0)}\nonline-mode = true\nforce-key-authentication = true\nplayer-info-forwarding-mode = "modern"\nforwarding-secret-file = "forwarding.secret"\nping-passthrough = "DISABLED"\n\n[servers]\n${routes.map((p) => `${q(p.id)} = ${q(`${addresses[p.id] ?? serverName(p.id)}:25565`)}`).join("\n")}\ntry = ${JSON.stringify([config.proxy.lobby, ...routes.map((p) => p.id).filter((id) => id !== config.proxy.lobby)])}\n\n[forced-hosts]\n${routes
.filter((p) => p.hostname)
.map((p) => `${q(p.hostname)} = [${q(p.id)}]`)
.join(
"\n",
)}\n\n[advanced]\nfailover-on-unexpected-server-disconnect = true\n`;
}
export function renderProxyCompose(
config: NetworkConfig,
infra: { hostDataDir: string; network: string },
) {
const root = path.join(path.dirname(infra.hostDataDir), "network", "proxy");
return {
name: "craftdeck-proxy",
services: {
proxy: {
image: "itzg/mc-proxy:latest",
container_name: proxyName,
restart: "unless-stopped",
logging,
environment: {
TYPE: "VELOCITY",
VELOCITY_VERSION: config.proxy.version,
MEMORY: "512m",
HEALTH_PORT: "25565",
},
mem_limit: "1g",
ports: [`${config.proxy.port}:25565`],
networks: ["stack"],
volumes: [`${root}/data:/server`, `${root}/config:/config:ro`],
},
},
networks: { stack: { external: true, name: infra.network } },
};
}
/** Compose interpolates dollar signs even in JSON/YAML quoted strings. */
export function composeJson(value: unknown) {
return JSON.stringify(value, null, 2).replace(/\$/g, "$$$$");
}
+503
View File
@@ -0,0 +1,503 @@
import fs from "node:fs";
import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import YAML from "yaml";
import {
appleApply,
appleAddress,
appleCommand,
appleRefreshBackup,
appleWaitReady,
} from "./apple-container";
import { env } from "./env";
import { readEnvFile } from "./envfile";
import {
inspectContainer,
powerContainer,
execInContainer,
networkInfrastructure,
} from "./docker";
import { withServerOperation } from "./operation";
import { assertDeploymentEditable } from "./managed";
import {
atomicWrite,
readNetwork,
writeNetwork,
profileById,
profileRoot,
validatePorts,
NetworkInputError,
} from "./network-config";
import {
serverName,
backupName,
proxyName,
renderProfileCompose,
renderProxyCompose,
renderVelocity,
composeJson,
} from "./network-render";
import type { NetworkConfig, NetworkJob, WorldProfile } from "@/lib/network";
const exec = promisify(execFile);
const globalJobs = globalThis as unknown as { __networkJobs?: Set<string> };
const runningJobs = (globalJobs.__networkJobs ??= new Set<string>());
export const networkBusy = () => runningJobs.size > 0;
export function assertNetworkIdle() {
if (networkBusy())
throw new NetworkInputError(
"Wait for the current network operation to finish",
);
}
export function checkPorts(config: NetworkConfig) {
const values = readEnvFile();
validatePorts(
config,
Number(values.MC_PORT || 25565),
Number(values.DASHBOARD_PORT || 8080),
);
}
export function reconcileJobs(config: NetworkConfig) {
let changed = false;
for (const job of config.jobs)
if (["queued", "running"].includes(job.state) && !runningJobs.has(job.id)) {
job.state = "failed";
job.message =
"Panel restarted during this operation. Inspect server status and logs before retrying.";
job.finishedAt = Date.now();
changed = true;
}
if (changed) writeNetwork(config);
return config;
}
function redact(message: string) {
const secrets = [env.rconPassword];
const secretFile = path.join(
profileRoot("proxy"),
"config",
"forwarding.secret",
);
if (fs.existsSync(secretFile))
secrets.push(fs.readFileSync(secretFile, "utf8").trim());
for (const value of secrets.filter(Boolean))
message = message.split(value).join("[redacted]");
return message.slice(-2500);
}
function updateJob(id: string, changes: Partial<NetworkJob>) {
const config = readNetwork();
const job = config.jobs.find((j) => j.id === id);
if (job) {
Object.assign(job, changes);
writeNetwork(config);
}
}
export function queueNetworkJob(
profileId: string,
action: string,
operation: (progress: (message: string) => void) => Promise<string>,
) {
assertDeploymentEditable();
assertNetworkIdle();
const config = readNetwork();
const job: NetworkJob = {
id: randomUUID(),
profileId,
action,
state: "queued",
message: "Waiting for server operations",
startedAt: Date.now(),
};
config.jobs = [job, ...config.jobs].slice(0, 40);
writeNetwork(config);
runningJobs.add(job.id);
void withServerOperation(async () => {
try {
updateJob(job.id, { state: "running" });
const result = await operation((message) =>
updateJob(job.id, { message }),
);
updateJob(job.id, {
state: "succeeded",
message: result,
finishedAt: Date.now(),
});
} catch (e) {
try {
updateJob(job.id, {
state: "failed",
message: redact((e as Error).message),
finishedAt: Date.now(),
});
} catch {
// A full/unwritable registry must not leave the process locked forever.
// The next registry read reconciles this unfinished job as interrupted.
console.error("[craftdeck] Could not persist network operation status");
}
} finally {
runningJobs.delete(job.id);
}
});
return job;
}
async function compose(file: string, args: string[]) {
if (env.containerRuntime === "apple") return appleApply(file);
try {
await exec(
"docker",
[
"compose",
"--project-directory",
path.dirname(file),
"-f",
file,
...args,
],
{ timeout: 15 * 60_000, maxBuffer: 4 * 1024 * 1024 },
);
} catch (e) {
const err = e as Error & { stderr?: string };
throw new Error(redact(err.stderr || err.message));
}
}
function secret() {
const file = path.join(profileRoot("proxy"), "config", "forwarding.secret");
if (!fs.existsSync(file))
atomicWrite(file, randomBytes(32).toString("hex"), 0o644);
return fs.readFileSync(file, "utf8").trim();
}
async function resolveMap(p: WorldProfile) {
if (p.map === "none") return "";
const loader = p.loader.toLowerCase();
const params = new URLSearchParams({
loaders: JSON.stringify([loader]),
game_versions: JSON.stringify([p.version]),
});
const res = await fetch(
`https://api.modrinth.com/v2/project/${p.map}/version?${params}`,
{
headers: { "User-Agent": "CraftDeck/1.0" },
signal: AbortSignal.timeout(15000),
},
);
if (!res.ok)
throw new Error(
`Could not resolve ${p.map} (${res.status}); choose another map or install a compatible release manually`,
);
const versions = (await res.json()) as Array<{
id: string;
version_type: string;
}>;
const version = versions.find((v) => v.version_type === "release");
if (!version)
throw new Error(
`No stable ${p.map} build advertises ${p.loader} ${p.version} support. No server files were changed.`,
);
return `${p.map}:${version.id}`;
}
async function validatePack(p: WorldProfile) {
if (p.loader !== "MODRINTH") return;
const res = await fetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(p.modpack)}/version/${encodeURIComponent(p.modpackVersion)}`,
{
headers: { "User-Agent": "CraftDeck/1.0" },
signal: AbortSignal.timeout(15000),
},
);
if (!res.ok) throw new Error("Modrinth pack version could not be resolved");
const version = (await res.json()) as { game_versions: string[] };
if (!version.game_versions.includes(p.version))
throw new Error(
"The pack version does not support the selected Minecraft version",
);
}
function prepareForwarding(p: WorldProfile) {
if (p.route !== "proxy") return;
const file = path.join(
profileRoot(p.id),
"data",
"config",
"paper-global.yml",
);
const doc = fs.existsSync(file)
? YAML.parseDocument(fs.readFileSync(file, "utf8"))
: new YAML.Document({});
if (doc.errors.length)
throw new Error(
"Could not parse paper-global.yml; repair it before deploying",
);
doc.setIn(["proxies", "velocity", "enabled"], true);
doc.setIn(["proxies", "velocity", "online-mode"], true);
doc.setIn(["proxies", "velocity", "secret"], secret());
atomicWrite(file, doc.toString(), 0o644);
try {
fs.chownSync(file, 1000, 1000);
} catch {}
}
export async function deployProfile(id: string, progress: (s: string) => void) {
const config = readNetwork();
checkPorts(config);
const p = profileById(config, id);
progress("Checking loader, map and container runtime");
await validatePack(p);
const mapProject = await resolveMap(p);
const infra = await networkInfrastructure();
const root = profileRoot(id);
fs.mkdirSync(path.join(root, "data"), { recursive: true });
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
const state = await inspectContainer(serverName(id));
if (
state.running ||
fs.existsSync(path.join(root, "data", "world", "level.dat"))
) {
progress("Taking a verified backup before applying changes");
await snapshotProfile(id, progress);
}
const backup = await inspectContainer(backupName(id));
if (backup.running) await powerContainer(backupName(id), "stop");
if ((await inspectContainer(serverName(id))).running)
await powerContainer(serverName(id), "stop");
prepareForwarding(p);
const file = path.join(root, "compose.json");
atomicWrite(
file,
composeJson(renderProfileCompose(p, infra, env.rconPassword, mapProject)),
);
progress(
"Downloading runtime and starting server; first world generation can take several minutes",
);
await compose(file, ["up", "-d", "--wait", "--wait-timeout", "600"]);
const ready = await inspectContainer(serverName(id));
if (ready.health !== "healthy")
throw new Error(
"Container started but Minecraft readiness is unverified; check its console",
);
const routing = await refreshAppleGateway();
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : `Connect on port ${p.port}.`)}`;
}
/** Native VM addresses are ephemeral. Refresh only a gateway the user has started. */
async function refreshAppleGateway() {
if (
env.containerRuntime !== "apple" ||
!(await inspectContainer(proxyName)).running
)
return "";
const config = readNetwork();
const addresses: Record<string, string> = {};
const active = [];
for (const p of config.profiles.filter((p) => p.route === "proxy")) {
const state = await inspectContainer(serverName(p.id));
if (state.running && state.health === "healthy") {
addresses[p.id] = await appleAddress(serverName(p.id));
active.push(p);
}
}
if (!active.length) {
await powerContainer(proxyName, "stop");
return "No routed worlds are ready; gateway stopped. Apply routing after starting a world.";
}
const routed = {
...config,
profiles: active,
proxy: {
...config.proxy,
lobby: active.some((p) => p.id === config.proxy.lobby)
? config.proxy.lobby
: active[0].id,
},
};
const file = path.join(profileRoot("proxy"), "config", "velocity.toml");
const rendered = renderVelocity(routed, addresses);
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === rendered)
return "Gateway routes are current.";
atomicWrite(file, rendered, 0o644);
await powerContainer(proxyName, "restart");
await appleWaitReady(proxyName, 180);
return "Gateway restarted with current world addresses; players can reconnect.";
}
export async function deployProxy(progress: (s: string) => void) {
const config = readNetwork();
checkPorts(config);
if (!config.proxy.lobby)
throw new NetworkInputError("Choose and save a lobby first");
const lobby = profileById(config, config.proxy.lobby);
if (lobby.route !== "proxy")
throw new NetworkInputError("Lobby must be a proxy profile");
const state = await inspectContainer(serverName(lobby.id));
if (!state.running || state.health !== "healthy")
throw new NetworkInputError(
"Start the lobby and wait until healthy before applying the proxy",
);
const infra = await networkInfrastructure();
secret();
const root = profileRoot("proxy");
const configFile = path.join(root, "config", "velocity.toml");
if (fs.existsSync(configFile))
fs.copyFileSync(configFile, `${configFile}.previous`);
const addresses: Record<string, string> = {};
if (env.containerRuntime === "apple") {
for (const p of config.profiles.filter((p) => p.route === "proxy"))
addresses[p.id] = await appleAddress(serverName(p.id));
}
atomicWrite(configFile, renderVelocity(config, addresses), 0o644);
fs.mkdirSync(path.join(root, "data"), { recursive: true });
const file = path.join(root, "compose.json");
atomicWrite(file, composeJson(renderProxyCompose(config, infra)));
progress("Applying Velocity routing; connected players will reconnect");
await compose(file, [
"up",
"-d",
"--force-recreate",
"--wait",
"--wait-timeout",
"180",
]);
return `Velocity is ready on port ${config.proxy.port}; lobby: ${lobby.name}`;
}
export async function stopProfile(id: string) {
profileById(readNetwork(), id);
if ((await inspectContainer(backupName(id))).running)
await powerContainer(backupName(id), "stop");
const state = await inspectContainer(serverName(id));
if (state.running) await powerContainer(serverName(id), "stop");
const routing = await refreshAppleGateway();
return `Server and backup writer stopped; all world files retained. ${routing}`.trim();
}
export async function restartProfile(id: string) {
profileById(readNetwork(), id);
if (
env.containerRuntime === "apple" &&
(await inspectContainer(backupName(id))).running
)
await powerContainer(backupName(id), "stop");
await powerContainer(serverName(id), "restart");
if (env.containerRuntime === "apple") {
await appleRefreshBackup(path.join(profileRoot(id), "compose.json"));
const routing = await refreshAppleGateway();
return `Server is healthy; backup connection refreshed. ${routing}`.trim();
}
const backup = await inspectContainer(backupName(id));
if (backup.exists && !backup.running)
await powerContainer(backupName(id), "start");
return "Restart requested; watch the health indicator for readiness";
}
export function profileBackups(id: string) {
profileById(readNetwork(), id);
const dir = path.join(profileRoot(id), "backups");
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((n) => /^[\w.-]+\.(tgz|tar\.gz)$/.test(n))
.map((name) => ({
name,
size: fs.statSync(path.join(dir, name)).size,
mtime: fs.statSync(path.join(dir, name)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
}
export async function snapshotProfile(
id: string,
progress: (s: string) => void,
) {
profileById(readNetwork(), id);
const root = profileRoot(id);
const data = path.join(root, "data");
const server = await inspectContainer(serverName(id));
const backup = await inspectContainer(backupName(id));
progress("Stopping writers for a consistent world snapshot");
if (backup.running) await powerContainer(backupName(id), "stop");
if (server.running) await powerContainer(serverName(id), "stop");
try {
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
const name = `snapshot-${Date.now()}.tgz`;
const file = path.join(root, "backups", name);
await exec("tar", ["-czf", `${file}.partial`, "-C", data, "."], {
timeout: 30 * 60_000,
});
await exec(
"python3",
[env.restoreHelper, "verify", "--archive", `${file}.partial`],
{ timeout: 30 * 60_000 },
);
fs.renameSync(`${file}.partial`, file);
return `Verified snapshot: ${name}`;
} finally {
if (server.running) await powerContainer(serverName(id), "start");
if (env.containerRuntime === "apple") {
if (server.running) {
progress(
"Waiting for world readiness and refreshing native container connections",
);
if (backup.running)
await appleRefreshBackup(path.join(root, "compose.json"));
else await appleWaitReady(serverName(id));
}
await refreshAppleGateway();
} else if (backup.running) await powerContainer(backupName(id), "start");
}
}
export async function restoreProfile(
id: string,
name: string,
progress: (s: string) => void,
) {
if (!profileBackups(id).some((b) => b.name === name))
throw new NetworkInputError("Backup not found");
const root = profileRoot(id);
const archive = path.join(root, "backups", name);
await exec("python3", [env.restoreHelper, "verify", "--archive", archive], {
timeout: 30 * 60_000,
});
await stopProfile(id);
progress(
"Restoring verified archive; previous data is retained in the recovery directory",
);
await exec(
"python3",
[
env.restoreHelper,
"restore",
"--archive",
archive,
"--data",
path.join(root, "data"),
"--work",
path.join(root, "recovery"),
],
{ timeout: 30 * 60_000 },
);
return "Restored. Server remains stopped; inspect files and deploy when ready.";
}
export async function profileConsole(id: string, command: string) {
profileById(readNetwork(), id);
if (
typeof command !== "string" ||
!command.trim() ||
command.length > 2000 ||
/[\r\n\0]/.test(command)
)
throw new NetworkInputError("Enter one console command");
const result = await execInContainer(
serverName(id),
["rcon-cli", command.trim().replace(/^\//, "")],
20000,
);
if (result.exitCode)
throw new Error(result.output || "Console command failed");
return result.output;
}
export async function profileLogs(id: string) {
profileById(readNetwork(), id);
const result =
env.containerRuntime === "apple"
? await appleCommand(["logs", "-n", "160", serverName(id)], 15000)
: await exec("docker", ["logs", "--tail", "160", serverName(id)], {
timeout: 15000,
maxBuffer: 512 * 1024,
});
return redactLogs(`${result.stdout}\n${result.stderr}`);
}
function redactLogs(s: string) {
return s.split(env.rconPassword).join("[redacted]").slice(-32000);
}
export { proxyName };
+16
View File
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
// One reentrant queue for power, backup, restore and Compose operations.
const state = globalThis as unknown as {
__craftdeckOperation?: { tail: Promise<unknown>; context: AsyncLocalStorage<boolean> };
};
const queue = state.__craftdeckOperation ??= {
tail: Promise.resolve(), context: new AsyncLocalStorage<boolean>(),
};
export function withServerOperation<T>(operation: () => Promise<T>): Promise<T> {
if (queue.context.getStore()) return operation();
const result = queue.tail.then(() => queue.context.run(true, operation));
queue.tail = result.catch(() => undefined);
return result;
}
+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");
+310
View File
@@ -0,0 +1,310 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/server/env", () => ({
env: {
mcDataDir: "",
appDataDir: "",
projectDir: "",
managed: false,
rconPassword: "test-rcon",
restoreHelper: "/helper.py",
containerRuntime: "docker",
},
}));
vi.mock("../src/server/docker", () => ({
inspectContainer: vi.fn(async () => ({
exists: true,
running: true,
health: "healthy",
})),
powerContainer: vi.fn(async () => {}),
execInContainer: vi.fn(),
networkInfrastructure: vi.fn(async () => ({
hostDataDir: "/srv/data/mc",
network: "stack",
})),
}));
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
execFile: vi.fn((...args: unknown[]) => {
const cb = args.at(-1) as (
err: Error | null,
stdout: string,
stderr: string,
) => void;
cb(null, "", "");
}),
}));
vi.mock("../src/server/apple-container", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/server/apple-container")>()),
appleRefreshBackup: vi.fn(async () => {}),
appleWaitReady: vi.fn(async () => {}),
appleAddress: vi.fn(async () => "192.0.2.25"),
}));
import { env } from "../src/server/env";
import { execFile } from "node:child_process";
import { inspectContainer, powerContainer } from "../src/server/docker";
import { defaultProfile } from "../src/lib/network";
import {
readNetwork,
writeNetwork,
validateProfile,
profileRoot,
} from "../src/server/network-config";
import {
deployProfile,
snapshotProfile,
restoreProfile,
restartProfile,
stopProfile,
queueNetworkJob,
reconcileJobs,
networkBusy,
assertNetworkIdle,
} from "../src/server/network";
import {
appleRunArguments,
appleRefreshBackup,
appleWaitReady,
} from "../src/server/apple-container";
let root: string;
let id: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-ops-"));
env.mcDataDir = path.join(root, "mc");
env.appDataDir = path.join(root, "app");
env.projectDir = root;
env.managed = false;
env.containerRuntime = "docker";
vi.clearAllMocks();
vi.mocked(inspectContainer).mockResolvedValue({
exists: true,
running: true,
health: "healthy",
status: "running",
});
const p = validateProfile({ ...defaultProfile, name: "Lobby" });
id = p.id;
writeNetwork({
schema: 1,
profiles: [p],
proxy: { port: 25566, lobby: id, motd: "Network", version: "latest" },
jobs: [],
});
fs.mkdirSync(path.join(profileRoot(id), "data", "world"), {
recursive: true,
});
fs.writeFileSync(
path.join(profileRoot(id), "data", "world", "level.dat"),
"test",
);
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
vi.unstubAllGlobals();
});
describe("network lifecycle safety", () => {
it("reconnects native backups and gateway routes after a restart changes the world IP", async () => {
env.containerRuntime = "apple";
await restartProfile(id);
expect(powerContainer).toHaveBeenCalledWith(
`craftdeck-${id}-backup`,
"stop",
);
expect(appleRefreshBackup).toHaveBeenCalledWith(
path.join(profileRoot(id), "compose.json"),
);
expect(
fs.readFileSync(
path.join(profileRoot("proxy"), "config", "velocity.toml"),
"utf8",
),
).toContain("192.0.2.25:25565");
expect(powerContainer).toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
expect(appleWaitReady).toHaveBeenCalledWith("craftdeck-velocity", 180);
const backupCall =
vi.mocked(appleRefreshBackup).mock.invocationCallOrder[0];
const gatewayCall = vi
.mocked(powerContainer)
.mock.invocationCallOrder.at(-1)!;
expect(backupCall).toBeLessThan(gatewayCall);
});
it("restores native connections even when snapshot creation fails", async () => {
env.containerRuntime = "apple";
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
return {} as ReturnType<typeof execFile>;
});
await expect(snapshotProfile(id, () => {})).rejects.toThrow("disk full");
expect(appleRefreshBackup).toHaveBeenCalledWith(
path.join(profileRoot(id), "compose.json"),
);
expect(powerContainer).toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
});
it("removes a stopped native world from routes instead of retaining its reusable IP", async () => {
env.containerRuntime = "apple";
const config = readNetwork();
const other = validateProfile({ ...defaultProfile, name: "Other world" });
config.profiles.push(other);
writeNetwork(config);
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
exists: true,
running: name !== `craftdeck-${id}`,
status: "running",
health: "healthy",
}));
await stopProfile(id);
const routes = fs.readFileSync(
path.join(profileRoot("proxy"), "config", "velocity.toml"),
"utf8",
);
expect(routes).not.toContain(id);
expect(routes).toContain(other.id);
expect(readNetwork().proxy.lobby).toBe(id);
});
it("stops the native gateway when its last world is stopped", async () => {
env.containerRuntime = "apple";
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
exists: true,
running: name !== `craftdeck-${id}`,
status: "running",
health: "healthy",
}));
expect(await stopProfile(id)).toContain("Apply routing");
expect(powerContainer).toHaveBeenCalledWith("craftdeck-velocity", "stop");
expect(powerContainer).not.toHaveBeenCalledWith(
"craftdeck-velocity",
"restart",
);
});
it("backup failure cancels deployment and resumes original writers", async () => {
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
return {} as ReturnType<typeof execFile>;
});
await expect(deployProfile(id, () => {})).rejects.toThrow("disk full");
expect(vi.mocked(execFile).mock.calls.some((c) => c[0] === "docker")).toBe(
false,
);
expect(powerContainer).toHaveBeenCalledWith(`craftdeck-${id}`, "start");
expect(powerContainer).toHaveBeenCalledWith(
`craftdeck-${id}-backup`,
"start",
);
expect(fs.existsSync(path.join(profileRoot(id), "compose.json"))).toBe(
false,
);
});
it("does not stop or change a world when map compatibility resolution fails", async () => {
const c = readNetwork();
c.profiles[0].map = "bluemap";
writeNetwork(c);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => [] })),
);
await expect(deployProfile(id, () => {})).rejects.toThrow("No stable");
expect(powerContainer).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
});
it("does not publish an archive until verification succeeds", async () => {
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
const argv = args[1] as string[];
fs.writeFileSync(argv[1], "partial");
(args.at(-1) as (e: null, stdout: string, stderr: string) => void)(
null,
"",
"",
);
return {} as ReturnType<typeof execFile>;
});
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
(args.at(-1) as (e: Error) => void)(new Error("archive invalid"));
return {} as ReturnType<typeof execFile>;
});
await expect(snapshotProfile(id, () => {})).rejects.toThrow(
"archive invalid",
);
expect(
fs
.readdirSync(path.join(profileRoot(id), "backups"))
.every((n) => n.endsWith(".partial")),
).toBe(true);
});
it("validates restore selection before stopping writers", async () => {
await expect(restoreProfile(id, "../../bad.tgz", () => {})).rejects.toThrow(
"Backup not found",
);
expect(powerContainer).not.toHaveBeenCalled();
});
it("marks orphan jobs failed without retrying destructive operations", () => {
const c = readNetwork();
c.jobs = [
{
id: "old",
profileId: id,
action: "restore",
state: "running",
message: "",
startedAt: 1,
},
];
writeNetwork(c);
expect(reconcileJobs(c).jobs[0].state).toBe("failed");
expect(powerContainer).not.toHaveBeenCalled();
});
it("blocks conflicting edits and persists job failures without secrets", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const job = queueNetworkJob(id, "test", async () => {
await gate;
throw new Error(`oops ${env.rconPassword}`);
});
expect(networkBusy()).toBe(true);
expect(() => assertNetworkIdle()).toThrow("Wait");
expect(() => queueNetworkJob(id, "second", async () => "ok")).toThrow(
"Wait",
);
release();
await vi.waitFor(() => expect(networkBusy()).toBe(false));
const saved = readNetwork().jobs.find((j) => j.id === job.id)!;
expect(saved.state).toBe("failed");
expect(saved.message).toBe("oops [redacted]");
});
it("managed deployments reject jobs before changing persistent state", () => {
env.managed = true;
expect(() => queueNetworkJob(id, "start", async () => "ok")).toThrow(
"managed by Komodo",
);
expect(readNetwork().jobs).toEqual([]);
});
it("uses the native Apple CLI with separate arguments and a credential file", () => {
const args = appleRunArguments(
{
image: "itzg/minecraft-server:java21",
container_name: "craftdeck-lobby",
environment: { RCON_PASSWORD: "never-in-argv" },
mem_limit: "3g",
volumes: ["/path with spaces/data:/data"],
ports: ["127.0.0.1:8101:8100"],
},
"craftdeck",
"/private/profile.env",
);
expect(args).toContain("/path with spaces/data:/data");
expect(args).toContain("3G");
expect(args).not.toContain("never-in-argv");
expect(args).not.toContain("docker");
expect(args).toContain("--env-file");
});
});
+240
View File
@@ -0,0 +1,240 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/server/env", () => ({
env: { mcDataDir: "", appDataDir: "" },
}));
import { env } from "../src/server/env";
import {
defaultProfile,
type NetworkConfig,
type WorldProfile,
} from "../src/lib/network";
import {
validateProfile,
validatePorts,
validateProxy,
profileRoot,
readNetwork,
writeNetwork,
profileById,
} from "../src/server/network-config";
import {
composeJson,
renderProfileCompose,
renderVelocity,
serverEnvironment,
} from "../src/server/network-render";
import { networkFilePath } from "../src/server/network-files";
let dir: string;
const make = (overrides: Partial<WorldProfile> = {}) =>
validateProfile({ ...defaultProfile, name: "Test world", ...overrides });
const config = (profiles: WorldProfile[]): NetworkConfig => ({
schema: 1,
profiles,
proxy: {
port: 25566,
lobby: profiles[0]?.id ?? "",
motd: 'Friends "together"',
version: "latest",
},
jobs: [],
});
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-network-"));
env.mcDataDir = path.join(dir, "mc");
env.appDataDir = path.join(dir, "app");
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
describe("isolated server profiles", () => {
it("generates server-owned IDs and ignores caller supplied paths", () => {
const p = make({ id: "../../outside", name: "../Friends ♥", createdAt: 1 });
expect(p.id).toMatch(/^friends-[a-f0-9]{8}$/);
expect(p.createdAt).not.toBe(1);
expect(() => profileRoot("../escape")).toThrow();
});
it.each(["FABRIC", "FORGE", "NEOFORGE", "QUILT", "VANILLA", "MODRINTH"])(
"does not claim transparent proxy support for %s",
(loader) => {
expect(() =>
make({ loader: loader as WorldProfile["loader"], route: "proxy" }),
).toThrow("Automatic secure proxy");
},
);
it("requires exact modpack selection", () => {
expect(() =>
make({ loader: "MODRINTH", route: "direct", modpack: "pack" }),
).toThrow("exact pack version");
const p = make({
loader: "MODRINTH",
route: "direct",
modpack: "pack",
modpackVersion: "version123",
});
expect(serverEnvironment(p, "secret")).toMatchObject({
TYPE: "MODRINTH",
MODRINTH_MODPACK: "pack",
MODRINTH_VERSION: "version123",
});
});
it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
for (const update of [
{ memory: 0 },
{ memory: "4" },
{ pvp: "true" },
{ name: "world\nEULA=TRUE" },
{ resourcePack: "javascript:alert(1)" },
{ mapUrl: "https://user:password@example.com" },
{ version: "latest" },
])
expect(() =>
validateProfile({ ...defaultProfile, name: "world", ...update }),
).toThrow();
});
it("requires checksums and validates resource pack consistency", () => {
expect(() =>
make({ resourcePack: "https://example.com/pack.zip" }),
).toThrow("SHA-1");
expect(() => make({ resourcePackRequired: true })).toThrow("download URL");
const p = make({
resourcePack: "https://example.com/pack.zip",
resourcePackSha1: "a".repeat(40),
resourcePackRequired: true,
});
expect(serverEnvironment(p, "secret")).toMatchObject({
RESOURCE_PACK_ENFORCE: "true",
RESOURCE_PACK_SHA1: "a".repeat(40),
});
});
it("prevents seed, identity, version and runtime changes after initialization", () => {
const p = make();
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
fs.writeFileSync(
path.join(profileRoot(p.id), "data", "server.properties"),
"",
);
for (const update of [
{ seed: "123" },
{ version: "1.21.4" },
{ route: "direct" },
{ loader: "FOLIA" },
])
expect(() => validateProfile({ ...p, ...update }, p)).toThrow(
"Create a new profile",
);
expect(validateProfile({ ...p, memory: 8 }, p).memory).toBe(8);
});
it("persists registry and rejects malformed registry instead of losing profiles", () => {
const p = make();
writeNetwork(config([p]));
expect(readNetwork().profiles[0]).toEqual(p);
expect(profileById(readNetwork(), p.id)).toEqual(p);
fs.writeFileSync(path.join(env.appDataDir, "network.json"), "{");
expect(() => readNetwork()).toThrow();
});
});
describe("routing and network isolation", () => {
it("reserves gateway, primary, dashboard, direct, and map ports together", () => {
for (const port of [25565, 25566, 8080])
expect(() =>
validatePorts(config([make({ route: "direct", port })]), 25565, 8080),
).toThrow("already assigned");
expect(() =>
validatePorts(
config([
make({
route: "direct",
port: 25570,
map: "bluemap",
mapPort: 25570,
}),
]),
25565,
8080,
),
).toThrow("already assigned");
});
it("allows private backends to share internal game ports", () => {
expect(() =>
validatePorts(config([make(), make({ name: "Survival" })]), 25565, 8080),
).not.toThrow();
});
it("rejects duplicate hostname routes and direct-server lobbies", () => {
const p = make({ hostname: "survival.example.com" });
expect(() =>
validatePorts(config([p, make({ hostname: p.hostname })]), 25565, 8080),
).toThrow("Duplicate hostname");
const direct = make({ route: "direct" });
const c = config([direct]);
expect(() => validateProxy(c.proxy, c)).toThrow("Paper/Folia");
});
it("publishes no backend or RCON ports, isolates storage and enables forwarding authentication", () => {
const p = make({ map: "bluemap" });
const rendered = renderProfileCompose(
p,
{ hostDataDir: "/srv/game/data/mc", network: "minecraft_default" },
"secret",
"bluemap:exact",
);
expect(rendered.services.server.ports).toEqual(["127.0.0.1:8101:8100"]);
expect(rendered.services.server.volumes[0]).toBe(
`/srv/game/data/network/${p.id}/data:/data`,
);
expect(rendered.services.backup.volumes[1]).toBe(
`/srv/game/data/network/${p.id}/backups:/backups`,
);
expect(rendered.services.server.environment.ONLINE_MODE).toBe("FALSE");
expect(rendered.services.server.environment.MODRINTH_PROJECTS).toBe(
"bluemap:exact",
);
});
it("direct profiles verify accounts and never inherit primary Fabric mods", () => {
const p = make({ loader: "FORGE", route: "direct" });
const e = serverEnvironment(p, "secret");
expect(e.ONLINE_MODE).toBe("TRUE");
expect(e.MODRINTH_PROJECTS).toBe("");
expect(e).not.toHaveProperty("FABRIC_LOADER_VERSION");
});
it("escapes Compose interpolation in all user strings and credentials", () => {
expect(
JSON.parse(composeJson({ password: "${SECRET}", motd: "Costs $5" })),
).toEqual({ password: "$${SECRET}", motd: "Costs $$5" });
});
it("emits only compatible routes with explicit lobby and modern forwarding", () => {
const lobby = make({ hostname: "lobby.example.com" });
const direct = make({ loader: "FABRIC", route: "direct" });
const text = renderVelocity(config([lobby, direct]));
expect(text).toContain('player-info-forwarding-mode = "modern"');
expect(text).toContain("online-mode = true");
expect(text).toContain(`try = ["${lobby.id}"]`);
expect(text).toContain(`"lobby.example.com" = ["${lobby.id}"]`);
expect(text).not.toContain(direct.id);
expect(text).toContain('motd = "Friends \\"together\\""');
});
});
describe("profile file boundaries", () => {
it("rejects traversal and symlink escapes including nonexistent descendants", () => {
const p = make();
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
fs.mkdirSync(path.join(dir, "outside"));
fs.symlinkSync(
path.join(dir, "outside"),
path.join(profileRoot(p.id), "data", "link"),
);
expect(() => networkFilePath(p.id, "../../other")).toThrow("escapes");
expect(() => networkFilePath(p.id, "link/new/nested.yml")).toThrow(
"escapes",
);
fs.symlinkSync(
path.join(dir, "outside", "missing"),
path.join(profileRoot(p.id), "data", "dangling"),
);
expect(() => networkFilePath(p.id, "dangling")).toThrow();
expect(networkFilePath(p.id, "config/new.yml")).toBe(
path.join(profileRoot(p.id), "data", "config", "new.yml"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { withServerOperation } from "../src/server/operation";
describe("server operation queue", () => {
it("serializes destructive operations while allowing nested backups", async () => {
const events: string[] = [];
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const first = withServerOperation(async () => {
events.push("restore-start");
await withServerOperation(async () => { events.push("nested-backup"); });
await gate;
events.push("restore-finish");
});
const second = withServerOperation(async () => { events.push("power"); });
await new Promise(resolve => setImmediate(resolve));
expect(events).toEqual(["restore-start", "nested-backup"]);
release();
await Promise.all([first, second]);
expect(events).toEqual(["restore-start", "nested-backup", "restore-finish", "power"]);
});
it("a failure does not poison later operations", async () => {
await expect(withServerOperation(async () => { throw new Error("failed"); })).rejects.toThrow("failed");
await expect(withServerOperation(async () => "next")).resolves.toBe("next");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("../src/server/env", () => ({ env: { managed: false, backupsDir: "/missing-test-backups", backupContainer: "backup", projectDir: "/missing-project" } }));
vi.mock("../src/server/docker", () => ({
execInContainer: vi.fn(async () => ({ exitCode: 1, output: "backup disk full" })),
powerContainer: vi.fn(), inspectContainer: vi.fn(), DockerError: class extends Error {},
}));
vi.mock("../src/server/db", () => ({ logActivity: vi.fn() }));
vi.mock("../src/server/notify", () => ({ notify: vi.fn() }));
vi.mock("../src/server/compose", () => ({ applyMcConfig: vi.fn(), applyBackupConfig: vi.fn() }));
import { env } from "../src/server/env";
import { safetyBackup } from "../src/server/backups";
import { execInContainer } from "../src/server/docker";
import { applyMcConfig } from "../src/server/compose";
import { writeEnvFile } from "../src/server/envfile";
import { writeTextFile, writeBinaryFile, deletePath, makeDir } from "../src/server/files";
import { POST } from "../src/app/api/config/apply/route";
import { POST as applyMods } from "../src/app/api/mods/apply/route";
beforeEach(() => { env.managed = false; vi.clearAllMocks(); });
describe("required backups and managed deployment", () => {
it("does not swallow a failed safety backup", async () => {
await expect(safetyBackup("upgrade")).rejects.toThrow("disk full");
});
it("aborts a config apply even when a client asks to skip backup", async () => {
const response = await POST(new NextRequest("http://localhost/api/config/apply", {
method: "POST", body: JSON.stringify({ backupFirst: false }),
}));
expect(response.status).toBe(500);
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("also aborts mod apply after a failed backup", async () => {
expect((await applyMods()).status).toBe(500);
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("rejects managed config apply before executing anything", async () => {
env.managed = true;
const response = await POST(new NextRequest("http://localhost/api/config/apply", { method: "POST", body: "{}" }));
expect(response.status).toBe(409);
expect(execInContainer).not.toHaveBeenCalled();
expect(applyMcConfig).not.toHaveBeenCalled();
});
it("guards direct config and file mutation helpers", () => {
env.managed = true;
for (const change of [
() => writeEnvFile({ MC_VERSION: "latest" }),
() => writeTextFile("server.properties", "bad"),
() => writeBinaryFile("mods/unreviewed.jar", Buffer.from("bad")),
() => deletePath("world"), () => makeDir("mods"),
]) expect(change).toThrow("managed by Komodo");
});
});
+76
View File
@@ -0,0 +1,76 @@
import importlib.util
import io
import json
from pathlib import Path
import tarfile
import tempfile
import unittest
spec = importlib.util.spec_from_file_location("restore_data", Path(__file__).parents[1] / "scripts/restore_data.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class RestoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.data = self.root / "data"
self.data.mkdir()
(self.data / "newer-only.dat").write_text("preserve me")
self.archive = self.root / "backup.tgz"
def make_archive(self, files, link=False):
with tarfile.open(self.archive, "w:gz") as tar:
for name, content in files.items():
info = tarfile.TarInfo(name)
payload = content.encode()
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
if link:
info = tarfile.TarInfo("world/escape")
info.type, info.linkname = tarfile.SYMTYPE, "/etc"
tar.addfile(info)
def test_clean_restore_keeps_previous_and_bind_root(self):
self.make_archive({"./world/level.dat": "old world"})
inode = self.data.stat().st_ino
result = module.restore(self.archive, self.data, self.root / "restore")
self.assertEqual(self.data.stat().st_ino, inode)
self.assertFalse((self.data / "newer-only.dat").exists())
self.assertEqual((self.data / "world/level.dat").read_text(), "old world")
self.assertEqual((Path(result["previous_data"]) / "newer-only.dat").read_text(), "preserve me")
def test_traversal_rejected_before_mutation(self):
self.make_archive({"world/level.dat": "old", "../outside": "bad"})
with self.assertRaises(ValueError): module.restore(self.archive, self.data, self.root / "restore")
self.assertTrue((self.data / "newer-only.dat").exists())
def test_link_rejected(self):
self.make_archive({"world/level.dat": "old"}, link=True)
with self.assertRaises(ValueError): module.verify(self.archive)
def test_release_mismatch_keeps_current_world(self):
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": '{"minecraft":"26.2"}'})
lock = self.root / "lock.json"
lock.write_text('{"minecraft":"26.3"}')
with self.assertRaisesRegex(ValueError, "lock differs"):
module.restore(self.archive, self.data, self.root / "restore", lock)
self.assertTrue((self.data / "newer-only.dat").exists())
def test_matching_release_can_restore(self):
lock = self.root / "lock.json"
lock.write_text('{"minecraft":"26.3"}')
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": lock.read_text()})
module.restore(self.archive, self.data, self.root / "restore", lock)
self.assertTrue((self.data / "world/level.dat").exists())
def test_truncated_archive_rejected(self):
self.make_archive({"world/level.dat": "old"})
self.archive.write_bytes(self.archive.read_bytes()[:-12])
with self.assertRaises((EOFError, tarfile.TarError)):
module.verify(self.archive)
if __name__ == "__main__": unittest.main()
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: { environment: "node", include: ["tests/**/*.test.ts"] },
});
+26 -24
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
# Apple container for local tests; x86_64 Docker for production
The production target is an **x86_64 Linux host** running the normal Docker Compose
stack. The Dockerfile uses BuildKit's target architecture when installing the Docker
CLI and Compose plugin, so ARM64 local builds and x86_64 production builds each get
the correct binaries. No architecture is forced in the production Compose file.
The Mac development paths use Apple's official `container` CLI. Docker Desktop is
not required. The local data folders are isolated from production data and ignored by Git.
## Native world-server testing on macOS
Prerequisites: Apple silicon, supported macOS, Apple's `container` installed and its
system service running, Node 26, npm, Python 3.12+ (restore helper), and `npm ci` in
`dashboard/`.
```
./scripts/apple-container-dev.sh
```
The control process runs on the Mac at **http://localhost:8081/network**. Credentials
are generated once in `data/apple-native/panel.env` (username `admin`). That process
must run on macOS to invoke Apple's CLI. Minecraft, backups and Velocity run inside
Apple Linux containers. This is intentionally separate from the containerized UI preview.
With `CONTAINER_RUNTIME=apple`, Worlds & Network uses:
- `container image pull` and `container run` for runtime deployment;
- `container inspect`, `stats`, `logs`, `exec`, `stop` and `start` for management;
- a dedicated `craftdeck` virtual network, unless `APPLE_CONTAINER_NETWORK` overrides it;
- Minecraft protocol health checks before reporting servers ready;
- actual container IPs for backup RCON and Velocity backend routes;
- explicit DNS (1.1.1.1 by default; set `APPLE_CONTAINER_DNS` for your resolver);
- the same profile validation, forwarding authentication, backups and restore workflows.
Start all routed servers before initially applying the gateway. Native VM IPs can
change on every stop/start. Panel restart, deploy, snapshot and restore operations
refresh backup RCON and running gateway routes automatically. Updating those routes
restarts Velocity and disconnects its players, even if only one world changed.
Stopped worlds are removed from native routes; an available world temporarily serves
as the lobby if the chosen lobby is offline. If no routed world is ready, the gateway
stops; start a world and apply routing to bring it back. After changing containers
outside the panel, use Restart for each affected profile and apply routing again.
Apple CLI does not implement
Docker restart policies or Compose health scheduling: the adapter waits for startup,
but does not provide a continuous crash/reboot supervisor. Use the production Docker
stack for unattended hosting. A native snapshot uses macOS tar/Python and can be
slower on shared mounts. Apple maps host files into each VM; filesystem ownership
behavior can differ from the Linux production host.
The original primary-server pages still expect their configured `MC_CONTAINER` and
RCON endpoint. The local script creates no primary server; those pages remain offline
until one is explicitly configured. The new network page is the local test surface.
Primary `.env`/Compose configuration apply remains a Docker-only workflow.
Stop individual test servers through the network page (this also stops their backup
writer), or use `container stop --time 90 <name>`. Stop the gateway with
`container stop craftdeck-velocity`. Ctrl-C stops the native control process; it does
not stop already-running Minecraft containers. All test worlds persist under
`data/apple-native/network/`.
## Containerized panel preview
For image/UI/API smoke testing without server orchestration:
```
./scripts/apple-container-preview.sh build
./scripts/apple-container-preview.sh start
```
Open **http://localhost:8080/network**. The login is `admin`; the generated password is
in `data/apple-preview/preview.env`. Stop it with:
```
./scripts/apple-container-preview.sh stop
```
A Linux panel container cannot call the macOS Apple CLI and Apple container does not
supply a Docker Engine socket. Use the native host process above when testing actual
server controls. The image preview faithfully reports missing Docker infrastructure.
## x86_64 production image
Build the production architecture explicitly on the Mac:
```
container build --platform linux/amd64 --cpus 4 --memory 4G \
-t craftdeck:x86-local dashboard
```
Apple's Rosetta support can run that image for an additional local smoke test:
```
mkdir -p data/apple-x86
container run -d --name craftdeck-x86-preview --arch amd64 --rosetta \
--cpus 2 --memory 1G -p 127.0.0.1:8082:8080 \
--env-file data/apple-preview/preview.env \
-v "$PWD/data/apple-x86:/preview" craftdeck:x86-local
```
This verifies an x86_64 Linux process on the Mac; it is not a performance benchmark
of the real x86_64 server. On the Linux host, use the repository's normal setup and
`docker compose up -d --build`. Network orchestration defaults to Docker there.
Reference: [Apple container documentation](https://github.com/apple/container/tree/main/docs).
+10 -48
View File
@@ -1,57 +1,19 @@
# Backups & restore
# Backups and restore
## What runs automatically
The backup sidecar archives server data every 24 hours by default and keeps seven days locally. It flushes the world through RCON first and excludes temporary files, caches and logs. Manual panel backups wait for a newly created archive to pass complete gzip/tar validation. A request acknowledgment alone is not backup success.
The `backup` sidecar (itzg/mc-backup) tars the entire server data directory to
`data/backups/world-<timestamp>.tgz`:
Config and mod Apply require a successful safety backup; failure cancels the operation. Standalone world reset does too. Orion managed mode rejects configuration changes and world reset entirely; upgrades are reviewed in homelab-infra.
- flushes the world to disk first (`save-all`), safe while players are online
- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
- excludes caches and logs
## Same-release world restore
Safety backups are also taken automatically before config applies (when enabled) and
world resets.
Use the Backups page, or `scripts/restore.sh /absolute/path/backup.tgz` as the standalone fallback. Orion's fallback takes the extra arguments `/opt/minecraft/project /opt/minecraft/data`.
## Manual backup
The panel stops Minecraft and the backup writer, validates archive paths and the managed release lock, extracts a clean staging tree, and moves the existing data into a retained `restore-*/previous` directory. It preserves the bind-mount root and correct server ownership. Files that only existed in the newer world are not left active. On failure the writers remain stopped for inspection. The shell fallback also stops the panel to avoid competing requests.
- Panel: Backups page → **Back up now**
- CLI: `docker exec mc-backup backup now`
Do not overlay an archive with `tar -xzf` onto a populated world. Keep the world and staging workspace under a common parent mount, on the same filesystem. Reserve disk for the active world, staged copy and retained previous data, and remove retained copies only after confirming a successful recovery.
## Restoring
## Complete recovery
Panel: Backups page → **Restore** → type `restore`. The panel stops the server,
extracts the tar over the data directory, and starts the server again. Everything since
that backup is lost.
A world archive contains Minecraft and EasyAuth state. It does not contain CraftDeck's database, deployment config, proxy secret or panel credentials. For Orion use `homelab-infra/stacks/minecraft/cold-backup.sh`: it stops the stack and captures the complete deployment plus an export of the pinned panel image. Copy the secret-bearing backup off-host with private permissions.
CLI fallback (panel down):
```bash
./scripts/restore.sh data/backups/world-20260710-050000.tgz
```
## Off-site copies (recommended)
`data/backups/` is just files — sync it anywhere:
```bash
# example: nightly rsync to another machine (add to the host's crontab)
0 6 * * * rsync -a --delete /path/to/minecraft/data/backups/ backup-host:/srv/mc-backups/
```
You can also download any backup from the panel (⬇ button) before risky experiments.
## Disaster recovery (new machine)
```bash
git clone <repo> minecraft && cd minecraft
./scripts/setup.sh # fresh secrets — friends keep their passwords,
# those live in the world data, not in .env
mkdir -p data/backups && cp <your-backup>.tgz data/backups/
docker compose up -d # let it initialize once
./scripts/restore.sh data/backups/<your-backup>.tgz
```
The backup contains the world, mods, configs, and the EasyAuth database, so player
registrations survive. Panel state (approval history, metrics) lives in
`data/dashboard/` — include it in your off-site sync if you want it back too.
Restore a different release only with its matching complete backup, release lock and image. Keep the failed state recoverable, restore into a clean directory and recreate containers when replacing mount roots. Do not downgrade Minecraft against a newer world. Test the complete restore in isolation before relying on it. See [orion.md](orion.md) and the infrastructure runbook for the release workflow.
+4
View File
@@ -54,3 +54,7 @@ setup and add a `caddy` service; the panel works fine behind a reverse proxy
Performance & Lag page. Server-side lag shows as low TPS / high MSPT (affects everyone
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.
+23
View File
@@ -0,0 +1,23 @@
# Orion managed deployment
The authoritative Compose file, release lock, initial preparation script and OPNsense instructions are in `geneticglitch1/homelab-infra`, under `stacks/minecraft/`. Deploy that stack through Komodo on Orion. Do not deploy this repository's root Compose file on top of it.
The release selects Minecraft 26.3, Java 25, Fabric loader 0.19.5, launcher 1.1.2 and exact Modrinth version IDs. Velocity 4.2.0 build 30 consumes TCPShield PROXY protocol; FabricProxy-Lite handles authenticated modern forwarding to the private backend. Offline mode, EasyAuth and the whitelist remain the existing authentication model. Check player UUIDs against a copied world before a migration.
CraftDeck uses `PANEL_MANAGED=true`. Config, mod and file writes, Compose apply and world reset are rejected by the backend. The UI identifies managed mode. Console, player administration, metrics, power controls and backups remain available. These admin controls still have broad authority; managed mode prevents accidental configuration drift and is not a security boundary around the Docker socket.
Persistent state lives in `/opt/minecraft/data/{mc,backups,dashboard,restore}`. CraftDeck mounts their common parent at `/server-data`; this is necessary for Linux rename operations during restore. The deployment lives in `/opt/minecraft/project`, mounted read-only at the same path. Never put a world under a disposable Komodo source checkout.
Build the reviewed source commit on Orion using the infrastructure preparation script. It records Docker's local immutable image ID. Komodo uses that ID with `pull_policy: never`; a restart cannot pull a newer panel. Preparation refuses existing containers or an unknown world. It does not accept the Minecraft EULA or start services.
The panel listens only at `192.168.1.88:8080`, reachable through LAN/VPN and the host firewall. Never forward this port on OPNsense or publish it with NPM. The Docker socket grants host control; keep the panel credentials private.
## Restore and upgrade behavior
A required safety backup failure aborts the operation. A backup request is successful only after a newly created archive passes a complete gzip/tar validation. Power, backup, restore and Compose operations share one reentrant queue within the panel process.
Restores stop both Minecraft and the backup sidecar, validate paths and the managed deployment lock, unpack into a clean staging directory, then move the previous data aside without replacing the bind-mount root. Newer-only files are removed from the active world and retained with the previous data. Failure leaves writers stopped; inspect the recorded restore directory before recovery. Restored files retain the current server directory's ownership.
The panel accepts only backups with the current release lock. For a different release, use a complete stopped backup and the matching deployment/image, following the infrastructure runbook. A world archive does not include the panel database, proxy state or host secrets. Use `cold-backup.sh` for the complete deployment and copy it off Orion.
The root standalone Compose file uses the same restore mount layout. Existing standalone installs must recreate the dashboard with the new mounts and set `PROJECT_DIR` to the absolute host checkout path before using Apply or Restore. `scripts/setup.sh` fills that path for new installs. The manual restore fallback is documented in `scripts/restore.sh`; it stops the panel too, avoiding concurrency with web requests.
+8 -77
View File
@@ -1,84 +1,15 @@
# TCPShield (DDoS protection + real player IPs)
# TCPShield on Orion
[TCPShield](https://tcpshield.com) sits in front of the server: players connect to
your TCPShield domain, and your real server IP stays hidden and DDoS-protected.
The catch: without extra setup, every connection appears to come from TCPShield's
proxies, so the Security page, IP bans, and connection logs are useless.
Use the maintained runbook in `homelab-infra/stacks/minecraft/TCPSHIELD.md` for the exact OPNsense and host firewall steps.
TCPShield's own RealIP plugin is Bukkit/Bungee/Velocity-only — it does not exist
for Fabric. The working path for this stack is **PROXY protocol v2** plus the
**Proxy Protocol Support** Fabric mod.
Traffic flows: player → TCPShield → OPNsense source-restricted TCP 25565 DNAT → Orion Velocity → private Fabric backend. Enable PROXY protocol in TCPShield and `haproxy-protocol = true` in Velocity. Velocity then uses modern forwarding, with the same secret as `FABRIC_PROXY_SECRET` in FabricProxy-Lite.
## Setup
The reviewed deployment pins Minecraft 26.3 and Velocity 4.2.0 build 30. The direct Fabric `proxy-protocol-support` mod did not advertise 26.3 support at review time, so it is not used. The old `config/patches` files are legacy and are no longer mounted by the standalone Compose file. Do not combine that legacy direct-server guide with the Velocity deployment.
1. **TCPShield panel** → your backend → enable **Proxy Protocol**.
2. **Mods page** → Recommended → add **Proxy Protocol Support** → Apply
(or append `proxy-protocol-support` to `MODRINTH_PROJECTS` in `.env` and
`docker compose up -d mc`).
3. Nothing else — the mod's configuration is **enforced automatically on every
boot** by `config/patches/patch-set.json` (see below). Restart once after
installing so the patch applies:
OPNsense must preserve the TCPShield source address and permit only the official IPv4 ranges from https://tcpshield.com/v4/. Host DOCKER-USER rules enforce the same restriction before trusted-LAN exceptions. Do not add game port 25565 to unrestricted public ports. Do not expose the backend, RCON, panel or a map port.
```bash
docker compose restart mc
```
The public Minecraft hostname is a DNS-only CNAME to the hostname assigned by TCPShield. An ordinary Cloudflare orange-cloud HTTP proxy is not a Minecraft proxy. Use the public protected hostname even from home; direct LAN joins intentionally fail on the protected proxy port.
## Verifying it works
Verify an external join, real player IP, EasyAuth registration, whitelist/UUID continuity and direct-origin rejection before inviting players. Configure TCPShield for the exact protected hostname and backend; account/plan controls and any account-specific verification steps must be completed in its dashboard.
Watch the startup log (`docker compose logs -f mc`). You must see all three:
```
Proxy Protocol Support is enabled!
TCPShield integration enabled! Fetching official IPs...
Successfully added 2 TCPShield IPs to the trusted proxy list.
```
Then join through your TCPShield domain — the console shows your **real IP**, and
`docker compose ps` shows `mc` **healthy**.
## How the config is managed (don't hand-edit!)
The mod rewrites `config/proxy_protocol_support.json` at every startup, which
silently clobbers hand edits. Instead, the server image applies
`config/patches/patch-set.json` from this repo **before every server start**,
enforcing:
| Setting | Value | Why |
|---|---|---|
| `enableProxyProtocol` | `true` | decode PROXY protocol headers |
| `whitelistTCPShieldServers` | `true` | auto-fetch TCPShield's proxy IP ranges — no hardcoding, survives their infra changes |
| `proxyServerIPs` | `127.0.0.1` | (TCPShield's ranges are added automatically at runtime) |
| `directAccessIPs` | localhost (`127.0.0.1`, `::1`) + private ranges (`10/8`, `172.16/12`, `192.168/16`) | container healthcheck + LAN/panel access |
To change a value, edit the patch file in the repo and `docker compose up -d mc`.
**First-boot note:** on a brand-new install the mod's config file doesn't exist
yet when patches run, so the very first boot uses mod defaults; every boot after
that is patched. One `docker compose restart mc` after first install settles it.
## Security model
PROXY protocol headers carry no signature, so anyone who finds your raw server IP
could spoof client IPs — the mod prevents this with default-deny:
- PROXY headers are only accepted from **TCPShield's published ranges** (auto-fetched)
- Direct, header-less connections are only accepted from **localhost + LAN**
- Everything else → `REJECTED unauthorized direct connection` in the log
So occasional `REJECTED` lines from random public IPs are the wall working —
that's scanner bots hitting your raw IP and bouncing. A `REJECTED ... /0:0:0:0:0:0:0:1`
(IPv6 localhost) every few seconds means the container healthcheck is being
blocked — that's exactly what the `::1` entry in the patch prevents; if you see
it, the patch didn't apply (check `docker compose logs mc | head -50`).
For belt-and-braces, also enable TCPShield's Advanced Firewall (panel → Network →
Firewall), and consider host-firewalling port 25565 to TCPShield's ranges
(`curl tcpshield.com/v4`) once everything works.
## Gotchas
- **Friends must connect via the TCPShield domain** — direct-IP connections from
outside your LAN are rejected by design once this is active.
- If real players start bouncing with `REJECTED`, TCPShield may have changed
their IP ranges *and* the auto-fetch failed at boot — restart the server so it
re-fetches.
Sources: [TCPShield documentation](https://docs.tcpshield.com/), [Velocity forwarding](https://docs.papermc.io/velocity/player-information-forwarding/), [OPNsense NAT](https://docs.opnsense.org/manual/nat.html).
+6 -34
View File
@@ -1,39 +1,11 @@
# Updating
# Reviewed updates
## Upgrading Minecraft (e.g. 26.2 → 26.3)
For Orion, edit `homelab-infra/stacks/minecraft/release-lock.json` and Compose together. Verify the Minecraft, Java, Fabric, mod and Velocity compatibility matrix against upstream releases. Build and test the exact CraftDeck commit; record its immutable local image ID. No production setting should use `latest` without an image digest or an unversioned mod slug.
1. **Configuration** page → change `Minecraft version` → **Save**
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.
+157
View File
@@ -0,0 +1,157 @@
# Worlds, maps, and a server network
CraftDeck now has **Worlds & Network**, **Live Maps**, and a **Capabilities** guide.
The original Overview, Players, Access, Performance, Schedules and other pages still
manage the primary server. The network page manages additional independent servers;
its Manage dialog scopes console, files, backups and restore to the selected server.
## Quick workflow
1. Open **Worlds & Network → Create server**. Create a Paper lobby, choose an exact
Minecraft version and matching Java runtime, and enter allowed account usernames.
2. Save, then **Deploy**. This downloads the runtime and waits for Minecraft health.
Progress and failures appear in Operations even if you leave the page.
3. Add another Paper or Folia world with a different seed. Both servers can run at once.
4. Select the lobby under Network gateway, save, and **Apply routing**.
5. Join the gateway's port, then use `/server <server-id>` to switch servers. IDs appear
on the cards. Optional hostname routes can enter a server directly through Velocity.
The gateway defaults to **25566**, preserving the primary server's **25565** binding.
To keep one public address at port 25565, your router can forward that external port
into the gateway's host port 25566. Do not bind two services to the same host port.
Changing the lobby and applying routing makes that world the network's entry point.
It restarts the proxy and disconnects current connections; it is not a live migration.
A proxy profile's game and RCON ports are not published. Velocity authenticates
Minecraft accounts and uses modern forwarding with a shared secret; Paper/Folia
are configured to verify that forwarding. All new servers enforce a whitelist.
These identities differ from the primary server's offline/EasyAuth accounts. The
panel does not migrate inventories or authentication databases between them.
## Runtime choices and isolated worlds
| Runtime | Connection | Content |
| --- | --- | --- |
| Paper | Velocity or direct | Paper-compatible plugins |
| Folia | Velocity or direct | Plugins explicitly compatible with Folia |
| Fabric / Forge / NeoForge / Quilt | Direct | Matching loader mods and clients |
| Vanilla | Direct | Vanilla clients and datapacks |
| Modrinth modpack | Direct | Exact pack/version, loader supplied by pack |
Automatic modern forwarding configuration targets Paper/Folia **1.19+**. Fabric and
modded proxy integrations require additional compatible mods and version-specific
setup; this panel deliberately does not auto-route those profiles. A proxy cannot
swap the client's installed modpack. Switching to an incompatible modpack requires
changing the client installation and reconnecting.
Each profile has a separate full data directory, including all dimensions, mods,
plugin configs, inventories and logs. The Copy settings button creates a **fresh
world**, not a clone of terrain. Change its seed or runtime before first deployment.
After initialization, runtime, Minecraft version, Java, modpack version, route and
seed are immutable in the profile editor. Create another profile to explore a new
version or pack without converting existing terrain accidentally. There is no
in-place downgrade or automatic world-conversion feature.
Java 8, 17, 21 and 25 images are available as choices; choose the release required by
your exact Minecraft/loader combination. Choosing a runtime does not verify that
all uploaded mods support it. Modrinth modpacks are checked against the selected
Minecraft version before deployment. Profile runtime images currently use Java tags;
for reproducible production deployments, review and pin the generated image digests.
Folia distributes independent regions across threads. It does not make every plugin
thread-safe or guarantee a player count/TPS. Players clustered in one region, CPU
contention and disk pressure can still cause lag. Allocate RAM for the sum of all
running heaps, at least 1 GiB per server overhead, the proxy, maps and the host OS.
## Maps
Choose BlueMap or Dynmap on a supported profile. Deployment resolves a stable release
advertising the exact Minecraft version and loader; it refuses deployment if no such
release is found. It does not fall back to an incompatible JAR. For a manually installed
renderer, leave automatic installation off and use its own configuration and ingress.
- BlueMap: internal web port **8100**, 3D terrain rendering.
- Dynmap: internal web port **8123**, 2D/isometric map rendering.
- Each maps to its own **host loopback** port. Publish through an HTTPS reverse proxy
or use an SSH tunnel. Ports are not exposed on every network interface by default.
- Save the resulting browser URL in the profile and open **Live Maps**.
- BlueMap requires its own asset-download consent in its generated config. Stop the
server, edit it through Manage → Files, and restart after reviewing that setting.
- Initial rendering can be expensive. Configure renderer threads, map dimensions,
player visibility, permissions, and privacy in the renderer's files.
- Claims need an addon compatible with the renderer and claim plugin. No claims data
is fabricated by CraftDeck. The viewer displays the renderer's real output.
The embedded viewer requires a working reachable URL; it does not prove map health.
HTTPS pages cannot embed insecure HTTP content. Renderer CSP/framing headers can
require opening the full map in its own tab.
## Resource packs and content
Set a direct ZIP URL, the file's SHA-1, an optional prompt and whether accepting the
pack is required. Deploy applies these server properties. Use a publicly reachable
static file compatible with the client version. The panel configures distribution;
it does not host the ZIP or install client mods. Compute a checksum on macOS with
`shasum -a 1 pack.zip`.
Use comma-separated Modrinth slugs (optionally `slug:version-id`) for managed mods or
plugins. Manage → Files can upload JARs into `mods/` or `plugins/`, and datapack ZIPs
into `world/datapacks/`. Config editing and uploads require the server to be stopped.
There is a 2 MB text editor limit and 100 MB upload limit. Uploads do not overwrite
existing files. Use the profile's Console to manage whitelist entries, gamerules,
weather, time, permissions and supported plugin commands. The saved profile whitelist
is authoritative at deployment, so also update it when adding permanent players.
## Backups, recovery and failures
Every server has a dedicated backup sidecar with an interval and retention policy.
The backup data and output directories belong to that profile only. Manual snapshots
briefly stop Minecraft and the sidecar, write to a partial archive, verify its contents,
then publish it as a completed snapshot. Writers resume afterward. Applying changes
to an existing world requires a verified snapshot first; backup failure cancels apply.
Restore verifies the archive before stopping writers, stages the data with the shared
restore helper, and retains previous contents under `recovery/`. The server remains
stopped after restore for inspection. Restart or deploy it once ready. Manual snapshots
are recovery points; monitor their disk usage in addition to automatic retention.
Operations are serialized with existing primary-server power/backup operations. Their
status is persisted. After a panel crash, unfinished jobs are marked failed and are
**not retried automatically**. Inspect actual container status and logs before retrying.
An unsuccessful deploy can leave the selected server stopped or unhealthy; it does
not claim success or automatically replace data. Proxy config retains a `.previous`
copy before applying changes.
Runtime storage (never commit):
```
data/dashboard/network.json # profiles, gateway settings, jobs
data/network/<id>/compose.json # generated Compose + RCON credential
data/network/<id>/data/ # complete Minecraft server
data/network/<id>/backups/ # this server's archives
data/network/<id>/recovery/ # retained pre-restore data
data/network/proxy/config/velocity.toml # routes
data/network/proxy/config/forwarding.secret
data/network/proxy/data/ # proxy runtime and plugins
```
Back up the registry and proxy configuration along with server archives. Local backups
are not off-host disaster recovery. On the production Docker host, deployment expects the standalone primary
server's bind-mounted `/data` directory and one user-defined Docker network. Komodo
managed installations stay read-only and should be extended through homelab-infra.
For native Mac testing using Apple’s container CLI, see [apple-container.md](apple-container.md).
## What else is possible?
The in-panel **Capabilities** guide distinguishes implemented features from addons:
claims/towns, economies, cross-server inventories, permissions, rollback, anti-cheat,
chunk pregeneration, voice chat, Discord bridges, Bedrock bridging, version translation,
custom dimensions, minigames and off-host backups. Those integrations need their own
compatible plugins, ports, databases and configuration; they are not preinstalled.
References: [Velocity compatibility](https://docs.papermc.io/velocity/server-compatibility/),
[modern forwarding](https://docs.papermc.io/velocity/player-information-forwarding/),
[Folia](https://docs.papermc.io/folia/),
[BlueMap installation](https://bluemap.bluecolored.de/wiki/getting-started/Installation.html),
[Docker server properties](https://docker-minecraft-server.readthedocs.io/en/latest/configuration/server-properties/),
[Modrinth modpacks](https://docker-minecraft-server.readthedocs.io/en/latest/types-and-platforms/mod-platforms/modrinth-modpacks/).
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Native macOS control process + Apple Linux containers for local server testing.
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
local_dir="$project_dir/data/apple-native"
command -v container >/dev/null
container system status >/dev/null
mkdir -p "$local_dir/project"
if [[ ! -f "$local_dir/panel.env" ]]; then
python3 - "$local_dir/panel.env" <<'PY'
import os, secrets, sys
fd=os.open(sys.argv[1],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as f:
for key in ('PANEL_SESSION_SECRET','PANEL_ADMIN_PASSWORD','RCON_PASSWORD'):
f.write(key+'='+secrets.token_hex(24)+'\n')
PY
fi
set -a
source "$local_dir/panel.env"
set +a
export CONTAINER_RUNTIME=apple
export APPLE_CONTAINER_CLI="$(command -v container)"
export APP_DATA_DIR="$local_dir/dashboard"
export MC_DATA_DIR="$local_dir/mc"
export BACKUPS_DIR="$local_dir/backups"
export PROJECT_DIR="$local_dir/project"
export RESTORE_HELPER="$project_dir/dashboard/scripts/restore_data.py"
export RESTORE_WORK_DIR="$local_dir/recovery"
export NEXT_TELEMETRY_DISABLED=1
export PORT="${CRAFTDECK_LOCAL_PORT:-8081}"
echo "Native Apple runtime panel: http://localhost:$PORT/network"
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $local_dir/panel.env"
cd "$project_dir/dashboard"
exec npm run dev -- --hostname 127.0.0.1 --port "$PORT"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Build and run the ARM64 panel using Apple's container CLI, without Docker Desktop.
# This is a local UI/API preview; Apple container does not expose a Docker Engine API.
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
preview_dir="$project_dir/data/apple-preview"
command -v container >/dev/null || { echo "Install Apple's container CLI first." >&2; exit 1; }
container system status >/dev/null
mkdir -p "$preview_dir"
if [[ ! -f "$preview_dir/preview.env" ]]; then
python3 - "$preview_dir/preview.env" <<'PY'
import os, secrets, sys
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as f:
f.write('PANEL_SESSION_SECRET=' + secrets.token_hex(32) + '\n')
f.write('PANEL_ADMIN_PASSWORD=' + secrets.token_urlsafe(20) + '\n')
f.write('APP_DATA_DIR=/preview/dashboard\nMC_DATA_DIR=/preview/mc\nBACKUPS_DIR=/preview/backups\nPROJECT_DIR=/preview/project\n')
PY
fi
case "${1:-start}" in
build) container build --platform linux/arm64 --cpus 4 --memory 4G -t craftdeck:network-local "$project_dir/dashboard" ;;
start)
if container inspect craftdeck-preview >/dev/null 2>&1; then
container start craftdeck-preview
else
container run -d --name craftdeck-preview --cpus 2 --memory 1G \
-p 127.0.0.1:8080:8080 --env-file "$preview_dir/preview.env" \
-v "$preview_dir:/preview" craftdeck:network-local
fi
echo "Panel: http://localhost:8080/network"
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $preview_dir/preview.env"
echo "Docker orchestration requires the regular Docker deployment; this is an Apple-container UI/API preview."
;;
stop) container stop craftdeck-preview ;;
*) echo "Usage: $0 {build|start|stop}" >&2; exit 1 ;;
esac
+27 -23
View File
@@ -1,25 +1,29 @@
#!/usr/bin/env bash
# Manual backup restore (fallback for when the dashboard is unavailable).
# The dashboard's Backups page does this same procedure with one click.
#
# Usage: ./scripts/restore.sh data/backups/world-20260710-120000.tgz
# Clean restore fallback. Python 3.12+ required. Retains the prior world.
# Standalone: scripts/restore.sh /absolute/backup.tgz
# Orion: scripts/restore.sh /absolute/backup.tgz /opt/minecraft/project /opt/minecraft/data
set -euo pipefail
cd "$(dirname "$0")/.."
archive="${1:-}"
[[ -n "$archive" && -f "$archive" ]] || {
echo "Usage: $0 <backup .tgz file>"
echo "Available backups:"
ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)"
exit 1
}
echo "This will STOP the server and overwrite the current world with:"
echo " $archive"
read -r -p "Type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; }
docker compose stop mc
tar -xzf "$archive" -C data/mc
docker compose start mc
echo "✔ Restore complete — server starting."
repo=$(cd "$(dirname "$0")/.." && pwd)
archive=${1:?Supply an existing archive}
archive=$(realpath "$archive")
project=${2:-$repo}
data=${3:-$repo/data}
[[ -f "$archive" ]] || exit 1
helper="$repo/dashboard/scripts/restore_data.py"
python3 "$helper" verify --archive "$archive"
echo "Restore $archive into $data/mc; retain current data in $data/restore."
read -r -p "Type 'restore' to stop the stack and continue: " confirm
[[ "$confirm" == restore ]] || exit 1
cd "$project"
export PROJECT_DIR="$project"
# Stop the panel as well: its in-process queue cannot coordinate with this script.
running=$(docker compose ps --status running --services)
docker compose stop dashboard backup mc
args=(restore --archive "$archive" --data "$data/mc" --work "$data/restore")
[[ ! -f "$project/release-lock.json" ]] || args+=(--lock-file "$project/release-lock.json")
# On failure, leave writers stopped for inspection.
python3 "$helper" "${args[@]}"
for service in mc backup dashboard; do
if [[ $'\n'"$running"$'\n' == *$'\n'"$service"$'\n'* ]]; then docker compose start "$service"; fi
done
echo 'Restore complete. Previous data was retained in the restore workspace.'
+3 -1
View File
@@ -21,7 +21,9 @@ sed -e "s|^RCON_PASSWORD=.*|RCON_PASSWORD=${rcon_pw}|" \
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
.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"