26 changed files with 4224 additions and 4 deletions

No files matched your search

+6
View File
@@ -11,6 +11,9 @@ A production-quality, self-hosted Minecraft **Java 26.3** 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
| | |
@@ -67,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
+2
View File
@@ -2,3 +2,5 @@ node_modules
.next
Dockerfile
.dockerignore
dev-data
*.tsbuildinfo
+1 -1
View File
@@ -13,7 +13,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
ARG TARGETARCH=amd64
ARG TARGETARCH
ARG DOCKER_CLI_VERSION=27.5.1
ARG COMPOSE_VERSION=v2.32.4
+17 -1
View File
@@ -16,7 +16,8 @@
"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",
@@ -3111,6 +3112,21 @@
"engines": {
"node": ">=20.11"
}
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+2 -1
View File
@@ -19,7 +19,8 @@
"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",
+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>
);
}
+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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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");
}
});
}
+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";
+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");
}
+1
View File
@@ -13,6 +13,7 @@ import path from "node:path";
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
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",
+17
View File
@@ -1,4 +1,5 @@
import http from "node:http";
import { appleInspect, applePower, appleExec, appleEnvironment, appleStats, appleLogStream, appleInfrastructure } from "./apple-container";
import { env } from "./env";
import { withServerOperation } from "./operation";
@@ -97,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 {
@@ -117,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 ?? []) {
@@ -135,6 +138,7 @@ export async function powerContainer(name: string, action: PowerAction): Promise
}
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",
@@ -172,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`);
@@ -231,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}`,
@@ -267,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 },
@@ -300,3 +307,13 @@ export async function execInContainer(
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] };
}
+1
View File
@@ -14,6 +14,7 @@ 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"),
+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 };
+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"),
);
});
});
+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).
+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