diff --git a/README.md b/README.md
index 6801a1f..7a42111 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore
index daf787e..ca90523 100644
--- a/dashboard/.dockerignore
+++ b/dashboard/.dockerignore
@@ -2,3 +2,5 @@ node_modules
.next
Dockerfile
.dockerignore
+dev-data
+*.tsbuildinfo
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
index c39e329..0760fab 100644
--- a/dashboard/Dockerfile
+++ b/dashboard/Dockerfile
@@ -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
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index dc2aba0..6c88987 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -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"
+ }
}
}
}
diff --git a/dashboard/package.json b/dashboard/package.json
index d319c59..14eabc7 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -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",
diff --git a/dashboard/src/app/(panel)/features/page.tsx b/dashboard/src/app/(panel)/features/page.tsx
new file mode 100644
index 0000000..298b78a
--- /dev/null
+++ b/dashboard/src/app/(panel)/features/page.tsx
@@ -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 (
+
+
+
+ CraftDeck / Possibilities
+
+
Build the server you want.
+
+ 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.
+
+
+ Open your network
+
+
+
+
+ {groups.map((g) => (
+
+
+ {g.title}
+
+ }
+ >
+
+ {g.items.map(([title, status, body]) => (
+
+
+
{title}
+
+ {status}
+
+
+
{body}
+
+ ))}
+
+
+ ))}
+
+
+ Compatibility changes by Minecraft release. Verify the exact mod/plugin
+ versions and test changes on a fresh profile before moving players.
+
+
+
+ );
+}
diff --git a/dashboard/src/app/(panel)/maps/page.tsx b/dashboard/src/app/(panel)/maps/page.tsx
new file mode 100644
index 0000000..167ea61
--- /dev/null
+++ b/dashboard/src/app/(panel)/maps/page.tsx
@@ -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("/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 (
+
+
+
+
+ CraftDeck / Atlas
+
+
Live world maps
+
+ Explore terrain, follow players, and view the overlays supplied by
+ your map renderer.
+
+
+
+ Configure maps →
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {loading &&
Loading maps…
}
+ {!!maps.length && (
+
+
+
({ value: p.id, label: p.name }))}
+ />
+
+ {selected?.map === "bluemap"
+ ? "BlueMap · 3D"
+ : "Dynmap · 2D / isometric"}
+
+
+ {selected?.version} · {selected?.loader}
+
+
+
+ )}
+ {selected?.mapUrl ? (
+ <>
+
+
+
+
+ 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.
+
+ >
+ ) : (
+
+ }
+ 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."
+ }
+ />
+
+ )}
+
+ {[
+ {
+ 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) => (
+
+ {x.body}
+
+ ))}
+
+
+ );
+}
diff --git a/dashboard/src/app/(panel)/network/page.tsx b/dashboard/src/app/(panel)/network/page.tsx
new file mode 100644
index 0000000..319d9b9
--- /dev/null
+++ b/dashboard/src/app/(panel)/network/page.tsx
@@ -0,0 +1,1185 @@
+"use client";
+import { useEffect, useState, type ReactNode } from "react";
+import Link from "next/link";
+import {
+ Globe2,
+ Plus,
+ Network,
+ Server,
+ Play,
+ Square,
+ RotateCw,
+ Terminal,
+ Archive,
+ Settings2,
+ Copy,
+ ArrowRight,
+ Layers,
+ Check,
+ FolderOpen,
+ RefreshCw,
+} from "lucide-react";
+import { api, useApi, toast } from "@/lib/api";
+import {
+ Badge,
+ Button,
+ Card,
+ EmptyState,
+ Input,
+ Modal,
+ Select,
+ Tabs,
+ Toggle,
+} from "@/components/ui";
+import {
+ defaultProfile,
+ LOADERS,
+ proxyCompatible,
+ type WorldProfile,
+ type NetworkView,
+ type ProxySettings,
+} from "@/lib/network";
+
+type Draft = Omit;
+type Action = { op: string; id?: string; name: string; backup?: string };
+const fieldStyle = "flex flex-col gap-1.5 text-xs text-ink2";
+export default function NetworkPage() {
+ const { data, error, refresh, loading } = useApi(
+ "/api/network",
+ 5000,
+ );
+ const [editor, setEditor] = useState<{ draft: Draft; id?: string } | null>(
+ null,
+ );
+ const [selected, setSelected] = useState(null);
+ const [action, setAction] = useState(null);
+ const [confirmation, setConfirmation] = useState("");
+ const [pending, setPending] = useState(false);
+ const [proxy, setProxy] = useState(null);
+ const [proxyDirty, setProxyDirty] = useState(false);
+ useEffect(() => {
+ if (data && !proxyDirty) setProxy(data.proxy);
+ }, [data, proxyDirty]);
+ const locked = !data || data.managed || data.busy || pending;
+ async function saveProfile(draft: Draft, id?: string) {
+ setPending(true);
+ try {
+ await api("/api/network", { body: { op: "save", profile: draft, id } });
+ setEditor(null);
+ toast("Profile saved. Deploy when ready.", "success");
+ await refresh();
+ } catch (e) {
+ toast((e as Error).message, "error");
+ } finally {
+ setPending(false);
+ }
+ }
+ async function act() {
+ if (!action) return;
+ setPending(true);
+ try {
+ await api("/api/network", { body: { ...action, confirm: confirmation } });
+ setAction(null);
+ setConfirmation("");
+ await refresh();
+ toast("Operation queued; progress appears below");
+ } catch (e) {
+ toast((e as Error).message, "error");
+ } finally {
+ setPending(false);
+ }
+ }
+ const ask = (next: Action) => {
+ setConfirmation("");
+ setAction(next);
+ };
+ const running =
+ data?.profiles.filter((p) => data.states[p.id]?.running).length ?? 0;
+ const routes = data?.profiles.filter((p) => p.route === "proxy") ?? [];
+ return (
+
+
+
+
+ CraftDeck / Infrastructure
+
+
+ Your worlds. One network.
+
+
+ Run independent servers, build a lobby, and give every world room to
+ grow.
+
+
+
{
+ const n = data?.profiles.length ?? 0;
+ setEditor({
+ draft: { ...defaultProfile, port: 25570 + n, mapPort: 8101 + n },
+ });
+ }}
+ >
+ Create server
+
+
+ {error && (
+
+ {error}
+
+ Retry
+
+
+ )}
+ {data?.managed && (
+
+ This installation is managed by Komodo. Deploy network changes through
+ homelab-infra; the panel keeps these controls read-only.
+
+ )}
+
+ {[
+ {
+ label: "WORLD SERVERS",
+ value: `${running} / ${data?.profiles.length ?? 0}`,
+ hint: "running / configured",
+ icon: Layers,
+ },
+ {
+ label: "PUBLIC ENTRY",
+ value: `:${data?.proxy.port ?? "—"}`,
+ hint: data?.states.proxy?.running
+ ? "Velocity is running"
+ : "Velocity is not running",
+ icon: Network,
+ },
+ {
+ label: "ISOLATED BY DESIGN",
+ value: `${data?.profiles.reduce((n, p) => n + p.memory, 0) ?? 0} GiB`,
+ hint: "configured Java heap across profiles",
+ icon: Server,
+ },
+ ].map((s) => (
+
+
+ {s.label}
+
+
+
{s.value}
+
{s.hint}
+
+ ))}
+
+
+ Network gateway
+
+ }
+ actions={
+
+ {data?.states.proxy?.health ??
+ data?.states.proxy?.status ??
+ "Loading"}
+
+ }
+ >
+
+
+
+
+
Players
+
+
+ Velocity :{proxy?.port}
+
+
+
+ {routes.length ? (
+ routes.map((p) => (
+
+
+ {p.name}
+ {p.id === proxy?.lobby && (
+ Lobby
+ )}
+ {p.loader}
+
+ ))
+ ) : (
+
+ Create a Paper or Folia server to add your first route.
+
+ )}
+
+
+ Players join the lobby, then use{" "}
+ /server <server-id>.
+ Hostname routes can send them straight to a world. Each server has
+ separate inventories unless you install a compatible sync plugin.
+
+
+ {proxy && (
+
+
+
+ Public port
+ {
+ setProxy({ ...proxy, port: +e.target.value });
+ setProxyDirty(true);
+ }}
+ />
+
+
+ Default lobby
+ {
+ setProxy({ ...proxy, lobby: v });
+ setProxyDirty(true);
+ }}
+ options={[
+ { value: "", label: "Choose a lobby" },
+ ...routes.map((p) => ({ value: p.id, label: p.name })),
+ ]}
+ />
+
+
+ Network MOTD
+ {
+ setProxy({ ...proxy, motd: e.target.value });
+ setProxyDirty(true);
+ }}
+ />
+
+
+ Velocity version
+ {
+ setProxy({ ...proxy, version: e.target.value });
+ setProxyDirty(true);
+ }}
+ />
+
+
+
+ The primary server keeps port {data?.primaryPort ?? 25565}.
+ Choose a free gateway port or forward your router’s public 25565
+ to this gateway. Applying routing reconnects players.
+
+
+ {
+ setPending(true);
+ try {
+ await api("/api/network", {
+ body: { op: "proxy-save", proxy },
+ });
+ setProxyDirty(false);
+ await refresh();
+ toast("Gateway saved", "success");
+ } catch (e) {
+ toast((e as Error).message, "error");
+ } finally {
+ setPending(false);
+ }
+ }}
+ >
+ Save gateway
+
+
+ ask({ op: "proxy-apply", name: "apply proxy" })
+ }
+ >
+ Apply routing
+
+ ask({ op: "proxy-stop", name: "stop proxy" })}
+ >
+ Stop gateway
+
+
+
+ )}
+
+
+
+
+ World servers{" "}
+
+ {data?.profiles.length ?? 0}
+
+
+
+ Open live maps →
+
+
+ {loading &&
Loading your network…
}
+ {data && !data.profiles.length && (
+
+ }
+ title="A new world starts here"
+ hint="Create a lobby, a survival world, or a modded adventure. Profiles are saved first; you decide when to start them."
+ />
+
+
+ setEditor({
+ draft: {
+ ...defaultProfile,
+ name: "Lobby",
+ mode: "adventure",
+ },
+ })
+ }
+ >
+ Create your lobby
+
+
+
+ )}
+
+ {data?.profiles.map((p) => {
+ const s = data.states[p.id];
+ return (
+
+
+
+
+
+
{p.name}
+
+ {p.id}
+
+
+
+ {s?.health ?? s?.status ?? "Unknown"}
+
+
+
+
+ {p.loader} {p.version}
+
+ {p.memory} GiB
+
+ {p.route === "proxy" ? "Proxy route" : `Direct :${p.port}`}
+
+ {p.map !== "none" && {p.map} }
+
+
+
+ World seed
+
+ {p.seed || "Random on first start"}
+
+
+
+ Game mode
+
+ {p.mode} · {p.difficulty}
+
+
+
+ Backups
+
+ Every {p.backupHours}h · {p.retentionDays} days
+
+
+
+ Resource pack
+
+ {p.resourcePack
+ ? p.resourcePackRequired
+ ? "Required download"
+ : "Optional download"
+ : "None"}
+
+
+
+ {s?.error && (
+
+ Container runtime unavailable; status could not be checked.
+
+ )}
+
+
+ ask({ op: "deploy", id: p.id, name: p.name })
+ }
+ >
+
+ {s?.exists ? "Deploy / apply" : "Deploy"}
+
+
+ ask({ op: "restart", id: p.id, name: p.name })
+ }
+ >
+
+
+
ask({ op: "stop", id: p.id, name: p.name })}
+ >
+
+
+
setSelected(p)}>
+
+ Manage
+
+
setEditor({ draft: p, id: p.id })}
+ >
+
+
+
+ setEditor({
+ draft: {
+ ...p,
+ name: `${p.name} copy`,
+ seed: "",
+ port: 25570 + data.profiles.length,
+ mapPort: 8101 + data.profiles.length,
+ hostname: "",
+ mapUrl: "",
+ },
+ })
+ }
+ >
+
+
+
+
+
+ );
+ })}
+
+ {!!data?.jobs.length && (
+
+
+ {data.jobs.slice(0, 6).map((j) => (
+
+ {j.state === "succeeded" ? (
+
+ ) : (
+
+ )}
+
+
+ {j.action}
+
+ {data.profiles.find((p) => p.id === j.profileId)?.name ??
+ "Gateway"}
+
+
+ {j.state}
+
+
+
+ {j.message}
+
+
+
+ ))}
+
+
+ )}
+
+
+ Runtime: {data?.runtime === "apple" ? "Apple container" : "Docker"}.
+ New network profiles use verified Minecraft accounts. The primary
+ server retains its existing authentication.
+
+
+ Explore server capabilities →
+
+
+
!pending && setEditor(null)}
+ title={editor?.id ? "Edit server profile" : "Create a world server"}
+ wide
+ >
+ {editor && (
+ saveProfile(draft, editor.id)}
+ />
+ )}
+
+
!pending && setAction(null)}
+ title={
+ action?.op === "proxy-apply"
+ ? "Apply gateway routing"
+ : `${action?.op ?? ""} · ${action?.name ?? ""}`
+ }
+ >
+
+
+ {action?.op === "proxy-apply" || action?.op === "proxy-stop"
+ ? "Changing the gateway disconnects all players using it. World servers keep running."
+ : action?.op === "restore"
+ ? "Restore replaces this server’s active files and leaves it stopped. Previous files are retained for recovery."
+ : action?.op === "deploy"
+ ? "Deploy downloads the selected runtime and starts this server. Existing worlds receive a verified backup before changes. By starting a Minecraft server you agree to the Minecraft EULA."
+ : "This operation may briefly disconnect players on the affected server. Other world servers keep running."}
+
+ {data?.runtime === "apple" && (
+
+ On this Mac, world operations refresh changing container addresses
+ and may restart the gateway. All connected players will need to
+ reconnect. If no routed world is ready, the gateway stops; apply
+ routing again after starting a world.
+
+ )}
+
+ Type {action?.name} to
+ continue
+ setConfirmation(e.target.value)}
+ />
+
+
+ Confirm {action?.op}
+
+
+
+
setSelected(null)}
+ title={selected?.name ?? "Server"}
+ wide
+ >
+ {selected && (
+ {
+ setSelected(null);
+ ask(a);
+ }}
+ />
+ )}
+
+
+ );
+}
+function Group({
+ title,
+ children,
+ open = false,
+}: {
+ title: string;
+ children: ReactNode;
+ open?: boolean;
+}) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+function ProfileForm({
+ draft,
+ busy,
+ onSave,
+}: {
+ draft: Draft;
+ busy: boolean;
+ onSave: (p: Draft) => void;
+}) {
+ const [p, setP] = useState(draft);
+ function field(
+ key: keyof Draft,
+ label: string,
+ type = "text",
+ hint?: string,
+ ) {
+ return (
+
+ {label}
+
+ setP({
+ ...p,
+ [key]: type === "number" ? +e.target.value : e.target.value,
+ })
+ }
+ />
+ {hint && (
+ {hint}
+ )}
+
+ );
+ }
+ function select(key: keyof Draft, label: string, values: readonly string[]) {
+ return (
+
+ {label}
+ setP({ ...p, [key]: v })}
+ options={values.map((v) => ({ value: v, label: v }))}
+ />
+
+ );
+ }
+ return (
+
+ );
+}
+function ServerTools({
+ profile: p,
+ locked,
+ onAction,
+}: {
+ profile: WorldProfile;
+ locked: boolean;
+ onAction: (a: Action) => void;
+}) {
+ const [tab, setTab] = useState("console");
+ const [logs, setLogs] = useState("");
+ const [error, setError] = useState("");
+ const [command, setCommand] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [backups, setBackups] = useState>(
+ [],
+ );
+ const load = async () => {
+ try {
+ setError("");
+ if (tab === "console")
+ setLogs(await api(`/api/network?id=${p.id}&kind=logs`));
+ if (tab === "backups")
+ setBackups(await api(`/api/network?id=${p.id}&kind=backups`));
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+ useEffect(() => {
+ void load();
+ const timer =
+ tab === "console" ? setInterval(() => void load(), 5000) : null;
+ return () => {
+ if (timer) clearInterval(timer);
+ };
+ }, [tab, p.id]); // explicit server scope; polling ends when closed
+ return (
+
+
+ {error &&
{error}
}
+ {tab === "console" && (
+ <>
+
+ {logs || "Start the server to see its console."}
+
+
+ >
+ )}
+ {tab === "backups" && (
+ <>
+
+ Manual snapshots briefly stop this server for a consistent backup.
+ Scheduled backups use its dedicated sidecar.
+
+
onAction({ op: "snapshot", id: p.id, name: p.name })}
+ >
+
+ Take snapshot
+
+
+ {backups.map((b) => (
+
+
+ {b.name}
+
+ {(b.size / 1024 / 1024).toFixed(1)} MB
+
+
+
+ onAction({
+ op: "restore",
+ id: p.id,
+ name: p.name,
+ backup: b.name,
+ })
+ }
+ >
+ Restore
+
+
+ ))}
+ {!backups.length &&
}
+
+ >
+ )}
+ {tab === "files" &&
}
+
+ );
+}
+function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
+ const [path, setPath] = useState("");
+ const [entries, setEntries] = useState<
+ Array<{ name: string; directory: boolean; link: boolean }>
+ >([]);
+ const [content, setContent] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const load = async (next: string) => {
+ setBusy(true);
+ try {
+ const r = await api<{ entries?: typeof entries; content?: string }>(
+ `/api/network/files?id=${id}&path=${encodeURIComponent(next)}`,
+ );
+ setPath(next);
+ setEntries(r.entries ?? []);
+ setContent(r.content ?? null);
+ setError("");
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+ useEffect(() => {
+ void load("");
+ }, [id]);
+ return (
+
+
+ Stop the server before editing configs or uploading mod/plugin JARs and
+ datapack ZIPs.
+
+
+ void load(path.split("/").slice(0, -1).join("/"))}
+ >
+ ↑ Up
+
+ /{path}
+
+ {error && (
+
+ {error}
+
+ )}
+ {content !== null ? (
+ <>
+
+ );
+}
diff --git a/dashboard/src/app/api/network/files/route.ts b/dashboard/src/app/api/network/files/route.ts
new file mode 100644
index 0000000..363e4f3
--- /dev/null
+++ b/dashboard/src/app/api/network/files/route.ts
@@ -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 });
+ }),
+ );
+}
diff --git a/dashboard/src/app/api/network/route.ts b/dashboard/src/app/api/network/route.ts
new file mode 100644
index 0000000..de622cc
--- /dev/null
+++ b/dashboard/src/app/api/network/route.ts
@@ -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>) {
+ 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>(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");
+ }
+ });
+}
diff --git a/dashboard/src/components/shell.tsx b/dashboard/src/components/shell.tsx
index 0830195..f54324b 100644
--- a/dashboard/src/components/shell.tsx
+++ b/dashboard/src/components/shell.tsx
@@ -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 }) {
}`}
>
- {isRunning ? "Online" : "Offline"}
+ Primary · {isRunning ? "Online" : "Offline"}
{isRunning && (
diff --git a/dashboard/src/lib/network.ts b/dashboard/src/lib/network.ts
new file mode 100644
index 0000000..4f19848
--- /dev/null
+++ b/dashboard/src/lib/network.ts
@@ -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;
+ primaryPort: number;
+ busy: boolean;
+};
+export const defaultProfile: Omit = {
+ 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";
diff --git a/dashboard/src/server/apple-container.ts b/dashboard/src/server/apple-container.ts
new file mode 100644
index 0000000..b0175eb
--- /dev/null
+++ b/dashboard/src/server/apple-container.ts
@@ -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 {
+ 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();
+export async function appleInspect(name: string): Promise {
+ 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();
+export async function appleStats(name: string): Promise {
+ 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;
+ 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;
+ mem_limit?: string;
+ volumes: string[];
+ ports?: string[];
+};
+type ComposePlan = {
+ services: Record;
+ 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");
+}
diff --git a/dashboard/src/server/compose.ts b/dashboard/src/server/compose.ts
index 4480bdd..61760b0 100644
--- a/dashboard/src/server/compose.ts
+++ b/dashboard/src/server/compose.ts
@@ -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",
diff --git a/dashboard/src/server/docker.ts b/dashboard/src/server/docker.ts
index 66bf7f1..8392024 100644
--- a/dashboard/src/server/docker.ts
+++ b/dashboard/src/server/docker.ts
@@ -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 {
+ if (env.containerRuntime === "apple") return appleInspect(name);
try {
const data = await json(`/containers/${name}/json`);
return {
@@ -117,6 +119,7 @@ export async function inspectContainer(name: string): Promise {
}
export async function containerEnv(name: string): Promise> {
+ if (env.containerRuntime === "apple") return appleEnvironment(name);
const data = await json(`/containers/${name}/json`);
const out: Record = {};
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 {
+ 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 {
+ 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(`/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 } }>(`/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] };
+}
diff --git a/dashboard/src/server/env.ts b/dashboard/src/server/env.ts
index db56058..bf1c770 100644
--- a/dashboard/src/server/env.ts
+++ b/dashboard/src/server/env.ts
@@ -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"),
diff --git a/dashboard/src/server/network-config.ts b/dashboard/src/server/network-config.ts
new file mode 100644
index 0000000..83c0af0
--- /dev/null
+++ b/dashboard/src/server/network-config.ts
@@ -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(
+ 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;
+ 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;
+ 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([
+ [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();
+ 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);
+ }
+ }
+}
diff --git a/dashboard/src/server/network-files.ts b/dashboard/src/server/network-files.ts
new file mode 100644
index 0000000..476a433
--- /dev/null
+++ b/dashboard/src/server/network-files.ts
@@ -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;
+}
diff --git a/dashboard/src/server/network-render.ts b/dashboard/src/server/network-render.ts
new file mode 100644
index 0000000..272e082
--- /dev/null
+++ b/dashboard/src/server/network-render.ts
@@ -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
+ )[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 = {},
+) {
+ 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, "$$$$");
+}
diff --git a/dashboard/src/server/network.ts b/dashboard/src/server/network.ts
new file mode 100644
index 0000000..ec66bf7
--- /dev/null
+++ b/dashboard/src/server/network.ts
@@ -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 };
+const runningJobs = (globalJobs.__networkJobs ??= new Set());
+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) {
+ 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,
+) {
+ 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 = {};
+ 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 = {};
+ 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 };
diff --git a/dashboard/tests/network-operations.test.ts b/dashboard/tests/network-operations.test.ts
new file mode 100644
index 0000000..ffd2705
--- /dev/null
+++ b/dashboard/tests/network-operations.test.ts
@@ -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()),
+ 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;
+ });
+ 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;
+ });
+ 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;
+ });
+ vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
+ (args.at(-1) as (e: Error) => void)(new Error("archive invalid"));
+ return {} as ReturnType;
+ });
+ 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((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");
+ });
+});
diff --git a/dashboard/tests/network.test.ts b/dashboard/tests/network.test.ts
new file mode 100644
index 0000000..f5cf115
--- /dev/null
+++ b/dashboard/tests/network.test.ts
@@ -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 = {}) =>
+ 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"),
+ );
+ });
+});
diff --git a/docs/apple-container.md b/docs/apple-container.md
new file mode 100644
index 0000000..c12a34e
--- /dev/null
+++ b/docs/apple-container.md
@@ -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 `. 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).
diff --git a/docs/worlds-and-network.md b/docs/worlds-and-network.md
new file mode 100644
index 0000000..6a4ff87
--- /dev/null
+++ b/docs/worlds-and-network.md
@@ -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 ` 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//compose.json # generated Compose + RCON credential
+data/network//data/ # complete Minecraft server
+data/network//backups/ # this server's archives
+data/network//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/).
diff --git a/scripts/apple-container-dev.sh b/scripts/apple-container-dev.sh
new file mode 100755
index 0000000..338c9f5
--- /dev/null
+++ b/scripts/apple-container-dev.sh
@@ -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"
diff --git a/scripts/apple-container-preview.sh b/scripts/apple-container-preview.sh
new file mode 100755
index 0000000..1688beb
--- /dev/null
+++ b/scripts/apple-container-preview.sh
@@ -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