This commit is contained in:
Aryan Singh committed 2026-10-05 18:34:53 -05:00
1 parent e30f89697b
commit 3e24d4a7bb
26 files changed
+4224 -4

No files matched your search

+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"),
);
});
});