new era
This commit is contained in:
1 parent
e30f89697b
commit
3e24d4a7bb
26 files changed
+4224
-4
No files matched your search
@@ -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,3 +2,5 @@ node_modules
|
||||
.next
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
dev-data
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+17
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "$$$$");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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/).
|
||||
Executable
+34
@@ -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"
|
||||
Executable
+36
@@ -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
|
||||
Reference in new issue
Block a user