Compare commits
9
Commits
No files matched your search
@@ -11,6 +11,9 @@ A production-quality, self-hosted Minecraft **Java 26.3** server for friends:
|
|||||||
- **CraftDeck**, a full web management panel: live console, players & stats, lag
|
- **CraftDeck**, a full web management panel: live console, players & stats, lag
|
||||||
diagnostics, performance graphs, mod manager with Modrinth search, backups & restore,
|
diagnostics, performance graphs, mod manager with Modrinth search, backups & restore,
|
||||||
file browser/editor, config editor, scheduled tasks, Discord alerts
|
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
|
- **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
|
## 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/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/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
|
- [docs/updating.md](docs/updating.md) — upgrading Minecraft, mods, and the panel
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
dev-data
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -13,7 +13,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
|
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
|
||||||
ARG TARGETARCH=amd64
|
ARG TARGETARCH
|
||||||
ARG DOCKER_CLI_VERSION=27.5.1
|
ARG DOCKER_CLI_VERSION=27.5.1
|
||||||
ARG COMPOSE_VERSION=v2.32.4
|
ARG COMPOSE_VERSION=v2.32.4
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Auth proxy clones multipart requests; its 10 MB default truncates uploads.
|
||||||
|
experimental: { proxyClientMaxBodySize: "260mb" },
|
||||||
// The panel is self-hosted on a LAN; no image CDN available.
|
// The panel is self-hosted on a LAN; no image CDN available.
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+17
-1
@@ -16,7 +16,8 @@
|
|||||||
"next": "16.3.7",
|
"next": "16.3.7",
|
||||||
"node-cron": "4.6.0",
|
"node-cron": "4.6.0",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"yaml": "2.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "4.3.2",
|
"@tailwindcss/postcss": "4.3.2",
|
||||||
@@ -3111,6 +3112,21 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.11"
|
"node": ">=20.11"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"next": "16.3.7",
|
"next": "16.3.7",
|
||||||
"node-cron": "4.6.0",
|
"node-cron": "4.6.0",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"yaml": "2.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "4.3.2",
|
"@tailwindcss/postcss": "4.3.2",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="description" content="Install the exact From the Fog Reimagined pack and join our Minecraft Java server.">
|
||||||
|
<title>The Fog — Pack & Play</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark;--bg:#0b1110;--fg:#e5eee8;--muted:#a3b2aa;--line:#2c3b33;--green:#c4f29d}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.6}a{color:inherit;text-underline-offset:4px}button,a{touch-action:manipulation}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:5px}header,main,footer{width:min(1120px,calc(100% - 48px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:27px 0;border-bottom:1px solid var(--line)}.brand{font-weight:800;letter-spacing:.15em;font-size:14px;text-decoration:none}.brand span{color:var(--green)}.mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;letter-spacing:.07em}.muted{color:var(--muted)}.hero{padding:84px 0 62px;position:relative;isolation:isolate;overflow:hidden}.hero:after{content:"";position:absolute;z-index:-1;width:56%;height:400px;right:-8%;top:5%;background:repeating-linear-gradient(90deg,transparent 0 34px,#36503b26 35px 52px,transparent 53px 70px),radial-gradient(ellipse,#6b84502b,transparent 65%);transform:skewY(-7deg);mask-image:linear-gradient(90deg,transparent,#000)}.eyebrow{color:var(--green);display:flex;align-items:center;gap:10px}.eyebrow:before{content:"";height:7px;width:7px;background:var(--green)}h1{font-size:clamp(44px,7.5vw,88px);line-height:1.04;letter-spacing:-.06em;margin:24px 0 27px;font-weight:650;max-width:800px}h1 em{font-style:normal;color:var(--green)}.intro{max-width:540px;font-size:18px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:30px}.button{display:inline-flex;align-items:center;justify-content:center;gap:16px;min-height:48px;padding:10px 19px;background:var(--green);color:#13200e;border:1px solid var(--green);font-family:inherit;font-weight:600;font-size:14px;text-decoration:none;border-radius:4px;cursor:pointer}.button.secondary{background:#121b16;color:var(--fg);border-color:var(--line)}.versions{display:flex;flex-wrap:wrap;gap:8px 28px;padding:22px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.versions span{color:var(--muted)}.versions b{color:var(--fg);font-weight:500}.section-title{display:flex;gap:20px;align-items:baseline;margin:64px 0 20px}.section-title h2{font-size:25px;letter-spacing:-.035em;margin:0}.steps{border-top:1px solid var(--line)}article{display:grid;grid-template-columns:64px 280px 1fr;gap:16px;border-bottom:1px solid var(--line);padding:30px 0}.num{font-family:ui-monospace,monospace;color:var(--green);font-size:14px}h3{font-size:20px;line-height:1.3;letter-spacing:-.02em;margin:0 0 10px}p{margin:0 0 14px}article p{color:var(--muted)}.detail{max-width:590px}.detail a{color:var(--fg)}.note{font-size:13px}.server-box{display:flex;align-items:center;justify-content:space-between;border:1px solid #536645;background:#162014;padding:15px 18px;margin-top:18px;gap:15px;border-radius:4px}.server-box code{font-size:clamp(13px,2vw,17px);overflow-wrap:anywhere}.copy{background:none;border:0;color:var(--green);font-weight:600;font-size:13px;cursor:pointer;min-height:30px;white-space:nowrap}code{font-family:ui-monospace,SFMono-Regular,monospace}details{border-bottom:1px solid var(--line);padding:18px 0}summary{cursor:pointer;list-style-position:outside;margin-left:18px;font-weight:550}details p{margin:14px 0 4px 18px;color:var(--muted);max-width:760px}.download{padding:30px;border:1px solid var(--line);border-radius:5px;margin:45px 0;background:#111a15;display:flex;justify-content:space-between;align-items:center;gap:24px}.download h3{margin-bottom:6px}.download p{margin:0;font-size:14px;color:var(--muted)}footer{padding:32px 0 42px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:20px;font-size:12px;color:var(--muted)}.toast{position:fixed;bottom:25px;left:50%;transform:translateX(-50%);background:var(--green);color:#13200e;padding:10px 20px;border-radius:4px;box-shadow:0 8px 35px #0008}.toast:empty{display:none}@media(max-width:760px){header,main,footer{width:calc(100% - 32px)}header .mono{font-size:10px}.hero{padding:52px 0 40px}.intro{font-size:16px}article{grid-template-columns:35px 1fr;gap:10px}.detail{grid-column:2}.section-title{margin-top:44px}.download{padding:22px;display:block}.download .button{margin-top:20px}footer{display:block}footer p{margin-bottom:10px}.versions{gap:8px 18px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><a class="brand" href="#">THE <span>FOG</span> / PACK & PLAY</a><span class="mono muted">JAVA EDITION · FRIENDS SERVER</span></header>
|
||||||
|
<main>
|
||||||
|
<section class="hero"><div class="eyebrow mono">FROM THE FOG REIMAGINED · 6.3.2</div><h1>Get in.<br><em>Stay together.</em></h1><p class="intro">One matching modpack. One server address. Everything you need to join the new world, without collecting a folder of mods.</p><div class="actions"><a class="button" href="#install">Install the pack <span aria-hidden="true">↗</span></a><a class="button secondary" href="#join">I’m ready to join <span aria-hidden="true">↓</span></a></div></section>
|
||||||
|
<div class="versions mono"><div><span>PACK</span> <b>6.3.2</b></div><div><span>MINECRAFT</span> <b>1.20.1</b></div><div><span>LOADER</span> <b>FORGE 47.4.10</b></div><div><span>JAVA</span> <b>17</b></div></div>
|
||||||
|
<section id="install" aria-labelledby="install-title"><div class="section-title"><span class="mono muted">01—03</span><h2 id="install-title">Three steps to the fog.</h2></div><div class="steps">
|
||||||
|
<article><span class="num">01</span><div><h3>Open your launcher.</h3><span class="mono muted">INSTALL ONCE</span></div><div class="detail"><p>Using an offline username? <a href="https://docs.skmedix.pl/4.0/" target="_blank" rel="noopener">SKlauncher 4.0</a> has a CurseForge modpack installer and automatic Java setup. Version 4.0 is currently beta.</p><p>If you play with Microsoft sign-in, you can also use the <a href="https://www.curseforge.com/download/app" target="_blank" rel="noopener">CurseForge app</a>.</p><p class="note">Already have a compatible launcher? Keep it. You only need its CurseForge pack installer or ZIP import.</p></div></article>
|
||||||
|
<article><span class="num">02</span><div><h3>Install the whole pack.</h3><span class="mono muted">PINNED TO 6.3.2</span></div><div class="detail"><p>Search <strong>From the Fog Reimagined</strong> by <strong>SoumXD</strong>. Select version <strong>6.3.2</strong>, then install and launch that profile.</p><p><a href="https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined/files/9191040" target="_blank" rel="noopener">Open the exact official release ↗</a></p><p class="note">The launcher downloads the mods for you. Allocate 6 GB of memory if your computer has room (the author’s minimum is 4 GB). Shaders are optional.</p></div></article>
|
||||||
|
<article id="join"><span class="num">03</span><div><h3>Add the server.</h3><span class="mono muted">MULTIPLAYER → ADD SERVER</span></div><div class="detail"><p>Launch the pack, open Multiplayer, and paste this address. Use the exact username the admin approved, including capitalization.</p><div class="server-box"><code id="server-address">minecraft.ayush-jain.dev</code><button class="copy" id="copy-address">Copy address</button></div><p class="note" style="margin-top:16px">Ask the admin to open your first-join registration window in CraftDeck. On first join, follow the prompt: <code>/login YOUR_PASSWORD YOUR_PASSWORD</code>. Later, use <code>/login YOUR_PASSWORD</code>. Choose your password in the game.</p></div></article>
|
||||||
|
</div></section>
|
||||||
|
<section aria-labelledby="help-title"><div class="section-title"><span class="mono muted">FIELD NOTES</span><h2 id="help-title">Stuck at the treeline?</h2></div><details><summary>“You are not whitelisted”</summary><p>Use your approved username exactly. The initial names are <code>aryan</code> and <code>jainster</code>. Ask the admin to approve or reopen your name in CraftDeck → Access & Auth immediately before joining.</p></details><details><summary>Missing mods or incompatible version</summary><p>Launch the complete Reimagined 6.3.2 profile with Minecraft 1.20.1 and Forge 47.4.10. The standalone From the Fog datapack and the latest vanilla Minecraft are different installations.</p></details><details><summary>The game runs slowly or closes</summary><p>Disable shaders first, then check the pack’s memory setting. The author lists 4 GB as the client minimum; 6 GB is useful when your computer has enough spare memory.</p></details><details><summary>I forgot my server password</summary><p>Ask the admin to use Re-register password in CraftDeck → Access & Auth. Rejoin during the new approval window and choose your own replacement password in the game.</p></details></section>
|
||||||
|
<section class="download"><div><h3>Need a ZIP for your launcher?</h3><p>Get the exact client manifest from CurseForge, then use your launcher’s pack import.<br>This is the client pack—not the server archive.</p></div><a class="button secondary" href="https://www.curseforge.com/api/v1/mods/964725/files/9191040/download">Download client pack <span aria-hidden="true">↓</span></a></section>
|
||||||
|
</main>
|
||||||
|
<footer><p>Pack by <a href="https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined" target="_blank" rel="noopener">SoumXD</a>. Mod files are downloaded from their official sources.</p><p>Official pack baseline · CaseOh’s exact custom setup is unverified.</p></footer>
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script>
|
||||||
|
const copy=document.getElementById('copy-address'),toast=document.getElementById('toast');let timer;
|
||||||
|
copy.addEventListener('click',async()=>{const address=document.getElementById('server-address').textContent;try{await navigator.clipboard.writeText(address);copy.textContent='Copied';toast.textContent='Server address copied';}catch{const selection=window.getSelection(),range=document.createRange();range.selectNodeContents(document.getElementById('server-address'));selection.removeAllRanges();selection.addRange(range);toast.textContent='Address selected. Press Ctrl+C or ⌘C to copy.';}clearTimeout(timer);timer=setTimeout(()=>{copy.textContent='Copy address';toast.textContent='';},3000);});
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@@ -21,6 +21,8 @@ type AccessData = {
|
|||||||
approved: ApprovedPlayer[];
|
approved: ApprovedPlayer[];
|
||||||
whitelist: string[] | null;
|
whitelist: string[] | null;
|
||||||
registered: string[] | null;
|
registered: string[] | null;
|
||||||
|
authProvider: "easyauth" | "serverlogin";
|
||||||
|
registrationCommand: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
|
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
|
||||||
@@ -80,7 +82,7 @@ export default function AccessPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Access & Auth</h1>
|
<h1 className="text-xl font-bold">Access & Auth</h1>
|
||||||
<p className="mt-0.5 text-xs text-ink2">
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
|
Only approved usernames can join. New players must register within the window using <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>, then{" "}
|
||||||
<code>/login</code> on every visit.
|
<code>/login</code> on every visit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +107,7 @@ export default function AccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] leading-relaxed text-muted">
|
<p className="mt-2 text-[11px] leading-relaxed text-muted">
|
||||||
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
|
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
|
||||||
<em>right away</em> and run <code>/register <password> <password></code>. If the timer
|
<em>right away</em> and run <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>. If the timer
|
||||||
expires first, the name is automatically removed from the whitelist.
|
expires first, the name is automatically removed from the whitelist.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -183,7 +185,7 @@ export default function AccessPage() {
|
|||||||
<RefreshCw size={12} /> Re-open window
|
<RefreshCw size={12} /> Re-open window
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{p.status === "registered" && (
|
{p.status === "registered" && data?.authProvider !== "serverlogin" && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -205,6 +207,11 @@ export default function AccessPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{p.status === "registered" && data?.authProvider === "serverlogin" && (
|
||||||
|
<Button size="sm" onClick={() => void run(`reregister-${p.username}`, { op: "force-reregister", username: p.username })} busy={busy === `reregister-${p.username}`}>
|
||||||
|
<KeyRound size={12} /> Re-register password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{p.status !== "revoked" && (
|
{p.status !== "revoked" && (
|
||||||
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
|
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
|
||||||
Revoke
|
Revoke
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
|
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
|
||||||
import { api, useSSE, toast } from "@/lib/api";
|
import { api, useSSE, useApi, toast } from "@/lib/api";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
const QUICK_COMMANDS = [
|
const QUICK_COMMANDS = [
|
||||||
@@ -24,6 +24,9 @@ function colorFor(line: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConsolePage() {
|
export default function ConsolePage() {
|
||||||
|
const [server, setServer] = useState("primary");
|
||||||
|
const [status, setStatus] = useState("Connecting…");
|
||||||
|
const { data: targets } = useApi<Array<{ id: string; name: string; running: boolean }>>("/api/console");
|
||||||
const [lines, setLines] = useState<string[]>([]);
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
@@ -33,7 +36,9 @@ export default function ConsolePage() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const paneRef = useRef<HTMLDivElement>(null);
|
const paneRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useSSE("/api/stream/logs", {
|
useEffect(() => { setLines([]); setStatus("Connecting…"); }, [server]);
|
||||||
|
useSSE(`/api/stream/logs?server=${encodeURIComponent(server)}`, {
|
||||||
|
status: value => setStatus(String(value)),
|
||||||
message: (line) => {
|
message: (line) => {
|
||||||
setLines((xs) => {
|
setLines((xs) => {
|
||||||
const next = [...xs, line as string];
|
const next = [...xs, line as string];
|
||||||
@@ -60,7 +65,9 @@ export default function ConsolePage() {
|
|||||||
if (!c) return;
|
if (!c) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api("/api/console", { body: { command: c } });
|
const target = server;
|
||||||
|
const result = await api<{ output: string }>("/api/console", { body: { command: c, server: target } });
|
||||||
|
setLines(xs => [...xs, `> ${c}`, ...result.output.trim().split("\n").filter(Boolean)]);
|
||||||
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
|
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
|
||||||
setHistIdx(-1);
|
setHistIdx(-1);
|
||||||
setCommand("");
|
setCommand("");
|
||||||
@@ -97,9 +104,12 @@ export default function ConsolePage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Console</h1>
|
<h1 className="text-xl font-bold">Console</h1>
|
||||||
<p className="mt-0.5 text-xs text-ink2">Live server log with RCON command input</p>
|
<p className="mt-0.5 text-xs text-ink2">{status}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select aria-label="Console server" value={server} disabled={busy} onChange={e => setServer(e.target.value)} className="rounded-lg border border-border bg-surface2 p-2 text-sm">
|
||||||
|
{(targets ?? [{ id: "primary", name: "Primary server", running: false }]).map(t => <option key={t.id} value={t.id}>{t.name}{t.running ? "" : " (stopped)"}</option>)}
|
||||||
|
</select>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter lines…"
|
placeholder="Filter lines…"
|
||||||
value={filter}
|
value={filter}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
Boxes,
|
||||||
|
Cpu,
|
||||||
|
Globe2,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge, Card } from "@/components/ui";
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
title: "Worlds & networks",
|
||||||
|
icon: Globe2,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"Independent world servers",
|
||||||
|
"Built in",
|
||||||
|
"Concurrent profiles with separate seeds, versions, Java runtimes, mods, configs, player data, and backups.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Lobby & server switching",
|
||||||
|
"Built in",
|
||||||
|
"Velocity gateway, default lobby, fallback routes, and hostname routing for Paper/Folia. Use /server to switch compatible servers.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Several worlds inside one server",
|
||||||
|
"Addon",
|
||||||
|
"Multiverse-style plugins or modded dimensions. These share one runtime, so use separate profiles for incompatible modpacks.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Cross-server inventories & chat",
|
||||||
|
"Addon",
|
||||||
|
"A compatible sync plugin and shared database can connect inventories, economies, permissions, and chat. Decide what each game mode should share.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Performance & scale",
|
||||||
|
icon: Cpu,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"Region-based ticking",
|
||||||
|
"Runtime option",
|
||||||
|
"Folia runs independent regions in parallel. It needs suitable hardware and Folia-aware plugins; crowded regions can still lag.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Fabric / Forge / NeoForge / Quilt",
|
||||||
|
"Built in",
|
||||||
|
"Dedicated runtime profiles preserve mod compatibility. Match the Java release and client modpack to the selected server version.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Profiling & tuning",
|
||||||
|
"Primary panel",
|
||||||
|
"Performance charts and spark diagnostics remain in the primary server panel. Tune view/simulation distances per network profile.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Chunk pregeneration",
|
||||||
|
"Addon",
|
||||||
|
"Pregenerate a bounded play area with a compatible tool to reduce exploration spikes. Budget disk space and schedule it outside peak hours.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maps & client experience",
|
||||||
|
icon: Sparkles,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"BlueMap / Dynmap",
|
||||||
|
"Integration",
|
||||||
|
"Compatible release resolution, isolated renderer ports, and an embedded viewer for your HTTPS map URL. Rendering runs on the server.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Resource pack downloads",
|
||||||
|
"Built in",
|
||||||
|
"Per-server ZIP URL, SHA-1, download prompt, and required/optional mode. Textures, models, sounds, and fonts; client mods are separate.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Claims, towns & economies",
|
||||||
|
"Addon",
|
||||||
|
"Install compatible protection, town, economy, shop, and map-marker plugins. Plugin compatibility depends on the runtime and version.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Bedrock crossplay",
|
||||||
|
"Addon",
|
||||||
|
"Geyser and optionally Floodgate can bridge supported clients. Configure identity handling and a separate UDP port; this panel does not deploy it automatically.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Safety & operations",
|
||||||
|
icon: Shield,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"Backups & recovery",
|
||||||
|
"Built in",
|
||||||
|
"Per-server backup sidecars, retention policies, verified cold snapshots, and staged restores that retain previous data.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Access & isolation",
|
||||||
|
"Built in",
|
||||||
|
"New profiles require verified accounts and a whitelist. Proxy backends are private and use a forwarding secret. Primary offline authentication remains separate.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Console & file management",
|
||||||
|
"Built in",
|
||||||
|
"Profile-specific commands, log tails, stopped-server config editing, mod/plugin uploads, and datapack uploads.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Remote backups & disaster recovery",
|
||||||
|
"External setup",
|
||||||
|
"Replicate archives to another host or object store and test restores. Local backups do not survive loss of the host disk.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gameplay extensions",
|
||||||
|
icon: Boxes,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"Custom terrain & dimensions",
|
||||||
|
"Addon",
|
||||||
|
"World-generation datapacks and mods, structure packs, biomes, and dimensions. Install generation content before exploring new terrain.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Minigames & events",
|
||||||
|
"Addon",
|
||||||
|
"Create separate arena profiles for PvP, parkour, creative, adventure, or seasonal worlds and route through the gateway.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Voice chat & Discord",
|
||||||
|
"Addon",
|
||||||
|
"Compatible voice mods need an extra UDP port. The primary panel already supports Discord operational alerts; in-game bridges need plugins.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Permissions, moderation & rollback",
|
||||||
|
"Addon",
|
||||||
|
"Use compatible permissions, audit, anti-cheat, and rollback tools. Folia requires explicit support; a Paper plugin label is insufficient.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Advanced infrastructure",
|
||||||
|
icon: Wrench,
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
"Custom domains & ingress",
|
||||||
|
"External setup",
|
||||||
|
"DNS/SRV records, HTTPS map reverse proxies, and Minecraft-aware TCP protection can provide a consistent entry point.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Version translation",
|
||||||
|
"Addon",
|
||||||
|
"ViaVersion-family plugins translate supported protocol differences. They cannot make incompatible client modpacks interchangeable.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Multiple physical hosts",
|
||||||
|
"External setup",
|
||||||
|
"A private network and per-host orchestration can spread servers across machines. This implementation manages one Docker host.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Maintenance & automation",
|
||||||
|
"Primary panel",
|
||||||
|
"The existing scheduler, metrics, notifications, and access flows target the primary server. Network profiles have independent automatic backups.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export default function FeaturesPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
|
||||||
|
CraftDeck / Possibilities
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Build the server you want.</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-ink2">
|
||||||
|
A practical capability guide, from a small survival world to a network
|
||||||
|
of game modes. The labels show what this panel manages and what needs
|
||||||
|
additional software or infrastructure.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/network"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 text-sm text-accent"
|
||||||
|
>
|
||||||
|
Open your network
|
||||||
|
<ArrowUpRight size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Card
|
||||||
|
key={g.title}
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<g.icon size={16} className="text-accent" />
|
||||||
|
{g.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{g.items.map(([title, status, body]) => (
|
||||||
|
<div key={title}>
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
status === "Built in"
|
||||||
|
? "good"
|
||||||
|
: status === "Addon"
|
||||||
|
? "muted"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-ink2">{body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Compatibility changes by Minecraft release. Verify the exact mod/plugin
|
||||||
|
versions and test changes on a fresh profile before moving players.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-accent">
|
||||||
|
<a
|
||||||
|
href="https://docs.papermc.io/folia/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Folia documentation ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://docs.papermc.io/velocity/server-compatibility/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Velocity compatibility ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://bluemap.bluecolored.de/wiki/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
BlueMap documentation ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://docker-minecraft-server.readthedocs.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Runtime documentation ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export default function FilesPage() {
|
|||||||
const [mkdirOpen, setMkdirOpen] = useState(false);
|
const [mkdirOpen, setMkdirOpen] = useState(false);
|
||||||
const [mkdirName, setMkdirName] = useState("");
|
const [mkdirName, setMkdirName] = useState("");
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const uploadRef = useRef<HTMLInputElement>(null);
|
const uploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async (p: string) => {
|
const load = useCallback(async (p: string) => {
|
||||||
@@ -92,18 +93,18 @@ export default function FilesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const upload = async (file: File) => {
|
const upload = async (file: File) => {
|
||||||
|
if (file.size > 256 * 1024 * 1024) { toast("File too large (256 MB max)", "error"); return; }
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("dir", path);
|
form.append("dir", path); form.append("file", file);
|
||||||
form.append("file", file);
|
|
||||||
toast(`Uploading ${file.name}…`, "info");
|
|
||||||
const res = await fetch("/api/files/upload", { method: "POST", body: form });
|
const res = await fetch("/api/files/upload", { method: "POST", body: form });
|
||||||
const payload = await res.json();
|
const payload = await res.json().catch(() => ({ error: res.status === 413 ? "Upload rejected by the web proxy: file is too large" : `Upload failed (${res.status})` }));
|
||||||
if (!res.ok || payload.ok === false) {
|
if (!res.ok || payload.ok === false) throw new Error(payload.error ?? "Upload failed");
|
||||||
toast(payload.error ?? "Upload failed", "error");
|
|
||||||
} else {
|
|
||||||
toast(`${file.name} uploaded`, "success");
|
toast(`${file.name} uploaded`, "success");
|
||||||
void load(path);
|
void load(path);
|
||||||
}
|
} catch (err) { toast((err as Error).message, "error"); }
|
||||||
|
finally { setUploading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const crumbs = path ? path.split("/") : [];
|
const crumbs = path ? path.split("/") : [];
|
||||||
@@ -126,7 +127,7 @@ export default function FilesPage() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
|
<Button disabled={managed || uploading} busy={uploading} size="sm" onClick={() => uploadRef.current?.click()}>
|
||||||
<Upload size={13} /> Upload here
|
<Upload size={13} /> Upload here
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Map, ExternalLink, RefreshCw, Layers } from "lucide-react";
|
||||||
|
import { useApi } from "@/lib/api";
|
||||||
|
import { Badge, Button, Card, EmptyState, Select } from "@/components/ui";
|
||||||
|
import type { NetworkView } from "@/lib/network";
|
||||||
|
export default function MapsPage() {
|
||||||
|
const { data, error, loading } = useApi<NetworkView>("/api/network", 15000);
|
||||||
|
const [id, setId] = useState("");
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
const maps = data?.profiles.filter((p) => p.map !== "none") ?? [];
|
||||||
|
const selected = maps.find((p) => p.id === id) ?? maps[0];
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[.2em] text-accent">
|
||||||
|
CraftDeck / Atlas
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Live world maps</h1>
|
||||||
|
<p className="mt-2 text-sm text-ink2">
|
||||||
|
Explore terrain, follow players, and view the overlays supplied by
|
||||||
|
your map renderer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/network" className="text-sm text-accent">
|
||||||
|
Configure maps →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-warn">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading && <p className="text-sm text-muted">Loading maps…</p>}
|
||||||
|
{!!maps.length && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Layers size={16} className="text-accent" />
|
||||||
|
<Select
|
||||||
|
value={selected?.id ?? ""}
|
||||||
|
onChange={setId}
|
||||||
|
options={maps.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
|
/>
|
||||||
|
<Badge color="info">
|
||||||
|
{selected?.map === "bluemap"
|
||||||
|
? "BlueMap · 3D"
|
||||||
|
: "Dynmap · 2D / isometric"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{selected?.version} · {selected?.loader}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => setReload((x) => x + 1)}>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
{selected?.mapUrl && (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1 text-xs"
|
||||||
|
href={selected.mapUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
Open full map
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected?.mapUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||||
|
<iframe
|
||||||
|
key={`${selected.id}-${reload}`}
|
||||||
|
title={`${selected.name} live map`}
|
||||||
|
src={selected.mapUrl}
|
||||||
|
className="h-[70vh] min-h-96 w-full"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-popups"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
The map is served by {selected.map}. If the frame is blank, open the
|
||||||
|
full map and check HTTPS, renderer startup, reverse proxy settings,
|
||||||
|
and framing policy. A loaded frame alone does not verify renderer
|
||||||
|
health.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={<Map size={40} />}
|
||||||
|
title={
|
||||||
|
selected
|
||||||
|
? "Connect this map to its web address"
|
||||||
|
: "Your worlds, from a different perspective"
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
selected
|
||||||
|
? `Publish loopback port ${selected.mapPort} through an HTTPS reverse proxy, then save the browser URL in this server’s Live web map settings.`
|
||||||
|
: "Create a world server with BlueMap or Dynmap, deploy a compatible renderer, and add its browser URL."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Terrain & dimensions",
|
||||||
|
body: "The renderer builds real tiles or 3D geometry from your server’s worlds. Initial rendering takes time and uses CPU, disk, and memory.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Live player locations",
|
||||||
|
body: "Enable player visibility in the renderer and configure permissions. Players, caves, and hidden areas follow its privacy settings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Claims & markers",
|
||||||
|
body: "Claim boundaries need a compatible integration for your claims plugin. Configure the addon on the server; its overlays appear here automatically.",
|
||||||
|
},
|
||||||
|
].map((x) => (
|
||||||
|
<Card key={x.title} title={x.title}>
|
||||||
|
<p className="text-xs leading-relaxed text-ink2">{x.body}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ import {
|
|||||||
forceReregister,
|
forceReregister,
|
||||||
importFromWhitelist,
|
importFromWhitelist,
|
||||||
} from "@/server/authflow";
|
} from "@/server/authflow";
|
||||||
import { whitelistList, easyauthList } from "@/server/mc";
|
import { whitelistList, easyauthList, authProvider, registrationCommand } from "@/server/mc";
|
||||||
import { ok, fail, readJson, handle } from "@/server/api";
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,7 +23,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
registered = await easyauthList();
|
registered = await easyauthList();
|
||||||
} catch {}
|
} catch {}
|
||||||
return ok({ approved: listApproved(), whitelist, registered });
|
return ok({ approved: listApproved(), whitelist, registered, authProvider: authProvider(), registrationCommand: registrationCommand() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
|
import { consoleTarget } from "@/server/console-target";
|
||||||
|
import { inspectContainer } from "@/server/docker";
|
||||||
|
import { readNetwork, NetworkInputError } from "@/server/network-config";
|
||||||
|
import { profileConsole } from "@/server/network";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { rcon, stripColors } from "@/server/rcon";
|
import { rcon, stripColors } from "@/server/rcon";
|
||||||
import { logActivity } from "@/server/db";
|
import { logActivity } from "@/server/db";
|
||||||
import { pushLogLine } from "@/server/bus";
|
import { pushLogLine } from "@/server/bus";
|
||||||
import { ok, fail, readJson, handle } from "@/server/api";
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
const targets = [{ id: "primary", name: "Primary server" }, ...readNetwork().profiles.map(({ id, name }) => ({ id, name }))];
|
||||||
|
return ok(await Promise.all(targets.map(async (target) => ({ ...target, ...await inspectContainer(consoleTarget(target.id)) }))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
const { command } = await readJson<{ command?: string }>(req);
|
const { command, server = "primary" } = await readJson<{ command?: string; server?: string }>(req);
|
||||||
if (!command?.trim()) return fail("Empty command");
|
if (typeof command !== "string" || !command.trim() || command.length > 2000 || /[\r\n\0]/.test(command)) return fail("Enter one console command");
|
||||||
|
try { consoleTarget(server); } catch (e) { if (e instanceof NetworkInputError) return fail(e.message); throw e; }
|
||||||
|
if (!(await inspectContainer(consoleTarget(server))).running) return fail("This server is stopped. Start it from Overview or Servers first.", 409);
|
||||||
const cmd = command.trim().replace(/^\//, "");
|
const cmd = command.trim().replace(/^\//, "");
|
||||||
const output = stripColors(await rcon().exec(cmd));
|
const output = stripColors(server === "primary" ? await rcon().exec(cmd) : await profileConsole(server, cmd));
|
||||||
logActivity("console", null, cmd);
|
logActivity("console", null, cmd);
|
||||||
// Echo the exchange into the live console stream so it reads like a terminal.
|
// Echo the exchange into the live console stream so it reads like a terminal.
|
||||||
pushLogLine(`> ${cmd}`);
|
pushLogLine(`> ${cmd}`);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return handle(async () => {
|
return handle(async () => {
|
||||||
|
if (Number(req.headers.get("content-length") || 0) > MAX_UPLOAD + 1024 * 1024) return fail("File too large (256 MB max)", 413);
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
const dir = (form.get("dir") as string) ?? "";
|
const dir = (form.get("dir") as string) ?? "";
|
||||||
const file = form.get("file");
|
const file = form.get("file");
|
||||||
|
|||||||
@@ -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) > 101 * 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$/i.test(rel) &&
|
||||||
|
!/^world\/datapacks\/[^/]+\.zip$/i.test(rel)
|
||||||
|
)
|
||||||
|
return fail(
|
||||||
|
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
|
||||||
|
);
|
||||||
|
if (fs.existsSync(file))
|
||||||
|
return fail("A file already exists with this name");
|
||||||
|
data = Buffer.from(await upload.arrayBuffer());
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!allowed.test(file) ||
|
||||||
|
typeof body.content !== "string" ||
|
||||||
|
Buffer.byteLength(body.content) > 2 * 1024 * 1024
|
||||||
|
)
|
||||||
|
return fail("Choose a supported configuration file under 2 MB");
|
||||||
|
data = Buffer.from(body.content);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, data, { flag: form ? "wx" : "w" });
|
||||||
|
try {
|
||||||
|
fs.chownSync(file, 1000, 1000);
|
||||||
|
} catch {}
|
||||||
|
return ok({ saved: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { ok, fail, handle, readJson } from "@/server/api";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { readEnvFile } from "@/server/envfile";
|
||||||
|
import { assertDeploymentEditable } from "@/server/managed";
|
||||||
|
import { inspectContainer, powerContainer } from "@/server/docker";
|
||||||
|
import {
|
||||||
|
readNetwork,
|
||||||
|
writeNetwork,
|
||||||
|
validateProfile,
|
||||||
|
validateProxy,
|
||||||
|
profileById,
|
||||||
|
NetworkInputError,
|
||||||
|
} from "@/server/network-config";
|
||||||
|
import { serverName, proxyName } from "@/server/network-render";
|
||||||
|
import {
|
||||||
|
networkBusy,
|
||||||
|
assertNetworkIdle,
|
||||||
|
reconcileJobs,
|
||||||
|
checkPorts,
|
||||||
|
queueNetworkJob,
|
||||||
|
deployProfile,
|
||||||
|
deployProxy,
|
||||||
|
stopProfile,
|
||||||
|
restartProfile,
|
||||||
|
snapshotProfile,
|
||||||
|
restoreProfile,
|
||||||
|
profileConsole,
|
||||||
|
profileLogs,
|
||||||
|
profileBackups,
|
||||||
|
} from "@/server/network";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
function networkHandle(fn: () => Promise<ReturnType<typeof ok>>) {
|
||||||
|
return handle(async () => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof NetworkInputError) return fail(e.message, 400);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return networkHandle(async () => {
|
||||||
|
const config = reconcileJobs(readNetwork());
|
||||||
|
const id = req.nextUrl.searchParams.get("id");
|
||||||
|
const kind = req.nextUrl.searchParams.get("kind");
|
||||||
|
if (id) {
|
||||||
|
profileById(config, id);
|
||||||
|
if (kind === "logs") return ok(await profileLogs(id));
|
||||||
|
if (kind === "backups") return ok(profileBackups(id));
|
||||||
|
}
|
||||||
|
const entries = await Promise.all(
|
||||||
|
[
|
||||||
|
...config.profiles.map((p) => [p.id, serverName(p.id)]),
|
||||||
|
["proxy", proxyName],
|
||||||
|
].map(async ([id, name]) => {
|
||||||
|
try {
|
||||||
|
return [id, await inspectContainer(name)] as const;
|
||||||
|
} catch (e) {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
exists: false,
|
||||||
|
running: false,
|
||||||
|
status: "unavailable",
|
||||||
|
error: (e as Error).message,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return ok({
|
||||||
|
...config,
|
||||||
|
managed: env.managed,
|
||||||
|
runtime: env.containerRuntime,
|
||||||
|
states: Object.fromEntries(entries),
|
||||||
|
primaryPort: Number(readEnvFile().MC_PORT || 25565),
|
||||||
|
busy: networkBusy(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return networkHandle(async () => {
|
||||||
|
assertDeploymentEditable();
|
||||||
|
assertNetworkIdle();
|
||||||
|
const body = await readJson<Record<string, unknown>>(req);
|
||||||
|
if (!body || typeof body !== "object") return fail("Invalid request");
|
||||||
|
const config = readNetwork();
|
||||||
|
if (body.op === "save") {
|
||||||
|
const existing = body.id ? profileById(config, body.id) : undefined;
|
||||||
|
if (!existing && config.profiles.length >= 32)
|
||||||
|
return fail("Maximum 32 profiles per panel");
|
||||||
|
const p = validateProfile(body.profile, existing);
|
||||||
|
config.profiles = [...config.profiles.filter((s) => s.id !== p.id), p];
|
||||||
|
if (!config.proxy.lobby && p.route === "proxy") config.proxy.lobby = p.id;
|
||||||
|
checkPorts(config);
|
||||||
|
writeNetwork(config);
|
||||||
|
return ok(p);
|
||||||
|
}
|
||||||
|
if (body.op === "proxy-save") {
|
||||||
|
config.proxy = validateProxy(body.proxy, config);
|
||||||
|
checkPorts(config);
|
||||||
|
writeNetwork(config);
|
||||||
|
return ok(config.proxy);
|
||||||
|
}
|
||||||
|
if (body.op === "proxy-apply") {
|
||||||
|
if (body.confirm !== "apply proxy")
|
||||||
|
return fail(
|
||||||
|
"Confirm applying the proxy; existing connections will close",
|
||||||
|
);
|
||||||
|
return ok(queueNetworkJob("proxy", "apply proxy", deployProxy));
|
||||||
|
}
|
||||||
|
if (body.op === "proxy-stop") {
|
||||||
|
if (body.confirm !== "stop proxy")
|
||||||
|
return fail("Confirm stopping the gateway");
|
||||||
|
return ok(
|
||||||
|
queueNetworkJob("proxy", "stop proxy", async () => {
|
||||||
|
if ((await inspectContainer(proxyName)).running)
|
||||||
|
await powerContainer(proxyName, "stop");
|
||||||
|
return "Gateway stopped. World servers and their data remain available.";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const p = profileById(config, body.id);
|
||||||
|
if (body.op === "command")
|
||||||
|
return ok(await profileConsole(p.id, body.command as string));
|
||||||
|
if (body.confirm !== p.name)
|
||||||
|
return fail(`Confirm by entering the server name: ${p.name}`);
|
||||||
|
switch (body.op) {
|
||||||
|
case "deploy":
|
||||||
|
return ok(
|
||||||
|
queueNetworkJob(p.id, "deploy", (progress) =>
|
||||||
|
deployProfile(p.id, progress),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case "stop":
|
||||||
|
return ok(queueNetworkJob(p.id, "stop", () => stopProfile(p.id)));
|
||||||
|
case "restart":
|
||||||
|
return ok(queueNetworkJob(p.id, "restart", () => restartProfile(p.id)));
|
||||||
|
case "snapshot":
|
||||||
|
return ok(
|
||||||
|
queueNetworkJob(p.id, "snapshot", (progress) =>
|
||||||
|
snapshotProfile(p.id, progress),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case "restore": {
|
||||||
|
if (typeof body.backup !== "string") return fail("Choose a backup");
|
||||||
|
const name = body.backup;
|
||||||
|
return ok(
|
||||||
|
queueNetworkJob(p.id, "restore", (progress) =>
|
||||||
|
restoreProfile(p.id, name, progress),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fail("Unknown network action");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,51 +1,64 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { bus, logRing } from "@/server/bus";
|
import { streamContainerLogs, inspectContainer, type LogStreamHandle } from "@/server/docker";
|
||||||
|
import { consoleTarget } from "@/server/console-target";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
/** Live console stream (SSE). Backfills recent lines, then follows. */
|
/** Each viewer follows its selected container, independent of the primary watcher. */
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
let name: string;
|
||||||
|
try { name = consoleTarget(req.nextUrl.searchParams.get("server")); }
|
||||||
|
catch { return Response.json({ error: "Unknown server" }, { status: 400 }); }
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
let closed = false;
|
let cleanup = () => {};
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const send = (line: string) => {
|
let closed = false;
|
||||||
|
let logs: LogStreamHandle | undefined;
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const send = (event: string, value: string) => {
|
||||||
|
if (closed) return;
|
||||||
|
try { controller.enqueue(encoder.encode(`${event === "message" ? "" : `event: ${event}\n`}data: ${JSON.stringify(value.split(env.rconPassword).join("[redacted]"))}\n\n`)); }
|
||||||
|
catch { cleanup(); }
|
||||||
|
};
|
||||||
|
const follow = async (tail: number) => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
|
const state = await inspectContainer(name);
|
||||||
} catch {
|
if (closed) return;
|
||||||
closed = true;
|
send("status", state.running ? "Connected" : "Server stopped — showing its last logs. Start it from Overview or Servers.");
|
||||||
|
if (!state.exists) throw new Error("Container has not been deployed");
|
||||||
|
logs = streamContainerLogs(name, { tail, follow: true }, line => send("message", line), err => {
|
||||||
|
if (closed) return;
|
||||||
|
if (err) send("status", err.message);
|
||||||
|
retry = setTimeout(() => void follow(0), 5000);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
send("status", (e as Error).message);
|
||||||
|
if (!closed) retry = setTimeout(() => void follow(tail), 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const line of logRing()) send(line);
|
|
||||||
const onLog = (line: string) => send(line);
|
|
||||||
bus().on("log", onLog);
|
|
||||||
const keepalive = setInterval(() => {
|
const keepalive = setInterval(() => {
|
||||||
if (closed) return;
|
if (!closed) { try { controller.enqueue(encoder.encode(": keepalive\n\n")); } catch { cleanup(); } }
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
||||||
} catch {
|
|
||||||
closed = true;
|
|
||||||
}
|
|
||||||
}, 15000);
|
}, 15000);
|
||||||
req.signal.addEventListener("abort", () => {
|
cleanup = () => {
|
||||||
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
bus().off("log", onLog);
|
|
||||||
clearInterval(keepalive);
|
clearInterval(keepalive);
|
||||||
try {
|
clearTimeout(retry);
|
||||||
controller.close();
|
logs?.stop();
|
||||||
} catch {}
|
req.signal.removeEventListener("abort", cleanup);
|
||||||
});
|
try { controller.close(); } catch {}
|
||||||
},
|
};
|
||||||
});
|
req.signal.addEventListener("abort", cleanup, { once: true });
|
||||||
|
if (req.signal.aborted) cleanup(); else void follow(200);
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache, no-transform",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
},
|
||||||
|
cancel() { cleanup(); },
|
||||||
});
|
});
|
||||||
|
return new Response(stream, { headers: {
|
||||||
|
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive", "X-Accel-Buffering": "no",
|
||||||
|
} });
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
|
Network,
|
||||||
|
Map,
|
||||||
|
Compass,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
import { Badge, Toaster } from "@/components/ui";
|
import { Badge, Toaster } from "@/components/ui";
|
||||||
@@ -26,6 +29,9 @@ import { timeAgo } from "@/lib/format";
|
|||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: "/", label: "Overview", icon: Gauge },
|
{ 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: "/console", label: "Console", icon: TerminalSquare },
|
||||||
{ href: "/players", label: "Players", icon: Users },
|
{ href: "/players", label: "Players", icon: Users },
|
||||||
{ href: "/access", label: "Access & Auth", icon: ShieldCheck },
|
{ href: "/access", label: "Access & Auth", icon: ShieldCheck },
|
||||||
@@ -139,7 +145,7 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CircleDot size={12} />
|
<CircleDot size={12} />
|
||||||
{isRunning ? "Online" : "Offline"}
|
Primary · {isRunning ? "Online" : "Offline"}
|
||||||
</span>
|
</span>
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<span className="text-xs text-ink2">
|
<span className="text-xs text-ink2">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/** Shared, serializable network configuration. No credentials belong here. */
|
||||||
|
export const LOADERS = [
|
||||||
|
"PAPER",
|
||||||
|
"FOLIA",
|
||||||
|
"FABRIC",
|
||||||
|
"FORGE",
|
||||||
|
"NEOFORGE",
|
||||||
|
"QUILT",
|
||||||
|
"VANILLA",
|
||||||
|
"MODRINTH",
|
||||||
|
] as const;
|
||||||
|
export type Loader = (typeof LOADERS)[number];
|
||||||
|
export type WorldProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
loader: Loader;
|
||||||
|
java: "8" | "17" | "21" | "25";
|
||||||
|
seed: string;
|
||||||
|
memory: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
difficulty: string;
|
||||||
|
mode: string;
|
||||||
|
viewDistance: number;
|
||||||
|
simulationDistance: number;
|
||||||
|
pvp: boolean;
|
||||||
|
hardcore: boolean;
|
||||||
|
route: "proxy" | "direct" | "internal";
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
mods: string;
|
||||||
|
modpack: string;
|
||||||
|
modpackVersion: string;
|
||||||
|
loaderVersion: string;
|
||||||
|
map: "none" | "bluemap" | "dynmap";
|
||||||
|
mapPort: number;
|
||||||
|
mapUrl: string;
|
||||||
|
resourcePack: string;
|
||||||
|
resourcePackSha1: string;
|
||||||
|
resourcePackRequired: boolean;
|
||||||
|
resourcePackPrompt: string;
|
||||||
|
whitelist: string;
|
||||||
|
backupHours: number;
|
||||||
|
retentionDays: number;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
export type ProxySettings = {
|
||||||
|
port: number;
|
||||||
|
lobby: string;
|
||||||
|
motd: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
export type NetworkJob = {
|
||||||
|
id: string;
|
||||||
|
profileId: string;
|
||||||
|
action: string;
|
||||||
|
state: "queued" | "running" | "succeeded" | "failed";
|
||||||
|
message: string;
|
||||||
|
startedAt: number;
|
||||||
|
finishedAt?: number;
|
||||||
|
};
|
||||||
|
export type NetworkConfig = {
|
||||||
|
schema: 1;
|
||||||
|
profiles: WorldProfile[];
|
||||||
|
proxy: ProxySettings;
|
||||||
|
jobs: NetworkJob[];
|
||||||
|
};
|
||||||
|
export type RuntimeState = {
|
||||||
|
exists: boolean;
|
||||||
|
running: boolean;
|
||||||
|
status: string;
|
||||||
|
health?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
export type NetworkView = NetworkConfig & {
|
||||||
|
managed: boolean;
|
||||||
|
runtime: string;
|
||||||
|
states: Record<string, RuntimeState>;
|
||||||
|
primaryPort: number;
|
||||||
|
busy: boolean;
|
||||||
|
};
|
||||||
|
export const defaultProfile: Omit<WorldProfile, "id" | "createdAt"> = {
|
||||||
|
name: "",
|
||||||
|
version: "1.21.11",
|
||||||
|
loader: "PAPER",
|
||||||
|
java: "21",
|
||||||
|
seed: "",
|
||||||
|
memory: 4,
|
||||||
|
maxPlayers: 20,
|
||||||
|
difficulty: "normal",
|
||||||
|
mode: "survival",
|
||||||
|
viewDistance: 10,
|
||||||
|
simulationDistance: 6,
|
||||||
|
pvp: true,
|
||||||
|
hardcore: false,
|
||||||
|
route: "proxy",
|
||||||
|
port: 25570,
|
||||||
|
hostname: "",
|
||||||
|
mods: "",
|
||||||
|
modpack: "",
|
||||||
|
modpackVersion: "",
|
||||||
|
loaderVersion: "",
|
||||||
|
map: "none",
|
||||||
|
mapPort: 8101,
|
||||||
|
mapUrl: "",
|
||||||
|
resourcePack: "",
|
||||||
|
resourcePackSha1: "",
|
||||||
|
resourcePackRequired: false,
|
||||||
|
resourcePackPrompt: "",
|
||||||
|
whitelist: "",
|
||||||
|
backupHours: 24,
|
||||||
|
retentionDays: 7,
|
||||||
|
};
|
||||||
|
export const proxyCompatible = (loader: Loader) =>
|
||||||
|
loader === "PAPER" || loader === "FOLIA";
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/** Apple's native macOS container CLI. The panel process must run on the Mac host. */
|
||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { atomicWrite } from "./network-config";
|
||||||
|
import type {
|
||||||
|
ContainerState,
|
||||||
|
ContainerStats,
|
||||||
|
LogStreamHandle,
|
||||||
|
PowerAction,
|
||||||
|
} from "./docker";
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
export type AppleInspect = {
|
||||||
|
configuration: {
|
||||||
|
id: string;
|
||||||
|
image: { reference: string };
|
||||||
|
initProcess: { environment: string[] };
|
||||||
|
mounts: Array<{ source: string; destination: string }>;
|
||||||
|
resources: { memoryInBytes: number };
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
state: string;
|
||||||
|
startedDate?: string;
|
||||||
|
networks: Array<{ ipv4Address: string; network: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
function cli() {
|
||||||
|
if (process.platform !== "darwin")
|
||||||
|
throw new Error(
|
||||||
|
"Apple container control must run on the macOS host. Use scripts/apple-container-dev.sh.",
|
||||||
|
);
|
||||||
|
return process.env.APPLE_CONTAINER_CLI || "/opt/homebrew/bin/container";
|
||||||
|
}
|
||||||
|
export async function appleCommand(args: string[], timeout = 30_000) {
|
||||||
|
try {
|
||||||
|
return await exec(cli(), args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
} catch (error) {
|
||||||
|
// execFile's generic message includes all argv: report stderr rather than secrets/commands.
|
||||||
|
const e = error as Error & { stderr?: string; code?: string | number };
|
||||||
|
if (e.code === "ENOENT")
|
||||||
|
throw new Error(
|
||||||
|
"Apple container CLI is unavailable; check APPLE_CONTAINER_CLI",
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
e.stderr?.trim() ||
|
||||||
|
`Apple container ${args[0]} failed (${e.code ?? "timeout"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function appleInspectRaw(
|
||||||
|
name: string,
|
||||||
|
): Promise<AppleInspect | null> {
|
||||||
|
try {
|
||||||
|
const r = await appleCommand(["inspect", name]);
|
||||||
|
return (JSON.parse(r.stdout) as AppleInspect[])[0] ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
/not found|does not exist|no such container/i.test((e as Error).message)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const health = new Map<string, { time: number; value: string }>();
|
||||||
|
export async function appleInspect(name: string): Promise<ContainerState> {
|
||||||
|
const info = await appleInspectRaw(name);
|
||||||
|
if (!info) return { exists: false, running: false, status: "missing" };
|
||||||
|
const running = info.status.state === "running";
|
||||||
|
let ready: string | undefined;
|
||||||
|
if (running && !name.endsWith("-backup")) {
|
||||||
|
const cached = health.get(name);
|
||||||
|
if (cached && Date.now() - cached.time < 8000) ready = cached.value;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
await appleCommand(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
name,
|
||||||
|
"mc-monitor",
|
||||||
|
"status",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
"25565",
|
||||||
|
],
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
ready = "healthy";
|
||||||
|
} catch {
|
||||||
|
ready = "starting";
|
||||||
|
}
|
||||||
|
health.set(name, { time: Date.now(), value: ready });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
running,
|
||||||
|
status: info.status.state,
|
||||||
|
health: ready,
|
||||||
|
startedAt: info.status.startedDate,
|
||||||
|
image: info.configuration.image.reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function applePower(name: string, action: PowerAction) {
|
||||||
|
health.delete(name);
|
||||||
|
if (action === "restart") {
|
||||||
|
const current = await appleInspectRaw(name);
|
||||||
|
if (current?.status.state === "running")
|
||||||
|
await appleCommand(["stop", "--time", "90", name], 150000);
|
||||||
|
await appleCommand(["start", name], 120000);
|
||||||
|
} else
|
||||||
|
await appleCommand(
|
||||||
|
action === "stop" ? ["stop", "--time", "90", name] : [action, name],
|
||||||
|
150000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function appleExec(name: string, args: string[], timeout: number) {
|
||||||
|
try {
|
||||||
|
const r = await appleCommand(["exec", name, ...args], timeout);
|
||||||
|
return { exitCode: 0, output: r.stdout + r.stderr };
|
||||||
|
} catch (e) {
|
||||||
|
return { exitCode: 1, output: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function appleEnvironment(name: string) {
|
||||||
|
const info = await appleInspectRaw(name);
|
||||||
|
if (!info) throw new Error("Container not found");
|
||||||
|
return Object.fromEntries(
|
||||||
|
info.configuration.initProcess.environment.map((s) => {
|
||||||
|
const i = s.indexOf("=");
|
||||||
|
return [s.slice(0, i), s.slice(i + 1)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cpuSamples = new Map<string, { time: number; usage: number }>();
|
||||||
|
export async function appleStats(name: string): Promise<ContainerStats> {
|
||||||
|
const result = await appleCommand([
|
||||||
|
"stats",
|
||||||
|
name,
|
||||||
|
"--no-stream",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
]);
|
||||||
|
const s = JSON.parse(result.stdout)[0] as {
|
||||||
|
cpuUsageUsec: number;
|
||||||
|
memoryUsageBytes: number;
|
||||||
|
memoryLimitBytes: number;
|
||||||
|
networkRxBytes: number;
|
||||||
|
networkTxBytes: number;
|
||||||
|
};
|
||||||
|
if (!s) throw new Error("No container stats available");
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = cpuSamples.get(name);
|
||||||
|
cpuSamples.set(name, { time: now, usage: s.cpuUsageUsec });
|
||||||
|
return {
|
||||||
|
cpuPercent:
|
||||||
|
prev && now > prev.time
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
((s.cpuUsageUsec - prev.usage) / ((now - prev.time) * 1000)) * 100,
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
memUsed: s.memoryUsageBytes,
|
||||||
|
memLimit: s.memoryLimitBytes,
|
||||||
|
netRx: s.networkRxBytes,
|
||||||
|
netTx: s.networkTxBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function appleLogStream(
|
||||||
|
name: string,
|
||||||
|
opts: { tail: number; follow: boolean },
|
||||||
|
onLine: (s: string) => void,
|
||||||
|
onEnd?: (e?: Error) => void,
|
||||||
|
): LogStreamHandle {
|
||||||
|
let child: ReturnType<typeof spawn>;
|
||||||
|
try {
|
||||||
|
child = spawn(cli(), [
|
||||||
|
"logs",
|
||||||
|
"-n",
|
||||||
|
String(opts.tail),
|
||||||
|
...(opts.follow ? ["--follow"] : []),
|
||||||
|
name,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
onEnd?.(e as Error);
|
||||||
|
return { stop() {} };
|
||||||
|
}
|
||||||
|
let carry = "";
|
||||||
|
let stopped = false;
|
||||||
|
const receive = (chunk: Buffer) => {
|
||||||
|
carry += chunk.toString();
|
||||||
|
let i;
|
||||||
|
while ((i = carry.indexOf("\n")) >= 0) {
|
||||||
|
onLine(carry.slice(0, i));
|
||||||
|
carry = carry.slice(i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout?.on("data", receive);
|
||||||
|
child.stderr?.on("data", receive);
|
||||||
|
child.on("error", (e) => {
|
||||||
|
if (!stopped) onEnd?.(e);
|
||||||
|
});
|
||||||
|
child.on("close", () => {
|
||||||
|
if (!stopped) onEnd?.();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
child.kill();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function appleInfrastructure() {
|
||||||
|
await appleCommand(["system", "status"]);
|
||||||
|
const network = process.env.APPLE_CONTAINER_NETWORK || "craftdeck";
|
||||||
|
try {
|
||||||
|
await appleCommand(["network", "inspect", network]);
|
||||||
|
} catch (e) {
|
||||||
|
if (!/not found|does not exist/i.test((e as Error).message)) throw e;
|
||||||
|
await appleCommand(["network", "create", network]);
|
||||||
|
}
|
||||||
|
return { hostDataDir: env.mcDataDir, network };
|
||||||
|
}
|
||||||
|
export async function appleAddress(name: string) {
|
||||||
|
const info = await appleInspectRaw(name);
|
||||||
|
const address = info?.status.networks[0]?.ipv4Address?.split("/")[0];
|
||||||
|
if (!address)
|
||||||
|
throw new Error(`Start ${name} before applying gateway routing`);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
type Service = {
|
||||||
|
image: string;
|
||||||
|
container_name: string;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
mem_limit?: string;
|
||||||
|
volumes: string[];
|
||||||
|
ports?: string[];
|
||||||
|
};
|
||||||
|
type ComposePlan = {
|
||||||
|
services: Record<string, Service>;
|
||||||
|
networks: { stack: { name: string } };
|
||||||
|
};
|
||||||
|
/** Translate only CraftDeck's own generated service definition, never arbitrary Compose input. */
|
||||||
|
export function appleRunArguments(
|
||||||
|
service: Service,
|
||||||
|
network: string,
|
||||||
|
envFile: string,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
service.container_name,
|
||||||
|
"--cpus",
|
||||||
|
service.container_name.endsWith("-backup") ? "1" : "2",
|
||||||
|
"--memory",
|
||||||
|
(service.mem_limit ?? "512m").toUpperCase(),
|
||||||
|
"--network",
|
||||||
|
network,
|
||||||
|
"--dns",
|
||||||
|
process.env.APPLE_CONTAINER_DNS || "1.1.1.1",
|
||||||
|
...(service.container_name === "craftdeck-velocity"
|
||||||
|
? [
|
||||||
|
"--user",
|
||||||
|
`${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
"--env-file",
|
||||||
|
envFile,
|
||||||
|
...service.volumes.flatMap((v) => ["--volume", v]),
|
||||||
|
...(service.ports ?? []).flatMap((p) => ["--publish", p]),
|
||||||
|
service.image,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async function runService(service: Service, network: string, dir: string) {
|
||||||
|
// Pull first so a registry failure leaves the existing container available.
|
||||||
|
await appleCommand(["image", "pull", service.image], 15 * 60_000);
|
||||||
|
const previous = await appleInspectRaw(service.container_name);
|
||||||
|
if (previous) {
|
||||||
|
if (previous.status.state === "running")
|
||||||
|
await applePower(service.container_name, "stop");
|
||||||
|
await appleCommand(["delete", service.container_name]);
|
||||||
|
}
|
||||||
|
health.delete(service.container_name);
|
||||||
|
const file = path.join(dir, `${service.container_name}.env`);
|
||||||
|
atomicWrite(
|
||||||
|
file,
|
||||||
|
Object.entries(service.environment)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("\n") + "\n",
|
||||||
|
);
|
||||||
|
await appleCommand(appleRunArguments(service, network, file), 120_000);
|
||||||
|
}
|
||||||
|
export async function appleWaitReady(name: string, seconds = 600) {
|
||||||
|
const deadline = Date.now() + seconds * 1000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const info = await appleInspect(name);
|
||||||
|
if (info.health === "healthy") return;
|
||||||
|
if (!info.running)
|
||||||
|
throw new Error(
|
||||||
|
`${name} exited before Minecraft became ready; inspect its console`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
throw new Error(`${name} did not become ready within ${seconds} seconds`);
|
||||||
|
}
|
||||||
|
function readPlan(file: string): ComposePlan {
|
||||||
|
// Undo Compose's escaped dollar signs before passing literal values via env-file.
|
||||||
|
return JSON.parse(
|
||||||
|
fs.readFileSync(file, "utf8").replace(/\$\$/g, "$"),
|
||||||
|
) as ComposePlan;
|
||||||
|
}
|
||||||
|
/** A stopped/started Apple VM can acquire a new IP, including after snapshots. */
|
||||||
|
export async function appleRefreshBackup(file: string) {
|
||||||
|
const plan = readPlan(file);
|
||||||
|
const server = plan.services.server;
|
||||||
|
if (!server) throw new Error("Missing server deployment plan");
|
||||||
|
await appleWaitReady(server.container_name);
|
||||||
|
const backup = plan.services.backup;
|
||||||
|
if (backup) {
|
||||||
|
backup.environment.RCON_HOST = await appleAddress(server.container_name);
|
||||||
|
await runService(backup, plan.networks.stack.name, path.dirname(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function appleApply(file: string) {
|
||||||
|
const plan = readPlan(file);
|
||||||
|
const network = plan.networks.stack.name;
|
||||||
|
const dir = path.dirname(file);
|
||||||
|
if (plan.services.server) {
|
||||||
|
await runService(plan.services.server, network, dir);
|
||||||
|
await appleRefreshBackup(file);
|
||||||
|
} else if (plan.services.proxy) {
|
||||||
|
await runService(plan.services.proxy, network, dir);
|
||||||
|
await appleWaitReady(plan.services.proxy.container_name, 180);
|
||||||
|
} else throw new Error("Unsupported Apple container deployment plan");
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
easyauthList,
|
easyauthList,
|
||||||
easyauthRemove,
|
easyauthRemove,
|
||||||
easyauthSetPassword,
|
easyauthSetPassword,
|
||||||
|
registrationCommand,
|
||||||
} from "./mc";
|
} from "./mc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +73,7 @@ export async function approvePlayer(username: string, note?: string): Promise<Ap
|
|||||||
)
|
)
|
||||||
.run(username, now, expires, note ?? null);
|
.run(username, now, expires, note ?? null);
|
||||||
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
|
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
|
||||||
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
|
notify("info", `Approved ${username}`, `They can now join and use ${registrationCommand()} — the window is ticking.`);
|
||||||
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
|
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ export async function resetPassword(username: string): Promise<string> {
|
|||||||
export async function forceReregister(username: string): Promise<void> {
|
export async function forceReregister(username: string): Promise<void> {
|
||||||
await easyauthRemove(username);
|
await easyauthRemove(username);
|
||||||
try {
|
try {
|
||||||
await kickPlayer(username, "Please rejoin and /register a new password");
|
await kickPlayer(username, `Please rejoin and use ${registrationCommand()} to choose a new password`);
|
||||||
} catch {}
|
} catch {}
|
||||||
await whitelistAdd(username);
|
await whitelistAdd(username);
|
||||||
db()
|
db()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||||
assertDeploymentEditable();
|
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) => {
|
return withServerOperation(() => new Promise((resolve) => {
|
||||||
execFile(
|
execFile(
|
||||||
"docker",
|
"docker",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { env } from "./env";
|
||||||
|
import { readNetwork, profileById } from "./network-config";
|
||||||
|
import { serverName } from "./network-render";
|
||||||
|
|
||||||
|
/** Resolve registered targets only; never accept a Docker name from a client. */
|
||||||
|
export function consoleTarget(id: string | null) {
|
||||||
|
if (!id || id === "primary") return env.mcContainer;
|
||||||
|
return serverName(profileById(readNetwork(), id).id);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import { StringDecoder } from "node:string_decoder";
|
||||||
|
import { appleInspect, applePower, appleExec, appleEnvironment, appleStats, appleLogStream, appleInfrastructure } from "./apple-container";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { withServerOperation } from "./operation";
|
import { withServerOperation } from "./operation";
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ type InspectResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function inspectContainer(name: string): Promise<ContainerState> {
|
export async function inspectContainer(name: string): Promise<ContainerState> {
|
||||||
|
if (env.containerRuntime === "apple") return appleInspect(name);
|
||||||
try {
|
try {
|
||||||
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||||
return {
|
return {
|
||||||
@@ -117,6 +120,7 @@ export async function inspectContainer(name: string): Promise<ContainerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function containerEnv(name: string): Promise<Record<string, string>> {
|
export async function containerEnv(name: string): Promise<Record<string, string>> {
|
||||||
|
if (env.containerRuntime === "apple") return appleEnvironment(name);
|
||||||
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const kv of data.Config.Env ?? []) {
|
for (const kv of data.Config.Env ?? []) {
|
||||||
@@ -135,6 +139,7 @@ export async function powerContainer(name: string, action: PowerAction): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
||||||
|
if (env.containerRuntime === "apple") return applePower(name, action);
|
||||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -172,6 +177,7 @@ type StatsResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function containerStats(name: string): Promise<ContainerStats> {
|
export async function containerStats(name: string): Promise<ContainerStats> {
|
||||||
|
if (env.containerRuntime === "apple") return appleStats(name);
|
||||||
// one-shot=false + stream=false makes the daemon take two samples so the
|
// 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
|
// precpu fields are populated and CPU% is computable from a single call
|
||||||
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
|
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
|
||||||
@@ -196,31 +202,41 @@ export async function containerStats(name: string): Promise<ContainerStats> {
|
|||||||
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
||||||
* [type, 0, 0, 0, len_be32] followed by len payload bytes.
|
* [type, 0, 0, 0, len_be32] followed by len payload bytes.
|
||||||
*/
|
*/
|
||||||
function createDemuxer(onLine: (line: string) => void) {
|
export function createDemuxer(onLine: (line: string) => void) {
|
||||||
let buf = Buffer.alloc(0);
|
let buf = Buffer.alloc(0);
|
||||||
let textCarry = "";
|
let textCarry = "";
|
||||||
return (chunk: Buffer) => {
|
const decoder = new StringDecoder("utf8");
|
||||||
buf = Buffer.concat([buf, chunk]);
|
let raw = false;
|
||||||
while (buf.length >= 8) {
|
const lines = () => {
|
||||||
const first = buf[0];
|
|
||||||
// Frames start with stream type 0/1/2; if not, the container was
|
|
||||||
// started with a TTY and the stream is raw text.
|
|
||||||
if (first > 2) {
|
|
||||||
textCarry += buf.toString("utf8");
|
|
||||||
buf = Buffer.alloc(0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const len = buf.readUInt32BE(4);
|
|
||||||
if (buf.length < 8 + len) break;
|
|
||||||
textCarry += buf.subarray(8, 8 + len).toString("utf8");
|
|
||||||
buf = buf.subarray(8 + len);
|
|
||||||
}
|
|
||||||
let nl;
|
let nl;
|
||||||
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
||||||
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
||||||
textCarry = textCarry.slice(nl + 1);
|
textCarry = textCarry.slice(nl + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const feed = (chunk: Buffer) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
if (buf.length && buf[0] > 2) raw = true;
|
||||||
|
if (raw) {
|
||||||
|
textCarry += decoder.write(buf);
|
||||||
|
buf = Buffer.alloc(0);
|
||||||
|
} else {
|
||||||
|
while (buf.length >= 8) {
|
||||||
|
const len = buf.readUInt32BE(4);
|
||||||
|
if (buf.length < 8 + len) break;
|
||||||
|
textCarry += decoder.write(buf.subarray(8, 8 + len));
|
||||||
|
buf = buf.subarray(8 + len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines();
|
||||||
|
};
|
||||||
|
feed.end = () => {
|
||||||
|
textCarry += decoder.end();
|
||||||
|
lines();
|
||||||
|
if (textCarry) onLine(textCarry.replace(/\r$/, ""));
|
||||||
|
textCarry = "";
|
||||||
|
};
|
||||||
|
return feed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogStreamHandle = { stop: () => void };
|
export type LogStreamHandle = { stop: () => void };
|
||||||
@@ -231,6 +247,7 @@ export function streamContainerLogs(
|
|||||||
onLine: (line: string) => void,
|
onLine: (line: string) => void,
|
||||||
onEnd?: (err?: Error) => void
|
onEnd?: (err?: Error) => void
|
||||||
): LogStreamHandle {
|
): LogStreamHandle {
|
||||||
|
if (env.containerRuntime === "apple") return appleLogStream(name, opts, onLine, onEnd);
|
||||||
const req = http.request({
|
const req = http.request({
|
||||||
socketPath: env.dockerSocket,
|
socketPath: env.dockerSocket,
|
||||||
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
|
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
|
||||||
@@ -245,7 +262,7 @@ export function streamContainerLogs(
|
|||||||
}
|
}
|
||||||
const demux = createDemuxer(onLine);
|
const demux = createDemuxer(onLine);
|
||||||
res.on("data", (c: Buffer) => demux(c));
|
res.on("data", (c: Buffer) => demux(c));
|
||||||
res.on("end", () => onEnd?.());
|
res.on("end", () => { demux.end(); if (!stopped) onEnd?.(); });
|
||||||
res.on("error", (e) => onEnd?.(e as Error));
|
res.on("error", (e) => onEnd?.(e as Error));
|
||||||
});
|
});
|
||||||
req.on("error", (e) => {
|
req.on("error", (e) => {
|
||||||
@@ -267,6 +284,7 @@ export async function execInContainer(
|
|||||||
cmd: string[],
|
cmd: string[],
|
||||||
timeoutMs = 15 * 60 * 1000
|
timeoutMs = 15 * 60 * 1000
|
||||||
): Promise<{ exitCode: number; output: string }> {
|
): Promise<{ exitCode: number; output: string }> {
|
||||||
|
if (env.containerRuntime === "apple") return appleExec(name, cmd, timeoutMs);
|
||||||
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
|
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
|
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
|
||||||
@@ -286,7 +304,7 @@ export async function execInContainer(
|
|||||||
text += line + "\n";
|
text += line + "\n";
|
||||||
});
|
});
|
||||||
res.on("data", (c: Buffer) => demux(c));
|
res.on("data", (c: Buffer) => demux(c));
|
||||||
res.on("end", () => resolve(text));
|
res.on("end", () => { demux.end(); resolve(text); });
|
||||||
res.on("error", reject);
|
res.on("error", reject);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -300,3 +318,13 @@ export async function execInContainer(
|
|||||||
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
|
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
|
||||||
return { exitCode: info.ExitCode, output };
|
return { exitCode: info.ExitCode, output };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Network profiles join the primary stack's Docker network and data mount. */
|
||||||
|
export async function networkInfrastructure() {
|
||||||
|
if (env.containerRuntime === "apple") return appleInfrastructure();
|
||||||
|
const data = await json<{ Mounts: Array<{ Type: string; Source: string; Destination: string }>; NetworkSettings: { Networks: Record<string, unknown> } }>(`/containers/${env.mcContainer}/json`);
|
||||||
|
const mount = data.Mounts.find(m => m.Destination === "/data" && m.Type === "bind");
|
||||||
|
const networks = Object.keys(data.NetworkSettings.Networks).filter(n => n !== "host" && n !== "none" && n !== "bridge");
|
||||||
|
if (!mount || networks.length !== 1) throw new Error("Network deployment requires the primary server's /data bind mount and one user-defined Docker network");
|
||||||
|
return { hostDataDir: mount.Source, network: networks[0] };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ function str(key: string, fallback: string): string {
|
|||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
dev,
|
dev,
|
||||||
|
containerRuntime: str("CONTAINER_RUNTIME", "docker"),
|
||||||
managed: process.env.PANEL_MANAGED === "true",
|
managed: process.env.PANEL_MANAGED === "true",
|
||||||
mcService: str("MC_SERVICE", "mc"),
|
mcService: str("MC_SERVICE", "mc"),
|
||||||
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
|
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
|
||||||
|
|||||||
@@ -384,9 +384,30 @@ export async function kickAll(reason: string): Promise<number> {
|
|||||||
|
|
||||||
// ── EasyAuth ────────────────────────────────────────────────────────────
|
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function authProvider() {
|
||||||
|
return fs.existsSync(path.join(env.mcDataDir, "config", "serverlogin-common.toml")) ? "serverlogin" : "easyauth";
|
||||||
|
}
|
||||||
|
export function registrationCommand() {
|
||||||
|
return authProvider() === "serverlogin" ? "/login <password> <password>" : "/register <password> <password>";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServerLoginAccounts(text: string): string[] {
|
||||||
|
const data = JSON.parse(text) as { formatVersion?: number; users?: Record<string, { lastKnownName?: string }> };
|
||||||
|
if (data.formatVersion !== 1 || !data.users || Array.isArray(data.users) || typeof data.users !== "object") throw new Error("Unsupported Server Login account database");
|
||||||
|
return Object.values(data.users).map(account => account?.lastKnownName).filter((name): name is string => typeof name === "string" && isValidUsername(name));
|
||||||
|
}
|
||||||
|
|
||||||
/** Registered account names according to EasyAuth. */
|
/** Registered account names according to EasyAuth. */
|
||||||
export async function easyauthList(): Promise<string[]> {
|
export async function easyauthList(): Promise<string[]> {
|
||||||
|
if (authProvider() === "serverlogin") {
|
||||||
|
// Confirm the server is reachable before reconciling registration windows.
|
||||||
|
await execChecked("auth count");
|
||||||
|
const file = path.join(env.mcDataDir, "world", "serverlogin", "users.json");
|
||||||
|
if (!fs.existsSync(file)) return [];
|
||||||
|
return parseServerLoginAccounts(fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
const out = stripColors(await rcon().exec("auth list"));
|
const out = stripColors(await rcon().exec("auth list"));
|
||||||
|
if (/unknown|incorrect|expected/i.test(out)) throw new Error("Authentication provider did not return an account list");
|
||||||
// Output is a list of registered usernames; extract all name-like tokens
|
// Output is a list of registered usernames; extract all name-like tokens
|
||||||
// after a colon if present, otherwise from the whole string.
|
// after a colon if present, otherwise from the whole string.
|
||||||
const colon = out.indexOf(":");
|
const colon = out.indexOf(":");
|
||||||
@@ -404,6 +425,7 @@ export async function easyauthRemove(name: string): Promise<string> {
|
|||||||
|
|
||||||
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
|
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
|
||||||
assertValidUsername(name);
|
assertValidUsername(name);
|
||||||
|
if (authProvider() === "serverlogin") throw new Error("Use Force re-register, then let the player choose a new password in-game");
|
||||||
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
|
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
|
||||||
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
|
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { env } from "./env";
|
||||||
|
import {
|
||||||
|
LOADERS,
|
||||||
|
defaultProfile,
|
||||||
|
proxyCompatible,
|
||||||
|
type NetworkConfig,
|
||||||
|
type WorldProfile,
|
||||||
|
type ProxySettings,
|
||||||
|
} from "@/lib/network";
|
||||||
|
|
||||||
|
export class NetworkInputError extends Error {}
|
||||||
|
export function networkFile() {
|
||||||
|
return path.join(env.appDataDir, "network.json");
|
||||||
|
}
|
||||||
|
export function readNetwork(): NetworkConfig {
|
||||||
|
if (!fs.existsSync(networkFile()))
|
||||||
|
return {
|
||||||
|
schema: 1,
|
||||||
|
profiles: [],
|
||||||
|
proxy: {
|
||||||
|
port: 25566,
|
||||||
|
lobby: "",
|
||||||
|
motd: "A CraftDeck network",
|
||||||
|
version: "latest",
|
||||||
|
},
|
||||||
|
jobs: [],
|
||||||
|
};
|
||||||
|
const value = JSON.parse(
|
||||||
|
fs.readFileSync(networkFile(), "utf8"),
|
||||||
|
) as NetworkConfig;
|
||||||
|
if (
|
||||||
|
value.schema !== 1 ||
|
||||||
|
!Array.isArray(value.profiles) ||
|
||||||
|
!Array.isArray(value.jobs)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Invalid network registry; restore network.json from backup",
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
export function atomicWrite(file: string, content: string, mode = 0o600) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, content, { mode });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
export function writeNetwork(config: NetworkConfig) {
|
||||||
|
atomicWrite(networkFile(), JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
export function profileById(config: NetworkConfig, id: unknown) {
|
||||||
|
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
|
||||||
|
throw new NetworkInputError("Invalid server ID");
|
||||||
|
const p = config.profiles.find((p) => p.id === id);
|
||||||
|
if (!p) throw new NetworkInputError("Server profile not found");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
export function profileRoot(id: string) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(id))
|
||||||
|
throw new NetworkInputError("Invalid server ID");
|
||||||
|
return path.join(path.dirname(env.mcDataDir), "network", id);
|
||||||
|
}
|
||||||
|
function string(value: unknown, label: string, max = 200): string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
value.length > max ||
|
||||||
|
/[\x00-\x1f\x7f]/.test(value)
|
||||||
|
)
|
||||||
|
throw new NetworkInputError(`Invalid ${label}`);
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
function integer(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max)
|
||||||
|
throw new NetworkInputError(`${label} must be ${min}–${max}`);
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
function choice<T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
choices: readonly T[],
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
if (!choices.includes(value as T))
|
||||||
|
throw new NetworkInputError(`Invalid ${label}`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
function bool(value: unknown, label: string) {
|
||||||
|
if (typeof value !== "boolean")
|
||||||
|
throw new NetworkInputError(`Invalid ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function url(value: unknown, label: string) {
|
||||||
|
const s = string(value, label, 2000);
|
||||||
|
if (!s) return "";
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(s);
|
||||||
|
} catch {
|
||||||
|
throw new NetworkInputError(`${label} must be an HTTP(S) URL`);
|
||||||
|
}
|
||||||
|
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password)
|
||||||
|
throw new NetworkInputError(`Invalid ${label}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
export function validateProfile(
|
||||||
|
input: unknown,
|
||||||
|
existing?: WorldProfile,
|
||||||
|
): WorldProfile {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||||
|
throw new NetworkInputError("Missing profile");
|
||||||
|
const v = { ...defaultProfile, ...input } as Record<string, unknown>;
|
||||||
|
const name = string(v.name, "name", 60);
|
||||||
|
if (!name) throw new NetworkInputError("Name is required");
|
||||||
|
const version = string(v.version, "Minecraft version", 40);
|
||||||
|
if (!/^[0-9][a-zA-Z0-9._-]*$/.test(version))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Use an exact Minecraft release or snapshot version",
|
||||||
|
);
|
||||||
|
const p: WorldProfile = {
|
||||||
|
id:
|
||||||
|
existing?.id ??
|
||||||
|
`${
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 28) || "world"
|
||||||
|
}-${randomUUID().slice(0, 8)}`,
|
||||||
|
createdAt: existing?.createdAt ?? Date.now(),
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
loader: choice(v.loader, LOADERS, "loader"),
|
||||||
|
java: choice(v.java, ["8", "17", "21", "25"], "Java runtime"),
|
||||||
|
seed: string(v.seed, "seed", 128),
|
||||||
|
memory: integer(v.memory, "Heap in GiB", 1, 128),
|
||||||
|
maxPlayers: integer(v.maxPlayers, "Player slots", 1, 10000),
|
||||||
|
difficulty: choice(
|
||||||
|
v.difficulty,
|
||||||
|
["peaceful", "easy", "normal", "hard"],
|
||||||
|
"difficulty",
|
||||||
|
),
|
||||||
|
mode: choice(
|
||||||
|
v.mode,
|
||||||
|
["survival", "creative", "adventure", "spectator"],
|
||||||
|
"game mode",
|
||||||
|
),
|
||||||
|
viewDistance: integer(v.viewDistance, "View distance", 2, 32),
|
||||||
|
simulationDistance: integer(
|
||||||
|
v.simulationDistance,
|
||||||
|
"Simulation distance",
|
||||||
|
2,
|
||||||
|
32,
|
||||||
|
),
|
||||||
|
pvp: bool(v.pvp, "PVP"),
|
||||||
|
hardcore: bool(v.hardcore, "hardcore"),
|
||||||
|
route: choice(v.route, ["proxy", "direct", "internal"], "connection mode"),
|
||||||
|
port: integer(v.port, "Game port", 1024, 65535),
|
||||||
|
hostname: string(v.hostname, "hostname", 253).toLowerCase(),
|
||||||
|
mods: string(v.mods, "mods", 4000),
|
||||||
|
modpack: string(v.modpack, "modpack", 100),
|
||||||
|
modpackVersion: string(v.modpackVersion, "modpack version", 100),
|
||||||
|
loaderVersion: string(v.loaderVersion, "loader version", 60),
|
||||||
|
map: choice(v.map, ["none", "bluemap", "dynmap"], "map"),
|
||||||
|
mapPort: integer(v.mapPort, "Map port", 1024, 65535),
|
||||||
|
mapUrl: url(v.mapUrl, "Map URL"),
|
||||||
|
resourcePack: url(v.resourcePack, "Resource pack URL"),
|
||||||
|
resourcePackSha1: string(
|
||||||
|
v.resourcePackSha1,
|
||||||
|
"Resource pack SHA-1",
|
||||||
|
40,
|
||||||
|
).toLowerCase(),
|
||||||
|
resourcePackRequired: bool(v.resourcePackRequired, "Required pack"),
|
||||||
|
resourcePackPrompt: string(v.resourcePackPrompt, "Pack prompt", 300),
|
||||||
|
whitelist: string(v.whitelist, "whitelist", 8000),
|
||||||
|
backupHours: integer(v.backupHours, "Backup interval", 1, 168),
|
||||||
|
retentionDays: integer(v.retentionDays, "Backup retention", 1, 365),
|
||||||
|
};
|
||||||
|
for (const field of [p.modpack, p.modpackVersion, p.loaderVersion])
|
||||||
|
if (field && !/^[a-zA-Z0-9._+-]+$/.test(field))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Use loader/modpack IDs or versions, not URLs",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
p.mods &&
|
||||||
|
!p.mods.split(",").every((s) => /^[\w-]+(?::[\w.+-]+)?$/.test(s.trim()))
|
||||||
|
)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Mods must be comma-separated Modrinth slugs with optional :version IDs",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
p.whitelist &&
|
||||||
|
!p.whitelist.split(",").every((s) => /^[a-zA-Z0-9_]{3,16}$/.test(s.trim()))
|
||||||
|
)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Whitelist must contain comma-separated Minecraft usernames",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
p.hostname &&
|
||||||
|
!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(p.hostname)
|
||||||
|
)
|
||||||
|
throw new NetworkInputError("Invalid hostname");
|
||||||
|
if (p.route === "proxy" && !proxyCompatible(p.loader))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Automatic secure proxy routing supports Paper and Folia; use a direct port for this loader",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
p.route === "proxy" &&
|
||||||
|
/^1\.(\d+)/.test(p.version) &&
|
||||||
|
Number(p.version.split(".")[1]) < 19
|
||||||
|
)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Managed proxy profiles require Paper/Folia 1.19 or newer",
|
||||||
|
);
|
||||||
|
if (p.loader === "MODRINTH" && (!p.modpack || !p.modpackVersion))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Choose a Modrinth pack and an exact pack version ID",
|
||||||
|
);
|
||||||
|
if (p.loader !== "MODRINTH" && p.modpack)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Select Modrinth modpack as the runtime to install a pack",
|
||||||
|
);
|
||||||
|
if (p.map !== "none" && ["VANILLA", "MODRINTH"].includes(p.loader))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Automatic map installation needs an explicit mod/plugin loader",
|
||||||
|
);
|
||||||
|
if (p.resourcePackSha1 && !/^[a-f0-9]{40}$/.test(p.resourcePackSha1))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Resource pack SHA-1 must be 40 hexadecimal characters",
|
||||||
|
);
|
||||||
|
if (p.resourcePack && !p.resourcePackSha1)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Provide the resource pack SHA-1 so clients can verify downloads",
|
||||||
|
);
|
||||||
|
if (p.resourcePackRequired && !p.resourcePack)
|
||||||
|
throw new NetworkInputError("Required resource packs need a download URL");
|
||||||
|
if (existing)
|
||||||
|
for (const key of [
|
||||||
|
"loader",
|
||||||
|
"version",
|
||||||
|
"java",
|
||||||
|
"modpack",
|
||||||
|
"modpackVersion",
|
||||||
|
"seed",
|
||||||
|
"route",
|
||||||
|
] as const) {
|
||||||
|
if (
|
||||||
|
existing[key] !== p[key] &&
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(profileRoot(existing.id), "data", "server.properties"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Create a new profile for seed, version, runtime, pack or authentication changes; existing world data stays intact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
export function validateProxy(
|
||||||
|
input: unknown,
|
||||||
|
config: NetworkConfig,
|
||||||
|
): ProxySettings {
|
||||||
|
if (!input || typeof input !== "object")
|
||||||
|
throw new NetworkInputError("Missing proxy configuration");
|
||||||
|
const v = input as Record<string, unknown>;
|
||||||
|
const p = {
|
||||||
|
port: integer(v.port, "Proxy port", 1024, 65535),
|
||||||
|
lobby: string(v.lobby, "lobby", 48),
|
||||||
|
motd: string(v.motd, "MOTD", 200),
|
||||||
|
version: string(v.version, "Velocity version", 40),
|
||||||
|
};
|
||||||
|
if (!/^[a-zA-Z0-9._+-]+$/.test(p.version))
|
||||||
|
throw new NetworkInputError("Invalid Velocity version");
|
||||||
|
if (!config.profiles.some((s) => s.id === p.lobby && s.route === "proxy"))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Choose a Paper/Folia proxy profile as the lobby",
|
||||||
|
);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
export function validatePorts(
|
||||||
|
config: NetworkConfig,
|
||||||
|
primaryPort: number,
|
||||||
|
panelPort: number,
|
||||||
|
) {
|
||||||
|
const used = new Map<number, string>([
|
||||||
|
[primaryPort, "primary server"],
|
||||||
|
[panelPort, "panel"],
|
||||||
|
]);
|
||||||
|
const reserve = (port: number, name: string) => {
|
||||||
|
if (used.has(port))
|
||||||
|
throw new NetworkInputError(
|
||||||
|
`Port ${port} is already assigned to ${used.get(port)}`,
|
||||||
|
);
|
||||||
|
used.set(port, name);
|
||||||
|
};
|
||||||
|
reserve(config.proxy.port, "proxy");
|
||||||
|
const hosts = new Set<string>();
|
||||||
|
for (const p of config.profiles) {
|
||||||
|
if (p.route === "direct") reserve(p.port, p.name);
|
||||||
|
if (p.map !== "none") reserve(p.mapPort, `${p.name} map`);
|
||||||
|
if (p.route === "proxy" && p.hostname) {
|
||||||
|
if (hosts.has(p.hostname))
|
||||||
|
throw new NetworkInputError(`Duplicate hostname: ${p.hostname}`);
|
||||||
|
hosts.add(p.hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { profileRoot, NetworkInputError } from "./network-config";
|
||||||
|
export function networkFilePath(id: string, relative: string) {
|
||||||
|
const root = path.join(profileRoot(id), "data");
|
||||||
|
if (!fs.existsSync(root))
|
||||||
|
throw new NetworkInputError("Deploy this server before browsing files");
|
||||||
|
if (typeof relative !== "string" || relative.includes("\0"))
|
||||||
|
throw new NetworkInputError("Invalid file path");
|
||||||
|
const resolved = path.resolve(root, relative || ".");
|
||||||
|
if (resolved !== root && !resolved.startsWith(root + path.sep))
|
||||||
|
throw new NetworkInputError("Path escapes server directory");
|
||||||
|
let parent = resolved;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
fs.lstatSync(/* turbopackIgnore: true */ parent);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
||||||
|
parent = path.dirname(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const real = fs.realpathSync(/* turbopackIgnore: true */ parent);
|
||||||
|
const realRoot = fs.realpathSync(root);
|
||||||
|
if (real !== realRoot && !real.startsWith(realRoot + path.sep))
|
||||||
|
throw new NetworkInputError("Symlink escapes server directory");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { NetworkConfig, WorldProfile } from "@/lib/network";
|
||||||
|
|
||||||
|
export const serverName = (id: string) => `craftdeck-${id}`;
|
||||||
|
export const backupName = (id: string) => `craftdeck-${id}-backup`;
|
||||||
|
export const proxyName = "craftdeck-velocity";
|
||||||
|
export function serverEnvironment(
|
||||||
|
p: WorldProfile,
|
||||||
|
password: string,
|
||||||
|
mapProject = "",
|
||||||
|
) {
|
||||||
|
const mods = [p.mods, mapProject].filter(Boolean).join(",");
|
||||||
|
return {
|
||||||
|
EULA: "TRUE",
|
||||||
|
TYPE: p.loader,
|
||||||
|
VERSION: p.version,
|
||||||
|
MEMORY: `${p.memory}G`,
|
||||||
|
ONLINE_MODE: p.route === "proxy" ? "FALSE" : "TRUE",
|
||||||
|
ENFORCE_SECURE_PROFILE: p.route === "proxy" ? "FALSE" : "TRUE",
|
||||||
|
ENABLE_WHITELIST: "TRUE",
|
||||||
|
ENFORCE_WHITELIST: "TRUE",
|
||||||
|
WHITELIST: p.whitelist,
|
||||||
|
EXISTING_WHITELIST_FILE: "SYNCHRONIZE",
|
||||||
|
ENABLE_RCON: "TRUE",
|
||||||
|
RCON_PASSWORD: password,
|
||||||
|
RCON_PORT: "25575",
|
||||||
|
MOTD: p.name,
|
||||||
|
SEED: p.seed,
|
||||||
|
LEVEL: "world",
|
||||||
|
DIFFICULTY: p.difficulty,
|
||||||
|
MODE: p.mode,
|
||||||
|
MAX_PLAYERS: String(p.maxPlayers),
|
||||||
|
VIEW_DISTANCE: String(p.viewDistance),
|
||||||
|
SIMULATION_DISTANCE: String(p.simulationDistance),
|
||||||
|
PVP: String(p.pvp),
|
||||||
|
HARDCORE: String(p.hardcore),
|
||||||
|
SPAWN_PROTECTION: "0",
|
||||||
|
MODRINTH_PROJECTS: mods,
|
||||||
|
MODRINTH_DOWNLOAD_DEPENDENCIES: "required",
|
||||||
|
...(p.loader === "MODRINTH"
|
||||||
|
? { MODRINTH_MODPACK: p.modpack, MODRINTH_VERSION: p.modpackVersion }
|
||||||
|
: {}),
|
||||||
|
...(p.loaderVersion
|
||||||
|
? {
|
||||||
|
[(
|
||||||
|
{
|
||||||
|
FABRIC: "FABRIC_LOADER_VERSION",
|
||||||
|
FORGE: "FORGE_VERSION",
|
||||||
|
NEOFORGE: "NEOFORGE_VERSION",
|
||||||
|
QUILT: "QUILT_LOADER_VERSION",
|
||||||
|
} as Record<string, string>
|
||||||
|
)[p.loader] ?? "PAPER_BUILD"]: p.loaderVersion,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
RESOURCE_PACK: p.resourcePack,
|
||||||
|
RESOURCE_PACK_SHA1: p.resourcePackSha1,
|
||||||
|
RESOURCE_PACK_ENFORCE: String(p.resourcePackRequired),
|
||||||
|
RESOURCE_PACK_PROMPT: p.resourcePackPrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const logging = {
|
||||||
|
driver: "json-file",
|
||||||
|
options: { "max-size": "10m", "max-file": "3" },
|
||||||
|
};
|
||||||
|
export function renderProfileCompose(
|
||||||
|
p: WorldProfile,
|
||||||
|
infra: { hostDataDir: string; network: string },
|
||||||
|
password: string,
|
||||||
|
mapProject: string,
|
||||||
|
) {
|
||||||
|
const hostRoot = path.join(path.dirname(infra.hostDataDir), "network", p.id);
|
||||||
|
return {
|
||||||
|
name: `craftdeck-${p.id}`,
|
||||||
|
services: {
|
||||||
|
server: {
|
||||||
|
image: `itzg/minecraft-server:java${p.java}`,
|
||||||
|
container_name: serverName(p.id),
|
||||||
|
restart: "unless-stopped",
|
||||||
|
stop_grace_period: "2m",
|
||||||
|
logging,
|
||||||
|
environment: serverEnvironment(p, password, mapProject),
|
||||||
|
mem_limit: `${p.memory + 1}g`,
|
||||||
|
volumes: [`${hostRoot}/data:/data`],
|
||||||
|
networks: ["stack"],
|
||||||
|
ports: [
|
||||||
|
...(p.route === "direct" ? [`${p.port}:25565`] : []),
|
||||||
|
...(p.map !== "none"
|
||||||
|
? [`127.0.0.1:${p.mapPort}:${p.map === "bluemap" ? 8100 : 8123}`]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
image:
|
||||||
|
"itzg/mc-backup:latest@sha256:5ddce05d917f7fb94c752e7be5aeb67ec043360615b0043d9b40a78809de0383",
|
||||||
|
container_name: backupName(p.id),
|
||||||
|
restart: "unless-stopped",
|
||||||
|
logging,
|
||||||
|
mem_limit: "512m",
|
||||||
|
depends_on: { server: { condition: "service_healthy" } },
|
||||||
|
networks: ["stack"],
|
||||||
|
environment: {
|
||||||
|
BACKUP_INTERVAL: `${p.backupHours}h`,
|
||||||
|
INITIAL_DELAY: "10m",
|
||||||
|
PRUNE_BACKUPS_DAYS: String(p.retentionDays),
|
||||||
|
BACKUP_NAME: "world",
|
||||||
|
TAR_COMPRESS_METHOD: "gzip",
|
||||||
|
PAUSE_IF_NO_PLAYERS: "false",
|
||||||
|
RCON_HOST: serverName(p.id),
|
||||||
|
RCON_PORT: "25575",
|
||||||
|
RCON_PASSWORD: password,
|
||||||
|
EXCLUDES: "*.tmp,cache,logs",
|
||||||
|
},
|
||||||
|
volumes: [`${hostRoot}/data:/data:ro`, `${hostRoot}/backups:/backups`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
networks: { stack: { external: true, name: infra.network } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function renderVelocity(
|
||||||
|
config: NetworkConfig,
|
||||||
|
addresses: Record<string, string> = {},
|
||||||
|
) {
|
||||||
|
const routes = config.profiles.filter((p) => p.route === "proxy");
|
||||||
|
// JSON strings are valid TOML basic strings for the validated single-line inputs.
|
||||||
|
const q = JSON.stringify;
|
||||||
|
return `config-version = "2.8"\nbind = "0.0.0.0:25565"\nmotd = ${q(config.proxy.motd)}\nshow-max-players = ${routes.reduce((n, p) => n + p.maxPlayers, 0)}\nonline-mode = true\nforce-key-authentication = true\nplayer-info-forwarding-mode = "modern"\nforwarding-secret-file = "forwarding.secret"\nping-passthrough = "DISABLED"\n\n[servers]\n${routes.map((p) => `${q(p.id)} = ${q(`${addresses[p.id] ?? serverName(p.id)}:25565`)}`).join("\n")}\ntry = ${JSON.stringify([config.proxy.lobby, ...routes.map((p) => p.id).filter((id) => id !== config.proxy.lobby)])}\n\n[forced-hosts]\n${routes
|
||||||
|
.filter((p) => p.hostname)
|
||||||
|
.map((p) => `${q(p.hostname)} = [${q(p.id)}]`)
|
||||||
|
.join(
|
||||||
|
"\n",
|
||||||
|
)}\n\n[advanced]\nfailover-on-unexpected-server-disconnect = true\n`;
|
||||||
|
}
|
||||||
|
export function renderProxyCompose(
|
||||||
|
config: NetworkConfig,
|
||||||
|
infra: { hostDataDir: string; network: string },
|
||||||
|
) {
|
||||||
|
const root = path.join(path.dirname(infra.hostDataDir), "network", "proxy");
|
||||||
|
return {
|
||||||
|
name: "craftdeck-proxy",
|
||||||
|
services: {
|
||||||
|
proxy: {
|
||||||
|
image: "itzg/mc-proxy:latest",
|
||||||
|
container_name: proxyName,
|
||||||
|
restart: "unless-stopped",
|
||||||
|
logging,
|
||||||
|
environment: {
|
||||||
|
TYPE: "VELOCITY",
|
||||||
|
VELOCITY_VERSION: config.proxy.version,
|
||||||
|
MEMORY: "512m",
|
||||||
|
HEALTH_PORT: "25565",
|
||||||
|
},
|
||||||
|
mem_limit: "1g",
|
||||||
|
ports: [`${config.proxy.port}:25565`],
|
||||||
|
networks: ["stack"],
|
||||||
|
volumes: [`${root}/data:/server`, `${root}/config:/config:ro`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
networks: { stack: { external: true, name: infra.network } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Compose interpolates dollar signs even in JSON/YAML quoted strings. */
|
||||||
|
export function composeJson(value: unknown) {
|
||||||
|
return JSON.stringify(value, null, 2).replace(/\$/g, "$$$$");
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import {
|
||||||
|
appleApply,
|
||||||
|
appleAddress,
|
||||||
|
appleCommand,
|
||||||
|
appleRefreshBackup,
|
||||||
|
appleWaitReady,
|
||||||
|
} from "./apple-container";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { readEnvFile } from "./envfile";
|
||||||
|
import {
|
||||||
|
inspectContainer,
|
||||||
|
powerContainer,
|
||||||
|
execInContainer,
|
||||||
|
networkInfrastructure,
|
||||||
|
} from "./docker";
|
||||||
|
import { withServerOperation } from "./operation";
|
||||||
|
import { assertDeploymentEditable } from "./managed";
|
||||||
|
import {
|
||||||
|
atomicWrite,
|
||||||
|
readNetwork,
|
||||||
|
writeNetwork,
|
||||||
|
profileById,
|
||||||
|
profileRoot,
|
||||||
|
validatePorts,
|
||||||
|
NetworkInputError,
|
||||||
|
} from "./network-config";
|
||||||
|
import {
|
||||||
|
serverName,
|
||||||
|
backupName,
|
||||||
|
proxyName,
|
||||||
|
renderProfileCompose,
|
||||||
|
renderProxyCompose,
|
||||||
|
renderVelocity,
|
||||||
|
composeJson,
|
||||||
|
} from "./network-render";
|
||||||
|
import type { NetworkConfig, NetworkJob, WorldProfile } from "@/lib/network";
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
const globalJobs = globalThis as unknown as { __networkJobs?: Set<string> };
|
||||||
|
const runningJobs = (globalJobs.__networkJobs ??= new Set<string>());
|
||||||
|
export const networkBusy = () => runningJobs.size > 0;
|
||||||
|
export function assertNetworkIdle() {
|
||||||
|
if (networkBusy())
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Wait for the current network operation to finish",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function checkPorts(config: NetworkConfig) {
|
||||||
|
const values = readEnvFile();
|
||||||
|
validatePorts(
|
||||||
|
config,
|
||||||
|
Number(values.MC_PORT || 25565),
|
||||||
|
Number(values.DASHBOARD_PORT || 8080),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function reconcileJobs(config: NetworkConfig) {
|
||||||
|
let changed = false;
|
||||||
|
for (const job of config.jobs)
|
||||||
|
if (["queued", "running"].includes(job.state) && !runningJobs.has(job.id)) {
|
||||||
|
job.state = "failed";
|
||||||
|
job.message =
|
||||||
|
"Panel restarted during this operation. Inspect server status and logs before retrying.";
|
||||||
|
job.finishedAt = Date.now();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) writeNetwork(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
function redact(message: string) {
|
||||||
|
const secrets = [env.rconPassword];
|
||||||
|
const secretFile = path.join(
|
||||||
|
profileRoot("proxy"),
|
||||||
|
"config",
|
||||||
|
"forwarding.secret",
|
||||||
|
);
|
||||||
|
if (fs.existsSync(secretFile))
|
||||||
|
secrets.push(fs.readFileSync(secretFile, "utf8").trim());
|
||||||
|
for (const value of secrets.filter(Boolean))
|
||||||
|
message = message.split(value).join("[redacted]");
|
||||||
|
return message.slice(-2500);
|
||||||
|
}
|
||||||
|
function updateJob(id: string, changes: Partial<NetworkJob>) {
|
||||||
|
const config = readNetwork();
|
||||||
|
const job = config.jobs.find((j) => j.id === id);
|
||||||
|
if (job) {
|
||||||
|
Object.assign(job, changes);
|
||||||
|
writeNetwork(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function queueNetworkJob(
|
||||||
|
profileId: string,
|
||||||
|
action: string,
|
||||||
|
operation: (progress: (message: string) => void) => Promise<string>,
|
||||||
|
) {
|
||||||
|
assertDeploymentEditable();
|
||||||
|
assertNetworkIdle();
|
||||||
|
const config = readNetwork();
|
||||||
|
const job: NetworkJob = {
|
||||||
|
id: randomUUID(),
|
||||||
|
profileId,
|
||||||
|
action,
|
||||||
|
state: "queued",
|
||||||
|
message: "Waiting for server operations",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
config.jobs = [job, ...config.jobs].slice(0, 40);
|
||||||
|
writeNetwork(config);
|
||||||
|
runningJobs.add(job.id);
|
||||||
|
void withServerOperation(async () => {
|
||||||
|
try {
|
||||||
|
updateJob(job.id, { state: "running" });
|
||||||
|
const result = await operation((message) =>
|
||||||
|
updateJob(job.id, { message }),
|
||||||
|
);
|
||||||
|
updateJob(job.id, {
|
||||||
|
state: "succeeded",
|
||||||
|
message: result,
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
updateJob(job.id, {
|
||||||
|
state: "failed",
|
||||||
|
message: redact((e as Error).message),
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A full/unwritable registry must not leave the process locked forever.
|
||||||
|
// The next registry read reconciles this unfinished job as interrupted.
|
||||||
|
console.error("[craftdeck] Could not persist network operation status");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runningJobs.delete(job.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
async function compose(file: string, args: string[]) {
|
||||||
|
if (env.containerRuntime === "apple") return appleApply(file);
|
||||||
|
try {
|
||||||
|
await exec(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"compose",
|
||||||
|
"--project-directory",
|
||||||
|
path.dirname(file),
|
||||||
|
"-f",
|
||||||
|
file,
|
||||||
|
...args,
|
||||||
|
],
|
||||||
|
{ timeout: 15 * 60_000, maxBuffer: 4 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { stderr?: string };
|
||||||
|
throw new Error(redact(err.stderr || err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function secret() {
|
||||||
|
const file = path.join(profileRoot("proxy"), "config", "forwarding.secret");
|
||||||
|
if (!fs.existsSync(file))
|
||||||
|
atomicWrite(file, randomBytes(32).toString("hex"), 0o644);
|
||||||
|
return fs.readFileSync(file, "utf8").trim();
|
||||||
|
}
|
||||||
|
async function resolveMap(p: WorldProfile) {
|
||||||
|
if (p.map === "none") return "";
|
||||||
|
const loader = p.loader.toLowerCase();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
loaders: JSON.stringify([loader]),
|
||||||
|
game_versions: JSON.stringify([p.version]),
|
||||||
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.modrinth.com/v2/project/${p.map}/version?${params}`,
|
||||||
|
{
|
||||||
|
headers: { "User-Agent": "CraftDeck/1.0" },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(
|
||||||
|
`Could not resolve ${p.map} (${res.status}); choose another map or install a compatible release manually`,
|
||||||
|
);
|
||||||
|
const versions = (await res.json()) as Array<{
|
||||||
|
id: string;
|
||||||
|
version_type: string;
|
||||||
|
}>;
|
||||||
|
const version = versions.find((v) => v.version_type === "release");
|
||||||
|
if (!version)
|
||||||
|
throw new Error(
|
||||||
|
`No stable ${p.map} build advertises ${p.loader} ${p.version} support. No server files were changed.`,
|
||||||
|
);
|
||||||
|
return `${p.map}:${version.id}`;
|
||||||
|
}
|
||||||
|
async function validatePack(p: WorldProfile) {
|
||||||
|
if (p.loader !== "MODRINTH") return;
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.modrinth.com/v2/project/${encodeURIComponent(p.modpack)}/version/${encodeURIComponent(p.modpackVersion)}`,
|
||||||
|
{
|
||||||
|
headers: { "User-Agent": "CraftDeck/1.0" },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Modrinth pack version could not be resolved");
|
||||||
|
const version = (await res.json()) as { game_versions: string[] };
|
||||||
|
if (!version.game_versions.includes(p.version))
|
||||||
|
throw new Error(
|
||||||
|
"The pack version does not support the selected Minecraft version",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function prepareForwarding(p: WorldProfile) {
|
||||||
|
if (p.route !== "proxy") return;
|
||||||
|
const file = path.join(
|
||||||
|
profileRoot(p.id),
|
||||||
|
"data",
|
||||||
|
"config",
|
||||||
|
"paper-global.yml",
|
||||||
|
);
|
||||||
|
const doc = fs.existsSync(file)
|
||||||
|
? YAML.parseDocument(fs.readFileSync(file, "utf8"))
|
||||||
|
: new YAML.Document({});
|
||||||
|
if (doc.errors.length)
|
||||||
|
throw new Error(
|
||||||
|
"Could not parse paper-global.yml; repair it before deploying",
|
||||||
|
);
|
||||||
|
doc.setIn(["proxies", "velocity", "enabled"], true);
|
||||||
|
doc.setIn(["proxies", "velocity", "online-mode"], true);
|
||||||
|
doc.setIn(["proxies", "velocity", "secret"], secret());
|
||||||
|
atomicWrite(file, doc.toString(), 0o644);
|
||||||
|
try {
|
||||||
|
fs.chownSync(file, 1000, 1000);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
export async function deployProfile(id: string, progress: (s: string) => void) {
|
||||||
|
const config = readNetwork();
|
||||||
|
checkPorts(config);
|
||||||
|
const p = profileById(config, id);
|
||||||
|
progress("Checking loader, map and container runtime");
|
||||||
|
await validatePack(p);
|
||||||
|
const mapProject = await resolveMap(p);
|
||||||
|
const infra = await networkInfrastructure();
|
||||||
|
const root = profileRoot(id);
|
||||||
|
fs.mkdirSync(path.join(root, "data"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
|
||||||
|
const state = await inspectContainer(serverName(id));
|
||||||
|
if (
|
||||||
|
state.running ||
|
||||||
|
fs.existsSync(path.join(root, "data", "world", "level.dat"))
|
||||||
|
) {
|
||||||
|
progress("Taking a verified backup before applying changes");
|
||||||
|
await snapshotProfile(id, progress);
|
||||||
|
}
|
||||||
|
const backup = await inspectContainer(backupName(id));
|
||||||
|
if (backup.running) await powerContainer(backupName(id), "stop");
|
||||||
|
if ((await inspectContainer(serverName(id))).running)
|
||||||
|
await powerContainer(serverName(id), "stop");
|
||||||
|
prepareForwarding(p);
|
||||||
|
const file = path.join(root, "compose.json");
|
||||||
|
atomicWrite(
|
||||||
|
file,
|
||||||
|
composeJson(renderProfileCompose(p, infra, env.rconPassword, mapProject)),
|
||||||
|
);
|
||||||
|
progress(
|
||||||
|
"Downloading runtime and starting server; first world generation can take several minutes",
|
||||||
|
);
|
||||||
|
await compose(file, ["up", "-d", "--wait", "--wait-timeout", "600"]);
|
||||||
|
const ready = await inspectContainer(serverName(id));
|
||||||
|
if (ready.health !== "healthy")
|
||||||
|
throw new Error(
|
||||||
|
"Container started but Minecraft readiness is unverified; check its console",
|
||||||
|
);
|
||||||
|
const routing = await refreshAppleGateway();
|
||||||
|
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : p.route === "internal" ? "Available to the existing gateway on the private Docker network." : `Connect on port ${p.port}.`)}`;
|
||||||
|
}
|
||||||
|
/** Native VM addresses are ephemeral. Refresh only a gateway the user has started. */
|
||||||
|
async function refreshAppleGateway() {
|
||||||
|
if (
|
||||||
|
env.containerRuntime !== "apple" ||
|
||||||
|
!(await inspectContainer(proxyName)).running
|
||||||
|
)
|
||||||
|
return "";
|
||||||
|
const config = readNetwork();
|
||||||
|
const addresses: Record<string, string> = {};
|
||||||
|
const active = [];
|
||||||
|
for (const p of config.profiles.filter((p) => p.route === "proxy")) {
|
||||||
|
const state = await inspectContainer(serverName(p.id));
|
||||||
|
if (state.running && state.health === "healthy") {
|
||||||
|
addresses[p.id] = await appleAddress(serverName(p.id));
|
||||||
|
active.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!active.length) {
|
||||||
|
await powerContainer(proxyName, "stop");
|
||||||
|
return "No routed worlds are ready; gateway stopped. Apply routing after starting a world.";
|
||||||
|
}
|
||||||
|
const routed = {
|
||||||
|
...config,
|
||||||
|
profiles: active,
|
||||||
|
proxy: {
|
||||||
|
...config.proxy,
|
||||||
|
lobby: active.some((p) => p.id === config.proxy.lobby)
|
||||||
|
? config.proxy.lobby
|
||||||
|
: active[0].id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const file = path.join(profileRoot("proxy"), "config", "velocity.toml");
|
||||||
|
const rendered = renderVelocity(routed, addresses);
|
||||||
|
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === rendered)
|
||||||
|
return "Gateway routes are current.";
|
||||||
|
atomicWrite(file, rendered, 0o644);
|
||||||
|
await powerContainer(proxyName, "restart");
|
||||||
|
await appleWaitReady(proxyName, 180);
|
||||||
|
return "Gateway restarted with current world addresses; players can reconnect.";
|
||||||
|
}
|
||||||
|
export async function deployProxy(progress: (s: string) => void) {
|
||||||
|
const config = readNetwork();
|
||||||
|
checkPorts(config);
|
||||||
|
if (!config.proxy.lobby)
|
||||||
|
throw new NetworkInputError("Choose and save a lobby first");
|
||||||
|
const lobby = profileById(config, config.proxy.lobby);
|
||||||
|
if (lobby.route !== "proxy")
|
||||||
|
throw new NetworkInputError("Lobby must be a proxy profile");
|
||||||
|
const state = await inspectContainer(serverName(lobby.id));
|
||||||
|
if (!state.running || state.health !== "healthy")
|
||||||
|
throw new NetworkInputError(
|
||||||
|
"Start the lobby and wait until healthy before applying the proxy",
|
||||||
|
);
|
||||||
|
const infra = await networkInfrastructure();
|
||||||
|
secret();
|
||||||
|
const root = profileRoot("proxy");
|
||||||
|
const configFile = path.join(root, "config", "velocity.toml");
|
||||||
|
if (fs.existsSync(configFile))
|
||||||
|
fs.copyFileSync(configFile, `${configFile}.previous`);
|
||||||
|
const addresses: Record<string, string> = {};
|
||||||
|
if (env.containerRuntime === "apple") {
|
||||||
|
for (const p of config.profiles.filter((p) => p.route === "proxy"))
|
||||||
|
addresses[p.id] = await appleAddress(serverName(p.id));
|
||||||
|
}
|
||||||
|
atomicWrite(configFile, renderVelocity(config, addresses), 0o644);
|
||||||
|
fs.mkdirSync(path.join(root, "data"), { recursive: true });
|
||||||
|
const file = path.join(root, "compose.json");
|
||||||
|
atomicWrite(file, composeJson(renderProxyCompose(config, infra)));
|
||||||
|
progress("Applying Velocity routing; connected players will reconnect");
|
||||||
|
await compose(file, [
|
||||||
|
"up",
|
||||||
|
"-d",
|
||||||
|
"--force-recreate",
|
||||||
|
"--wait",
|
||||||
|
"--wait-timeout",
|
||||||
|
"180",
|
||||||
|
]);
|
||||||
|
return `Velocity is ready on port ${config.proxy.port}; lobby: ${lobby.name}`;
|
||||||
|
}
|
||||||
|
export async function stopProfile(id: string) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
if ((await inspectContainer(backupName(id))).running)
|
||||||
|
await powerContainer(backupName(id), "stop");
|
||||||
|
const state = await inspectContainer(serverName(id));
|
||||||
|
if (state.running) await powerContainer(serverName(id), "stop");
|
||||||
|
const routing = await refreshAppleGateway();
|
||||||
|
return `Server and backup writer stopped; all world files retained. ${routing}`.trim();
|
||||||
|
}
|
||||||
|
export async function restartProfile(id: string) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
if (
|
||||||
|
env.containerRuntime === "apple" &&
|
||||||
|
(await inspectContainer(backupName(id))).running
|
||||||
|
)
|
||||||
|
await powerContainer(backupName(id), "stop");
|
||||||
|
await powerContainer(serverName(id), "restart");
|
||||||
|
if (env.containerRuntime === "apple") {
|
||||||
|
await appleRefreshBackup(path.join(profileRoot(id), "compose.json"));
|
||||||
|
const routing = await refreshAppleGateway();
|
||||||
|
return `Server is healthy; backup connection refreshed. ${routing}`.trim();
|
||||||
|
}
|
||||||
|
const backup = await inspectContainer(backupName(id));
|
||||||
|
if (backup.exists && !backup.running)
|
||||||
|
await powerContainer(backupName(id), "start");
|
||||||
|
return "Restart requested; watch the health indicator for readiness";
|
||||||
|
}
|
||||||
|
export function profileBackups(id: string) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
const dir = path.join(profileRoot(id), "backups");
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((n) => /^[\w.-]+\.(tgz|tar\.gz)$/.test(n))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
size: fs.statSync(path.join(dir, name)).size,
|
||||||
|
mtime: fs.statSync(path.join(dir, name)).mtimeMs,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
}
|
||||||
|
export async function snapshotProfile(
|
||||||
|
id: string,
|
||||||
|
progress: (s: string) => void,
|
||||||
|
) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
const root = profileRoot(id);
|
||||||
|
const data = path.join(root, "data");
|
||||||
|
const server = await inspectContainer(serverName(id));
|
||||||
|
const backup = await inspectContainer(backupName(id));
|
||||||
|
progress("Stopping writers for a consistent world snapshot");
|
||||||
|
if (backup.running) await powerContainer(backupName(id), "stop");
|
||||||
|
if (server.running) await powerContainer(serverName(id), "stop");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(root, "backups"), { recursive: true });
|
||||||
|
const name = `snapshot-${Date.now()}.tgz`;
|
||||||
|
const file = path.join(root, "backups", name);
|
||||||
|
await exec("tar", ["-czf", `${file}.partial`, "-C", data, "."], {
|
||||||
|
timeout: 30 * 60_000,
|
||||||
|
});
|
||||||
|
await exec(
|
||||||
|
"python3",
|
||||||
|
[env.restoreHelper, "verify", "--archive", `${file}.partial`],
|
||||||
|
{ timeout: 30 * 60_000 },
|
||||||
|
);
|
||||||
|
fs.renameSync(`${file}.partial`, file);
|
||||||
|
return `Verified snapshot: ${name}`;
|
||||||
|
} finally {
|
||||||
|
if (server.running) await powerContainer(serverName(id), "start");
|
||||||
|
if (env.containerRuntime === "apple") {
|
||||||
|
if (server.running) {
|
||||||
|
progress(
|
||||||
|
"Waiting for world readiness and refreshing native container connections",
|
||||||
|
);
|
||||||
|
if (backup.running)
|
||||||
|
await appleRefreshBackup(path.join(root, "compose.json"));
|
||||||
|
else await appleWaitReady(serverName(id));
|
||||||
|
}
|
||||||
|
await refreshAppleGateway();
|
||||||
|
} else if (backup.running) await powerContainer(backupName(id), "start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function restoreProfile(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
progress: (s: string) => void,
|
||||||
|
) {
|
||||||
|
if (!profileBackups(id).some((b) => b.name === name))
|
||||||
|
throw new NetworkInputError("Backup not found");
|
||||||
|
const root = profileRoot(id);
|
||||||
|
const archive = path.join(root, "backups", name);
|
||||||
|
await exec("python3", [env.restoreHelper, "verify", "--archive", archive], {
|
||||||
|
timeout: 30 * 60_000,
|
||||||
|
});
|
||||||
|
await stopProfile(id);
|
||||||
|
progress(
|
||||||
|
"Restoring verified archive; previous data is retained in the recovery directory",
|
||||||
|
);
|
||||||
|
await exec(
|
||||||
|
"python3",
|
||||||
|
[
|
||||||
|
env.restoreHelper,
|
||||||
|
"restore",
|
||||||
|
"--archive",
|
||||||
|
archive,
|
||||||
|
"--data",
|
||||||
|
path.join(root, "data"),
|
||||||
|
"--work",
|
||||||
|
path.join(root, "recovery"),
|
||||||
|
],
|
||||||
|
{ timeout: 30 * 60_000 },
|
||||||
|
);
|
||||||
|
return "Restored. Server remains stopped; inspect files and deploy when ready.";
|
||||||
|
}
|
||||||
|
export async function profileConsole(id: string, command: string) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
if (
|
||||||
|
typeof command !== "string" ||
|
||||||
|
!command.trim() ||
|
||||||
|
command.length > 2000 ||
|
||||||
|
/[\r\n\0]/.test(command)
|
||||||
|
)
|
||||||
|
throw new NetworkInputError("Enter one console command");
|
||||||
|
const result = await execInContainer(
|
||||||
|
serverName(id),
|
||||||
|
["rcon-cli", command.trim().replace(/^\//, "")],
|
||||||
|
20000,
|
||||||
|
);
|
||||||
|
if (result.exitCode)
|
||||||
|
throw new Error(result.output || "Console command failed");
|
||||||
|
return result.output;
|
||||||
|
}
|
||||||
|
export async function profileLogs(id: string) {
|
||||||
|
profileById(readNetwork(), id);
|
||||||
|
const result =
|
||||||
|
env.containerRuntime === "apple"
|
||||||
|
? await appleCommand(["logs", "-n", "160", serverName(id)], 15000)
|
||||||
|
: await exec("docker", ["logs", "--tail", "160", serverName(id)], {
|
||||||
|
timeout: 15000,
|
||||||
|
maxBuffer: 512 * 1024,
|
||||||
|
});
|
||||||
|
return redactLogs(`${result.stdout}\n${result.stderr}`);
|
||||||
|
}
|
||||||
|
function redactLogs(s: string) {
|
||||||
|
return s.split(env.rconPassword).join("[redacted]").slice(-32000);
|
||||||
|
}
|
||||||
|
export { proxyName };
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
vi.mock("../src/server/env", () => ({ env: { mcDataDir: "" } }));
|
||||||
|
vi.mock("../src/server/rcon", () => ({ rcon: () => ({ exec: vi.fn() }), stripColors: (s: string) => s, RconError: class extends Error {} }));
|
||||||
|
import { env } from "../src/server/env";
|
||||||
|
import { authProvider, registrationCommand, parseServerLoginAccounts, easyauthSetPassword } from "../src/server/mc";
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-auth-")); env.mcDataDir = dir; });
|
||||||
|
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
it("uses the Forge registration flow and never sends an unsupported password reset command", async () => {
|
||||||
|
expect(authProvider()).toBe("easyauth");
|
||||||
|
fs.mkdirSync(path.join(dir, "config")); fs.writeFileSync(path.join(dir, "config/serverlogin-common.toml"), "");
|
||||||
|
expect(registrationCommand()).toBe("/login <password> <password>");
|
||||||
|
await expect(easyauthSetPassword("aryan", "test-only")).rejects.toThrow("Force re-register");
|
||||||
|
});
|
||||||
|
it("returns names only from the documented account database and rejects unknown formats", () => {
|
||||||
|
expect(parseServerLoginAccounts(JSON.stringify({ formatVersion: 1, users: { aryan: { lastKnownName: "aryan", salt: "private", hash: "private" } } }))).toEqual(["aryan"]);
|
||||||
|
expect(() => parseServerLoginAccounts('{"formatVersion":2,"users":{}}')).toThrow();
|
||||||
|
expect(() => parseServerLoginAccounts('{"formatVersion":1,"users":[]}')).toThrow();
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
vi.mock("../src/server/env", () => ({ env: { mcContainer: "mc" } }));
|
||||||
|
vi.mock("../src/server/network-config", () => ({
|
||||||
|
readNetwork: () => ({ profiles: [{ id: "horror", name: "Horror" }] }),
|
||||||
|
profileById: (config: { profiles: Array<{ id: string }> }, id: string) => {
|
||||||
|
const p = config.profiles.find(p => p.id === id);
|
||||||
|
if (!p) throw new Error("Unknown server");
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
import { createDemuxer } from "../src/server/docker";
|
||||||
|
import { consoleTarget } from "../src/server/console-target";
|
||||||
|
const frame = (payload: Buffer) => {
|
||||||
|
const header = Buffer.alloc(8); header[0] = 1; header.writeUInt32BE(payload.length, 4);
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
};
|
||||||
|
describe("console transport", () => {
|
||||||
|
it("keeps UTF-8 characters split across Docker frames and flushes output without a newline", () => {
|
||||||
|
const lines: string[] = []; const demux = createDemuxer(s => lines.push(s));
|
||||||
|
const bytes = Buffer.from("hello 🌫️\nDone");
|
||||||
|
const data = Buffer.concat([frame(bytes.subarray(0, 8)), frame(bytes.subarray(8))]);
|
||||||
|
for (const byte of data) demux(Buffer.from([byte]));
|
||||||
|
demux.end();
|
||||||
|
expect(lines).toEqual(["hello 🌫️", "Done"]);
|
||||||
|
});
|
||||||
|
it("shows short raw TTY output", () => {
|
||||||
|
const lines: string[] = []; const demux = createDemuxer(s => lines.push(s));
|
||||||
|
demux(Buffer.from("OK")); demux.end(); expect(lines).toEqual(["OK"]);
|
||||||
|
});
|
||||||
|
it("allows only primary or registered profile IDs", () => {
|
||||||
|
expect(consoleTarget(null)).toBe("mc");
|
||||||
|
expect(consoleTarget("horror")).toBe("craftdeck-horror");
|
||||||
|
expect(() => consoleTarget("komodo-core")).toThrow();
|
||||||
|
expect(() => consoleTarget("../escape")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
vi.mock("../src/server/env", () => ({
|
||||||
|
env: {
|
||||||
|
mcDataDir: "",
|
||||||
|
appDataDir: "",
|
||||||
|
projectDir: "",
|
||||||
|
managed: false,
|
||||||
|
rconPassword: "test-rcon",
|
||||||
|
restoreHelper: "/helper.py",
|
||||||
|
containerRuntime: "docker",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../src/server/docker", () => ({
|
||||||
|
inspectContainer: vi.fn(async () => ({
|
||||||
|
exists: true,
|
||||||
|
running: true,
|
||||||
|
health: "healthy",
|
||||||
|
})),
|
||||||
|
powerContainer: vi.fn(async () => {}),
|
||||||
|
execInContainer: vi.fn(),
|
||||||
|
networkInfrastructure: vi.fn(async () => ({
|
||||||
|
hostDataDir: "/srv/data/mc",
|
||||||
|
network: "stack",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("node:child_process", () => ({
|
||||||
|
spawn: vi.fn(),
|
||||||
|
execFile: vi.fn((...args: unknown[]) => {
|
||||||
|
const cb = args.at(-1) as (
|
||||||
|
err: Error | null,
|
||||||
|
stdout: string,
|
||||||
|
stderr: string,
|
||||||
|
) => void;
|
||||||
|
cb(null, "", "");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../src/server/apple-container", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../src/server/apple-container")>()),
|
||||||
|
appleRefreshBackup: vi.fn(async () => {}),
|
||||||
|
appleWaitReady: vi.fn(async () => {}),
|
||||||
|
appleAddress: vi.fn(async () => "192.0.2.25"),
|
||||||
|
}));
|
||||||
|
import { env } from "../src/server/env";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { inspectContainer, powerContainer } from "../src/server/docker";
|
||||||
|
import { defaultProfile } from "../src/lib/network";
|
||||||
|
import {
|
||||||
|
readNetwork,
|
||||||
|
writeNetwork,
|
||||||
|
validateProfile,
|
||||||
|
profileRoot,
|
||||||
|
} from "../src/server/network-config";
|
||||||
|
import {
|
||||||
|
deployProfile,
|
||||||
|
snapshotProfile,
|
||||||
|
restoreProfile,
|
||||||
|
restartProfile,
|
||||||
|
stopProfile,
|
||||||
|
queueNetworkJob,
|
||||||
|
reconcileJobs,
|
||||||
|
networkBusy,
|
||||||
|
assertNetworkIdle,
|
||||||
|
} from "../src/server/network";
|
||||||
|
import {
|
||||||
|
appleRunArguments,
|
||||||
|
appleRefreshBackup,
|
||||||
|
appleWaitReady,
|
||||||
|
} from "../src/server/apple-container";
|
||||||
|
let root: string;
|
||||||
|
let id: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-ops-"));
|
||||||
|
env.mcDataDir = path.join(root, "mc");
|
||||||
|
env.appDataDir = path.join(root, "app");
|
||||||
|
env.projectDir = root;
|
||||||
|
env.managed = false;
|
||||||
|
env.containerRuntime = "docker";
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(inspectContainer).mockResolvedValue({
|
||||||
|
exists: true,
|
||||||
|
running: true,
|
||||||
|
health: "healthy",
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
const p = validateProfile({ ...defaultProfile, name: "Lobby" });
|
||||||
|
id = p.id;
|
||||||
|
writeNetwork({
|
||||||
|
schema: 1,
|
||||||
|
profiles: [p],
|
||||||
|
proxy: { port: 25566, lobby: id, motd: "Network", version: "latest" },
|
||||||
|
jobs: [],
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(profileRoot(id), "data", "world"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(profileRoot(id), "data", "world", "level.dat"),
|
||||||
|
"test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
describe("network lifecycle safety", () => {
|
||||||
|
it("reconnects native backups and gateway routes after a restart changes the world IP", async () => {
|
||||||
|
env.containerRuntime = "apple";
|
||||||
|
await restartProfile(id);
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith(
|
||||||
|
`craftdeck-${id}-backup`,
|
||||||
|
"stop",
|
||||||
|
);
|
||||||
|
expect(appleRefreshBackup).toHaveBeenCalledWith(
|
||||||
|
path.join(profileRoot(id), "compose.json"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(profileRoot("proxy"), "config", "velocity.toml"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
).toContain("192.0.2.25:25565");
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith(
|
||||||
|
"craftdeck-velocity",
|
||||||
|
"restart",
|
||||||
|
);
|
||||||
|
expect(appleWaitReady).toHaveBeenCalledWith("craftdeck-velocity", 180);
|
||||||
|
const backupCall =
|
||||||
|
vi.mocked(appleRefreshBackup).mock.invocationCallOrder[0];
|
||||||
|
const gatewayCall = vi
|
||||||
|
.mocked(powerContainer)
|
||||||
|
.mock.invocationCallOrder.at(-1)!;
|
||||||
|
expect(backupCall).toBeLessThan(gatewayCall);
|
||||||
|
});
|
||||||
|
it("restores native connections even when snapshot creation fails", async () => {
|
||||||
|
env.containerRuntime = "apple";
|
||||||
|
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||||
|
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
|
||||||
|
return {} as ReturnType<typeof execFile>;
|
||||||
|
});
|
||||||
|
await expect(snapshotProfile(id, () => {})).rejects.toThrow("disk full");
|
||||||
|
expect(appleRefreshBackup).toHaveBeenCalledWith(
|
||||||
|
path.join(profileRoot(id), "compose.json"),
|
||||||
|
);
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith(
|
||||||
|
"craftdeck-velocity",
|
||||||
|
"restart",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("removes a stopped native world from routes instead of retaining its reusable IP", async () => {
|
||||||
|
env.containerRuntime = "apple";
|
||||||
|
const config = readNetwork();
|
||||||
|
const other = validateProfile({ ...defaultProfile, name: "Other world" });
|
||||||
|
config.profiles.push(other);
|
||||||
|
writeNetwork(config);
|
||||||
|
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
|
||||||
|
exists: true,
|
||||||
|
running: name !== `craftdeck-${id}`,
|
||||||
|
status: "running",
|
||||||
|
health: "healthy",
|
||||||
|
}));
|
||||||
|
await stopProfile(id);
|
||||||
|
const routes = fs.readFileSync(
|
||||||
|
path.join(profileRoot("proxy"), "config", "velocity.toml"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(routes).not.toContain(id);
|
||||||
|
expect(routes).toContain(other.id);
|
||||||
|
expect(readNetwork().proxy.lobby).toBe(id);
|
||||||
|
});
|
||||||
|
it("stops the native gateway when its last world is stopped", async () => {
|
||||||
|
env.containerRuntime = "apple";
|
||||||
|
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
|
||||||
|
exists: true,
|
||||||
|
running: name !== `craftdeck-${id}`,
|
||||||
|
status: "running",
|
||||||
|
health: "healthy",
|
||||||
|
}));
|
||||||
|
expect(await stopProfile(id)).toContain("Apply routing");
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith("craftdeck-velocity", "stop");
|
||||||
|
expect(powerContainer).not.toHaveBeenCalledWith(
|
||||||
|
"craftdeck-velocity",
|
||||||
|
"restart",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("backup failure cancels deployment and resumes original writers", async () => {
|
||||||
|
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||||
|
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
|
||||||
|
return {} as ReturnType<typeof execFile>;
|
||||||
|
});
|
||||||
|
await expect(deployProfile(id, () => {})).rejects.toThrow("disk full");
|
||||||
|
expect(vi.mocked(execFile).mock.calls.some((c) => c[0] === "docker")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith(`craftdeck-${id}`, "start");
|
||||||
|
expect(powerContainer).toHaveBeenCalledWith(
|
||||||
|
`craftdeck-${id}-backup`,
|
||||||
|
"start",
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(path.join(profileRoot(id), "compose.json"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("does not stop or change a world when map compatibility resolution fails", async () => {
|
||||||
|
const c = readNetwork();
|
||||||
|
c.profiles[0].map = "bluemap";
|
||||||
|
writeNetwork(c);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({ ok: true, json: async () => [] })),
|
||||||
|
);
|
||||||
|
await expect(deployProfile(id, () => {})).rejects.toThrow("No stable");
|
||||||
|
expect(powerContainer).not.toHaveBeenCalled();
|
||||||
|
expect(execFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("does not publish an archive until verification succeeds", async () => {
|
||||||
|
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||||
|
const argv = args[1] as string[];
|
||||||
|
fs.writeFileSync(argv[1], "partial");
|
||||||
|
(args.at(-1) as (e: null, stdout: string, stderr: string) => void)(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return {} as ReturnType<typeof execFile>;
|
||||||
|
});
|
||||||
|
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||||
|
(args.at(-1) as (e: Error) => void)(new Error("archive invalid"));
|
||||||
|
return {} as ReturnType<typeof execFile>;
|
||||||
|
});
|
||||||
|
await expect(snapshotProfile(id, () => {})).rejects.toThrow(
|
||||||
|
"archive invalid",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
fs
|
||||||
|
.readdirSync(path.join(profileRoot(id), "backups"))
|
||||||
|
.every((n) => n.endsWith(".partial")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("validates restore selection before stopping writers", async () => {
|
||||||
|
await expect(restoreProfile(id, "../../bad.tgz", () => {})).rejects.toThrow(
|
||||||
|
"Backup not found",
|
||||||
|
);
|
||||||
|
expect(powerContainer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("marks orphan jobs failed without retrying destructive operations", () => {
|
||||||
|
const c = readNetwork();
|
||||||
|
c.jobs = [
|
||||||
|
{
|
||||||
|
id: "old",
|
||||||
|
profileId: id,
|
||||||
|
action: "restore",
|
||||||
|
state: "running",
|
||||||
|
message: "",
|
||||||
|
startedAt: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
writeNetwork(c);
|
||||||
|
expect(reconcileJobs(c).jobs[0].state).toBe("failed");
|
||||||
|
expect(powerContainer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("blocks conflicting edits and persists job failures without secrets", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const job = queueNetworkJob(id, "test", async () => {
|
||||||
|
await gate;
|
||||||
|
throw new Error(`oops ${env.rconPassword}`);
|
||||||
|
});
|
||||||
|
expect(networkBusy()).toBe(true);
|
||||||
|
expect(() => assertNetworkIdle()).toThrow("Wait");
|
||||||
|
expect(() => queueNetworkJob(id, "second", async () => "ok")).toThrow(
|
||||||
|
"Wait",
|
||||||
|
);
|
||||||
|
release();
|
||||||
|
await vi.waitFor(() => expect(networkBusy()).toBe(false));
|
||||||
|
const saved = readNetwork().jobs.find((j) => j.id === job.id)!;
|
||||||
|
expect(saved.state).toBe("failed");
|
||||||
|
expect(saved.message).toBe("oops [redacted]");
|
||||||
|
});
|
||||||
|
it("managed deployments reject jobs before changing persistent state", () => {
|
||||||
|
env.managed = true;
|
||||||
|
expect(() => queueNetworkJob(id, "start", async () => "ok")).toThrow(
|
||||||
|
"managed by Komodo",
|
||||||
|
);
|
||||||
|
expect(readNetwork().jobs).toEqual([]);
|
||||||
|
});
|
||||||
|
it("uses the native Apple CLI with separate arguments and a credential file", () => {
|
||||||
|
const args = appleRunArguments(
|
||||||
|
{
|
||||||
|
image: "itzg/minecraft-server:java21",
|
||||||
|
container_name: "craftdeck-lobby",
|
||||||
|
environment: { RCON_PASSWORD: "never-in-argv" },
|
||||||
|
mem_limit: "3g",
|
||||||
|
volumes: ["/path with spaces/data:/data"],
|
||||||
|
ports: ["127.0.0.1:8101:8100"],
|
||||||
|
},
|
||||||
|
"craftdeck",
|
||||||
|
"/private/profile.env",
|
||||||
|
);
|
||||||
|
expect(args).toContain("/path with spaces/data:/data");
|
||||||
|
expect(args).toContain("3G");
|
||||||
|
expect(args).not.toContain("never-in-argv");
|
||||||
|
expect(args).not.toContain("docker");
|
||||||
|
expect(args).toContain("--env-file");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
vi.mock("../src/server/env", () => ({
|
||||||
|
env: { mcDataDir: "", appDataDir: "" },
|
||||||
|
}));
|
||||||
|
import { env } from "../src/server/env";
|
||||||
|
import {
|
||||||
|
defaultProfile,
|
||||||
|
type NetworkConfig,
|
||||||
|
type WorldProfile,
|
||||||
|
} from "../src/lib/network";
|
||||||
|
import {
|
||||||
|
validateProfile,
|
||||||
|
validatePorts,
|
||||||
|
validateProxy,
|
||||||
|
profileRoot,
|
||||||
|
readNetwork,
|
||||||
|
writeNetwork,
|
||||||
|
profileById,
|
||||||
|
} from "../src/server/network-config";
|
||||||
|
import {
|
||||||
|
composeJson,
|
||||||
|
renderProfileCompose,
|
||||||
|
renderVelocity,
|
||||||
|
serverEnvironment,
|
||||||
|
} from "../src/server/network-render";
|
||||||
|
import { networkFilePath } from "../src/server/network-files";
|
||||||
|
let dir: string;
|
||||||
|
const make = (overrides: Partial<WorldProfile> = {}) =>
|
||||||
|
validateProfile({ ...defaultProfile, name: "Test world", ...overrides });
|
||||||
|
const config = (profiles: WorldProfile[]): NetworkConfig => ({
|
||||||
|
schema: 1,
|
||||||
|
profiles,
|
||||||
|
proxy: {
|
||||||
|
port: 25566,
|
||||||
|
lobby: profiles[0]?.id ?? "",
|
||||||
|
motd: 'Friends "together"',
|
||||||
|
version: "latest",
|
||||||
|
},
|
||||||
|
jobs: [],
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-network-"));
|
||||||
|
env.mcDataDir = path.join(dir, "mc");
|
||||||
|
env.appDataDir = path.join(dir, "app");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
describe("isolated server profiles", () => {
|
||||||
|
it("generates server-owned IDs and ignores caller supplied paths", () => {
|
||||||
|
const p = make({ id: "../../outside", name: "../Friends ♥", createdAt: 1 });
|
||||||
|
expect(p.id).toMatch(/^friends-[a-f0-9]{8}$/);
|
||||||
|
expect(p.createdAt).not.toBe(1);
|
||||||
|
expect(() => profileRoot("../escape")).toThrow();
|
||||||
|
});
|
||||||
|
it.each(["FABRIC", "FORGE", "NEOFORGE", "QUILT", "VANILLA", "MODRINTH"])(
|
||||||
|
"does not claim transparent proxy support for %s",
|
||||||
|
(loader) => {
|
||||||
|
expect(() =>
|
||||||
|
make({ loader: loader as WorldProfile["loader"], route: "proxy" }),
|
||||||
|
).toThrow("Automatic secure proxy");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("requires exact modpack selection", () => {
|
||||||
|
expect(() =>
|
||||||
|
make({ loader: "MODRINTH", route: "direct", modpack: "pack" }),
|
||||||
|
).toThrow("exact pack version");
|
||||||
|
const p = make({
|
||||||
|
loader: "MODRINTH",
|
||||||
|
route: "direct",
|
||||||
|
modpack: "pack",
|
||||||
|
modpackVersion: "version123",
|
||||||
|
});
|
||||||
|
expect(serverEnvironment(p, "secret")).toMatchObject({
|
||||||
|
TYPE: "MODRINTH",
|
||||||
|
MODRINTH_MODPACK: "pack",
|
||||||
|
MODRINTH_VERSION: "version123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps modded internal servers authenticated without publishing any ports", () => {
|
||||||
|
const p = make({ loader: "FORGE", version: "1.20.1", java: "17", route: "internal" });
|
||||||
|
const c = renderProfileCompose(p, { hostDataDir: "/data/mc", network: "stack" }, "secret", "");
|
||||||
|
expect(c.services.server.ports).toEqual([]);
|
||||||
|
expect(c.services.server.environment.ONLINE_MODE).toBe("TRUE");
|
||||||
|
expect(c.services.server.environment.ENABLE_WHITELIST).toBe("TRUE");
|
||||||
|
});
|
||||||
|
it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
|
||||||
|
for (const update of [
|
||||||
|
{ memory: 0 },
|
||||||
|
{ memory: "4" },
|
||||||
|
{ pvp: "true" },
|
||||||
|
{ name: "world\nEULA=TRUE" },
|
||||||
|
{ resourcePack: "javascript:alert(1)" },
|
||||||
|
{ mapUrl: "https://user:password@example.com" },
|
||||||
|
{ version: "latest" },
|
||||||
|
])
|
||||||
|
expect(() =>
|
||||||
|
validateProfile({ ...defaultProfile, name: "world", ...update }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
it("requires checksums and validates resource pack consistency", () => {
|
||||||
|
expect(() =>
|
||||||
|
make({ resourcePack: "https://example.com/pack.zip" }),
|
||||||
|
).toThrow("SHA-1");
|
||||||
|
expect(() => make({ resourcePackRequired: true })).toThrow("download URL");
|
||||||
|
const p = make({
|
||||||
|
resourcePack: "https://example.com/pack.zip",
|
||||||
|
resourcePackSha1: "a".repeat(40),
|
||||||
|
resourcePackRequired: true,
|
||||||
|
});
|
||||||
|
expect(serverEnvironment(p, "secret")).toMatchObject({
|
||||||
|
RESOURCE_PACK_ENFORCE: "true",
|
||||||
|
RESOURCE_PACK_SHA1: "a".repeat(40),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("prevents seed, identity, version and runtime changes after initialization", () => {
|
||||||
|
const p = make();
|
||||||
|
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(profileRoot(p.id), "data", "server.properties"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
for (const update of [
|
||||||
|
{ seed: "123" },
|
||||||
|
{ version: "1.21.4" },
|
||||||
|
{ route: "direct" },
|
||||||
|
{ loader: "FOLIA" },
|
||||||
|
])
|
||||||
|
expect(() => validateProfile({ ...p, ...update }, p)).toThrow(
|
||||||
|
"Create a new profile",
|
||||||
|
);
|
||||||
|
expect(validateProfile({ ...p, memory: 8 }, p).memory).toBe(8);
|
||||||
|
});
|
||||||
|
it("persists registry and rejects malformed registry instead of losing profiles", () => {
|
||||||
|
const p = make();
|
||||||
|
writeNetwork(config([p]));
|
||||||
|
expect(readNetwork().profiles[0]).toEqual(p);
|
||||||
|
expect(profileById(readNetwork(), p.id)).toEqual(p);
|
||||||
|
fs.writeFileSync(path.join(env.appDataDir, "network.json"), "{");
|
||||||
|
expect(() => readNetwork()).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("routing and network isolation", () => {
|
||||||
|
it("reserves gateway, primary, dashboard, direct, and map ports together", () => {
|
||||||
|
for (const port of [25565, 25566, 8080])
|
||||||
|
expect(() =>
|
||||||
|
validatePorts(config([make({ route: "direct", port })]), 25565, 8080),
|
||||||
|
).toThrow("already assigned");
|
||||||
|
expect(() =>
|
||||||
|
validatePorts(
|
||||||
|
config([
|
||||||
|
make({
|
||||||
|
route: "direct",
|
||||||
|
port: 25570,
|
||||||
|
map: "bluemap",
|
||||||
|
mapPort: 25570,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
25565,
|
||||||
|
8080,
|
||||||
|
),
|
||||||
|
).toThrow("already assigned");
|
||||||
|
});
|
||||||
|
it("allows private backends to share internal game ports", () => {
|
||||||
|
expect(() =>
|
||||||
|
validatePorts(config([make(), make({ name: "Survival" })]), 25565, 8080),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
it("rejects duplicate hostname routes and direct-server lobbies", () => {
|
||||||
|
const p = make({ hostname: "survival.example.com" });
|
||||||
|
expect(() =>
|
||||||
|
validatePorts(config([p, make({ hostname: p.hostname })]), 25565, 8080),
|
||||||
|
).toThrow("Duplicate hostname");
|
||||||
|
const direct = make({ route: "direct" });
|
||||||
|
const c = config([direct]);
|
||||||
|
expect(() => validateProxy(c.proxy, c)).toThrow("Paper/Folia");
|
||||||
|
});
|
||||||
|
it("publishes no backend or RCON ports, isolates storage and enables forwarding authentication", () => {
|
||||||
|
const p = make({ map: "bluemap" });
|
||||||
|
const rendered = renderProfileCompose(
|
||||||
|
p,
|
||||||
|
{ hostDataDir: "/srv/game/data/mc", network: "minecraft_default" },
|
||||||
|
"secret",
|
||||||
|
"bluemap:exact",
|
||||||
|
);
|
||||||
|
expect(rendered.services.server.ports).toEqual(["127.0.0.1:8101:8100"]);
|
||||||
|
expect(rendered.services.server.volumes[0]).toBe(
|
||||||
|
`/srv/game/data/network/${p.id}/data:/data`,
|
||||||
|
);
|
||||||
|
expect(rendered.services.backup.volumes[1]).toBe(
|
||||||
|
`/srv/game/data/network/${p.id}/backups:/backups`,
|
||||||
|
);
|
||||||
|
expect(rendered.services.server.environment.ONLINE_MODE).toBe("FALSE");
|
||||||
|
expect(rendered.services.server.environment.MODRINTH_PROJECTS).toBe(
|
||||||
|
"bluemap:exact",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("direct profiles verify accounts and never inherit primary Fabric mods", () => {
|
||||||
|
const p = make({ loader: "FORGE", route: "direct" });
|
||||||
|
const e = serverEnvironment(p, "secret");
|
||||||
|
expect(e.ONLINE_MODE).toBe("TRUE");
|
||||||
|
expect(e.MODRINTH_PROJECTS).toBe("");
|
||||||
|
expect(e).not.toHaveProperty("FABRIC_LOADER_VERSION");
|
||||||
|
});
|
||||||
|
it("escapes Compose interpolation in all user strings and credentials", () => {
|
||||||
|
expect(
|
||||||
|
JSON.parse(composeJson({ password: "${SECRET}", motd: "Costs $5" })),
|
||||||
|
).toEqual({ password: "$${SECRET}", motd: "Costs $$5" });
|
||||||
|
});
|
||||||
|
it("emits only compatible routes with explicit lobby and modern forwarding", () => {
|
||||||
|
const lobby = make({ hostname: "lobby.example.com" });
|
||||||
|
const direct = make({ loader: "FABRIC", route: "direct" });
|
||||||
|
const text = renderVelocity(config([lobby, direct]));
|
||||||
|
expect(text).toContain('player-info-forwarding-mode = "modern"');
|
||||||
|
expect(text).toContain("online-mode = true");
|
||||||
|
expect(text).toContain(`try = ["${lobby.id}"]`);
|
||||||
|
expect(text).toContain(`"lobby.example.com" = ["${lobby.id}"]`);
|
||||||
|
expect(text).not.toContain(direct.id);
|
||||||
|
expect(text).toContain('motd = "Friends \\"together\\""');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("profile file boundaries", () => {
|
||||||
|
it("rejects traversal and symlink escapes including nonexistent descendants", () => {
|
||||||
|
const p = make();
|
||||||
|
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dir, "outside"));
|
||||||
|
fs.symlinkSync(
|
||||||
|
path.join(dir, "outside"),
|
||||||
|
path.join(profileRoot(p.id), "data", "link"),
|
||||||
|
);
|
||||||
|
expect(() => networkFilePath(p.id, "../../other")).toThrow("escapes");
|
||||||
|
expect(() => networkFilePath(p.id, "link/new/nested.yml")).toThrow(
|
||||||
|
"escapes",
|
||||||
|
);
|
||||||
|
fs.symlinkSync(
|
||||||
|
path.join(dir, "outside", "missing"),
|
||||||
|
path.join(profileRoot(p.id), "data", "dangling"),
|
||||||
|
);
|
||||||
|
expect(() => networkFilePath(p.id, "dangling")).toThrow();
|
||||||
|
expect(networkFilePath(p.id, "config/new.yml")).toBe(
|
||||||
|
path.join(profileRoot(p.id), "data", "config", "new.yml"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Play From the Fog Reimagined
|
||||||
|
|
||||||
|
[Open the pack website](https://packs.aryan-singh.dev).
|
||||||
|
|
||||||
|
**Server:** `minecraft.ayush-jain.dev`
|
||||||
|
**Pack:** From the Fog Reimagined **6.3.2** by SoumXD
|
||||||
|
**Minecraft:** 1.20.1 · Forge 47.4.10 · Java 17
|
||||||
|
|
||||||
|
## Install once
|
||||||
|
|
||||||
|
1. Open your launcher’s modpack installer. For offline-name accounts, [SKlauncher 4.0](https://docs.skmedix.pl/4.0/) supports CurseForge packs and automatic Java setup. Its 4.0 release is currently beta. If you use Microsoft sign-in, the [CurseForge app](https://www.curseforge.com/download/app) can install the pack directly.
|
||||||
|
2. Search **From the Fog Reimagined** by **SoumXD**, select **6.3.2**, and install the whole pack. [Exact official client release](https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined/files/9191040).
|
||||||
|
3. Launch that pack. Give the game 6 GB of memory if your computer has room; the pack author lists 4 GB as the client minimum. Shaders are optional—turn them off if performance is poor.
|
||||||
|
4. Multiplayer → Add Server → `minecraft.ayush-jain.dev`.
|
||||||
|
|
||||||
|
Use the exact username the admin approved, including capitalization. Start with `aryan` or `jainster` as appropriate. Ask the admin to approve any additional name in CraftDeck → Access & Auth immediately before your first join.
|
||||||
|
|
||||||
|
On first join, follow the server prompt: `/login YOUR_PASSWORD YOUR_PASSWORD`. On later joins: `/login YOUR_PASSWORD`. Choose your own password in the game; do not send it to the admin or reuse an important account password. If the registration window expires, the admin can reopen it in CraftDeck.
|
||||||
|
|
||||||
|
## If joining fails
|
||||||
|
|
||||||
|
- **Not whitelisted:** check the exact username and ask the admin to reopen approval.
|
||||||
|
- **Missing/incompatible mods:** launch the complete **6.3.2** pack, rather than vanilla or a standalone From the Fog datapack. Both sides must use 1.20.1 Forge.
|
||||||
|
- **Out of memory / slow:** allocate memory in this pack’s launcher settings and disable shaders first.
|
||||||
|
- **Cannot connect:** ask the admin to check the Minecraft container and TCPShield route. The CraftDeck admin address is private and is not the game server address.
|
||||||
|
|
||||||
|
The server uses the pack author’s matching server pack, plus a server-only login mod. Friends do not need to copy server files or install that login mod. Download pack content from the author through your launcher; no separate mod ZIP collection or hosted mirror is needed.
|
||||||
|
|
||||||
|
This is the official Reimagined pack baseline. An exact CaseOh-specific mod/shader list has not been verified.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Apple container for local tests; x86_64 Docker for production
|
||||||
|
|
||||||
|
The production target is an **x86_64 Linux host** running the normal Docker Compose
|
||||||
|
stack. The Dockerfile uses BuildKit's target architecture when installing the Docker
|
||||||
|
CLI and Compose plugin, so ARM64 local builds and x86_64 production builds each get
|
||||||
|
the correct binaries. No architecture is forced in the production Compose file.
|
||||||
|
|
||||||
|
The Mac development paths use Apple's official `container` CLI. Docker Desktop is
|
||||||
|
not required. The local data folders are isolated from production data and ignored by Git.
|
||||||
|
|
||||||
|
## Native world-server testing on macOS
|
||||||
|
|
||||||
|
Prerequisites: Apple silicon, supported macOS, Apple's `container` installed and its
|
||||||
|
system service running, Node 26, npm, Python 3.12+ (restore helper), and `npm ci` in
|
||||||
|
`dashboard/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/apple-container-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The control process runs on the Mac at **http://localhost:8081/network**. Credentials
|
||||||
|
are generated once in `data/apple-native/panel.env` (username `admin`). That process
|
||||||
|
must run on macOS to invoke Apple's CLI. Minecraft, backups and Velocity run inside
|
||||||
|
Apple Linux containers. This is intentionally separate from the containerized UI preview.
|
||||||
|
|
||||||
|
With `CONTAINER_RUNTIME=apple`, Worlds & Network uses:
|
||||||
|
|
||||||
|
- `container image pull` and `container run` for runtime deployment;
|
||||||
|
- `container inspect`, `stats`, `logs`, `exec`, `stop` and `start` for management;
|
||||||
|
- a dedicated `craftdeck` virtual network, unless `APPLE_CONTAINER_NETWORK` overrides it;
|
||||||
|
- Minecraft protocol health checks before reporting servers ready;
|
||||||
|
- actual container IPs for backup RCON and Velocity backend routes;
|
||||||
|
- explicit DNS (1.1.1.1 by default; set `APPLE_CONTAINER_DNS` for your resolver);
|
||||||
|
- the same profile validation, forwarding authentication, backups and restore workflows.
|
||||||
|
|
||||||
|
Start all routed servers before initially applying the gateway. Native VM IPs can
|
||||||
|
change on every stop/start. Panel restart, deploy, snapshot and restore operations
|
||||||
|
refresh backup RCON and running gateway routes automatically. Updating those routes
|
||||||
|
restarts Velocity and disconnects its players, even if only one world changed.
|
||||||
|
Stopped worlds are removed from native routes; an available world temporarily serves
|
||||||
|
as the lobby if the chosen lobby is offline. If no routed world is ready, the gateway
|
||||||
|
stops; start a world and apply routing to bring it back. After changing containers
|
||||||
|
outside the panel, use Restart for each affected profile and apply routing again.
|
||||||
|
Apple CLI does not implement
|
||||||
|
Docker restart policies or Compose health scheduling: the adapter waits for startup,
|
||||||
|
but does not provide a continuous crash/reboot supervisor. Use the production Docker
|
||||||
|
stack for unattended hosting. A native snapshot uses macOS tar/Python and can be
|
||||||
|
slower on shared mounts. Apple maps host files into each VM; filesystem ownership
|
||||||
|
behavior can differ from the Linux production host.
|
||||||
|
|
||||||
|
The original primary-server pages still expect their configured `MC_CONTAINER` and
|
||||||
|
RCON endpoint. The local script creates no primary server; those pages remain offline
|
||||||
|
until one is explicitly configured. The new network page is the local test surface.
|
||||||
|
Primary `.env`/Compose configuration apply remains a Docker-only workflow.
|
||||||
|
|
||||||
|
Stop individual test servers through the network page (this also stops their backup
|
||||||
|
writer), or use `container stop --time 90 <name>`. Stop the gateway with
|
||||||
|
`container stop craftdeck-velocity`. Ctrl-C stops the native control process; it does
|
||||||
|
not stop already-running Minecraft containers. All test worlds persist under
|
||||||
|
`data/apple-native/network/`.
|
||||||
|
|
||||||
|
## Containerized panel preview
|
||||||
|
|
||||||
|
For image/UI/API smoke testing without server orchestration:
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/apple-container-preview.sh build
|
||||||
|
./scripts/apple-container-preview.sh start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:8080/network**. The login is `admin`; the generated password is
|
||||||
|
in `data/apple-preview/preview.env`. Stop it with:
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/apple-container-preview.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
A Linux panel container cannot call the macOS Apple CLI and Apple container does not
|
||||||
|
supply a Docker Engine socket. Use the native host process above when testing actual
|
||||||
|
server controls. The image preview faithfully reports missing Docker infrastructure.
|
||||||
|
|
||||||
|
## x86_64 production image
|
||||||
|
|
||||||
|
Build the production architecture explicitly on the Mac:
|
||||||
|
|
||||||
|
```
|
||||||
|
container build --platform linux/amd64 --cpus 4 --memory 4G \
|
||||||
|
-t craftdeck:x86-local dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Apple's Rosetta support can run that image for an additional local smoke test:
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p data/apple-x86
|
||||||
|
container run -d --name craftdeck-x86-preview --arch amd64 --rosetta \
|
||||||
|
--cpus 2 --memory 1G -p 127.0.0.1:8082:8080 \
|
||||||
|
--env-file data/apple-preview/preview.env \
|
||||||
|
-v "$PWD/data/apple-x86:/preview" craftdeck:x86-local
|
||||||
|
```
|
||||||
|
|
||||||
|
This verifies an x86_64 Linux process on the Mac; it is not a performance benchmark
|
||||||
|
of the real x86_64 server. On the Linux host, use the repository's normal setup and
|
||||||
|
`docker compose up -d --build`. Network orchestration defaults to Docker there.
|
||||||
|
|
||||||
|
Reference: [Apple container documentation](https://github.com/apple/container/tree/main/docs).
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Worlds, maps, and a server network
|
||||||
|
|
||||||
|
CraftDeck now has **Worlds & Network**, **Live Maps**, and a **Capabilities** guide.
|
||||||
|
The original Overview, Players, Access, Performance, Schedules and other pages still
|
||||||
|
manage the primary server. The network page manages additional independent servers;
|
||||||
|
its Manage dialog scopes console, files, backups and restore to the selected server.
|
||||||
|
|
||||||
|
## Quick workflow
|
||||||
|
|
||||||
|
1. Open **Worlds & Network → Create server**. Create a Paper lobby, choose an exact
|
||||||
|
Minecraft version and matching Java runtime, and enter allowed account usernames.
|
||||||
|
2. Save, then **Deploy**. This downloads the runtime and waits for Minecraft health.
|
||||||
|
Progress and failures appear in Operations even if you leave the page.
|
||||||
|
3. Add another Paper or Folia world with a different seed. Both servers can run at once.
|
||||||
|
4. Select the lobby under Network gateway, save, and **Apply routing**.
|
||||||
|
5. Join the gateway's port, then use `/server <server-id>` to switch servers. IDs appear
|
||||||
|
on the cards. Optional hostname routes can enter a server directly through Velocity.
|
||||||
|
|
||||||
|
The gateway defaults to **25566**, preserving the primary server's **25565** binding.
|
||||||
|
To keep one public address at port 25565, your router can forward that external port
|
||||||
|
into the gateway's host port 25566. Do not bind two services to the same host port.
|
||||||
|
Changing the lobby and applying routing makes that world the network's entry point.
|
||||||
|
It restarts the proxy and disconnects current connections; it is not a live migration.
|
||||||
|
|
||||||
|
A proxy profile's game and RCON ports are not published. Velocity authenticates
|
||||||
|
Minecraft accounts and uses modern forwarding with a shared secret; Paper/Folia
|
||||||
|
are configured to verify that forwarding. All new servers enforce a whitelist.
|
||||||
|
These identities differ from the primary server's offline/EasyAuth accounts. The
|
||||||
|
panel does not migrate inventories or authentication databases between them.
|
||||||
|
|
||||||
|
## Runtime choices and isolated worlds
|
||||||
|
|
||||||
|
| Runtime | Connection | Content |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Paper | Velocity or direct | Paper-compatible plugins |
|
||||||
|
| Folia | Velocity or direct | Plugins explicitly compatible with Folia |
|
||||||
|
| Fabric / Forge / NeoForge / Quilt | Direct | Matching loader mods and clients |
|
||||||
|
| Vanilla | Direct | Vanilla clients and datapacks |
|
||||||
|
| Modrinth modpack | Direct | Exact pack/version, loader supplied by pack |
|
||||||
|
|
||||||
|
Automatic modern forwarding configuration targets Paper/Folia **1.19+**. Fabric and
|
||||||
|
modded proxy integrations require additional compatible mods and version-specific
|
||||||
|
setup; this panel deliberately does not auto-route those profiles. A proxy cannot
|
||||||
|
swap the client's installed modpack. Switching to an incompatible modpack requires
|
||||||
|
changing the client installation and reconnecting.
|
||||||
|
|
||||||
|
Each profile has a separate full data directory, including all dimensions, mods,
|
||||||
|
plugin configs, inventories and logs. The Copy settings button creates a **fresh
|
||||||
|
world**, not a clone of terrain. Change its seed or runtime before first deployment.
|
||||||
|
After initialization, runtime, Minecraft version, Java, modpack version, route and
|
||||||
|
seed are immutable in the profile editor. Create another profile to explore a new
|
||||||
|
version or pack without converting existing terrain accidentally. There is no
|
||||||
|
in-place downgrade or automatic world-conversion feature.
|
||||||
|
|
||||||
|
Java 8, 17, 21 and 25 images are available as choices; choose the release required by
|
||||||
|
your exact Minecraft/loader combination. Choosing a runtime does not verify that
|
||||||
|
all uploaded mods support it. Modrinth modpacks are checked against the selected
|
||||||
|
Minecraft version before deployment. Profile runtime images currently use Java tags;
|
||||||
|
for reproducible production deployments, review and pin the generated image digests.
|
||||||
|
|
||||||
|
Folia distributes independent regions across threads. It does not make every plugin
|
||||||
|
thread-safe or guarantee a player count/TPS. Players clustered in one region, CPU
|
||||||
|
contention and disk pressure can still cause lag. Allocate RAM for the sum of all
|
||||||
|
running heaps, at least 1 GiB per server overhead, the proxy, maps and the host OS.
|
||||||
|
|
||||||
|
## Maps
|
||||||
|
|
||||||
|
Choose BlueMap or Dynmap on a supported profile. Deployment resolves a stable release
|
||||||
|
advertising the exact Minecraft version and loader; it refuses deployment if no such
|
||||||
|
release is found. It does not fall back to an incompatible JAR. For a manually installed
|
||||||
|
renderer, leave automatic installation off and use its own configuration and ingress.
|
||||||
|
|
||||||
|
- BlueMap: internal web port **8100**, 3D terrain rendering.
|
||||||
|
- Dynmap: internal web port **8123**, 2D/isometric map rendering.
|
||||||
|
- Each maps to its own **host loopback** port. Publish through an HTTPS reverse proxy
|
||||||
|
or use an SSH tunnel. Ports are not exposed on every network interface by default.
|
||||||
|
- Save the resulting browser URL in the profile and open **Live Maps**.
|
||||||
|
- BlueMap requires its own asset-download consent in its generated config. Stop the
|
||||||
|
server, edit it through Manage → Files, and restart after reviewing that setting.
|
||||||
|
- Initial rendering can be expensive. Configure renderer threads, map dimensions,
|
||||||
|
player visibility, permissions, and privacy in the renderer's files.
|
||||||
|
- Claims need an addon compatible with the renderer and claim plugin. No claims data
|
||||||
|
is fabricated by CraftDeck. The viewer displays the renderer's real output.
|
||||||
|
|
||||||
|
The embedded viewer requires a working reachable URL; it does not prove map health.
|
||||||
|
HTTPS pages cannot embed insecure HTTP content. Renderer CSP/framing headers can
|
||||||
|
require opening the full map in its own tab.
|
||||||
|
|
||||||
|
## Resource packs and content
|
||||||
|
|
||||||
|
Set a direct ZIP URL, the file's SHA-1, an optional prompt and whether accepting the
|
||||||
|
pack is required. Deploy applies these server properties. Use a publicly reachable
|
||||||
|
static file compatible with the client version. The panel configures distribution;
|
||||||
|
it does not host the ZIP or install client mods. Compute a checksum on macOS with
|
||||||
|
`shasum -a 1 pack.zip`.
|
||||||
|
|
||||||
|
Use comma-separated Modrinth slugs (optionally `slug:version-id`) for managed mods or
|
||||||
|
plugins. Manage → Files can upload JARs into `mods/` or `plugins/`, and datapack ZIPs
|
||||||
|
into `world/datapacks/`. Config editing and uploads require the server to be stopped.
|
||||||
|
There is a 2 MB text editor limit and 100 MB upload limit. Uploads do not overwrite
|
||||||
|
existing files. Use the profile's Console to manage whitelist entries, gamerules,
|
||||||
|
weather, time, permissions and supported plugin commands. The saved profile whitelist
|
||||||
|
is authoritative at deployment, so also update it when adding permanent players.
|
||||||
|
|
||||||
|
## Backups, recovery and failures
|
||||||
|
|
||||||
|
Every server has a dedicated backup sidecar with an interval and retention policy.
|
||||||
|
The backup data and output directories belong to that profile only. Manual snapshots
|
||||||
|
briefly stop Minecraft and the sidecar, write to a partial archive, verify its contents,
|
||||||
|
then publish it as a completed snapshot. Writers resume afterward. Applying changes
|
||||||
|
to an existing world requires a verified snapshot first; backup failure cancels apply.
|
||||||
|
|
||||||
|
Restore verifies the archive before stopping writers, stages the data with the shared
|
||||||
|
restore helper, and retains previous contents under `recovery/`. The server remains
|
||||||
|
stopped after restore for inspection. Restart or deploy it once ready. Manual snapshots
|
||||||
|
are recovery points; monitor their disk usage in addition to automatic retention.
|
||||||
|
|
||||||
|
Operations are serialized with existing primary-server power/backup operations. Their
|
||||||
|
status is persisted. After a panel crash, unfinished jobs are marked failed and are
|
||||||
|
**not retried automatically**. Inspect actual container status and logs before retrying.
|
||||||
|
An unsuccessful deploy can leave the selected server stopped or unhealthy; it does
|
||||||
|
not claim success or automatically replace data. Proxy config retains a `.previous`
|
||||||
|
copy before applying changes.
|
||||||
|
|
||||||
|
Runtime storage (never commit):
|
||||||
|
|
||||||
|
```
|
||||||
|
data/dashboard/network.json # profiles, gateway settings, jobs
|
||||||
|
data/network/<id>/compose.json # generated Compose + RCON credential
|
||||||
|
data/network/<id>/data/ # complete Minecraft server
|
||||||
|
data/network/<id>/backups/ # this server's archives
|
||||||
|
data/network/<id>/recovery/ # retained pre-restore data
|
||||||
|
data/network/proxy/config/velocity.toml # routes
|
||||||
|
data/network/proxy/config/forwarding.secret
|
||||||
|
data/network/proxy/data/ # proxy runtime and plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up the registry and proxy configuration along with server archives. Local backups
|
||||||
|
are not off-host disaster recovery. On the production Docker host, deployment expects the standalone primary
|
||||||
|
server's bind-mounted `/data` directory and one user-defined Docker network. Komodo
|
||||||
|
managed installations stay read-only and should be extended through homelab-infra.
|
||||||
|
For native Mac testing using Apple’s container CLI, see [apple-container.md](apple-container.md).
|
||||||
|
|
||||||
|
## What else is possible?
|
||||||
|
|
||||||
|
The in-panel **Capabilities** guide distinguishes implemented features from addons:
|
||||||
|
claims/towns, economies, cross-server inventories, permissions, rollback, anti-cheat,
|
||||||
|
chunk pregeneration, voice chat, Discord bridges, Bedrock bridging, version translation,
|
||||||
|
custom dimensions, minigames and off-host backups. Those integrations need their own
|
||||||
|
compatible plugins, ports, databases and configuration; they are not preinstalled.
|
||||||
|
|
||||||
|
References: [Velocity compatibility](https://docs.papermc.io/velocity/server-compatibility/),
|
||||||
|
[modern forwarding](https://docs.papermc.io/velocity/player-information-forwarding/),
|
||||||
|
[Folia](https://docs.papermc.io/folia/),
|
||||||
|
[BlueMap installation](https://bluemap.bluecolored.de/wiki/getting-started/Installation.html),
|
||||||
|
[Docker server properties](https://docker-minecraft-server.readthedocs.io/en/latest/configuration/server-properties/),
|
||||||
|
[Modrinth modpacks](https://docker-minecraft-server.readthedocs.io/en/latest/types-and-platforms/mod-platforms/modrinth-modpacks/).
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Native macOS control process + Apple Linux containers for local server testing.
|
||||||
|
set -euo pipefail
|
||||||
|
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
local_dir="$project_dir/data/apple-native"
|
||||||
|
command -v container >/dev/null
|
||||||
|
container system status >/dev/null
|
||||||
|
mkdir -p "$local_dir/project"
|
||||||
|
if [[ ! -f "$local_dir/panel.env" ]]; then
|
||||||
|
python3 - "$local_dir/panel.env" <<'PY'
|
||||||
|
import os, secrets, sys
|
||||||
|
fd=os.open(sys.argv[1],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||||
|
with os.fdopen(fd,'w') as f:
|
||||||
|
for key in ('PANEL_SESSION_SECRET','PANEL_ADMIN_PASSWORD','RCON_PASSWORD'):
|
||||||
|
f.write(key+'='+secrets.token_hex(24)+'\n')
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
set -a
|
||||||
|
source "$local_dir/panel.env"
|
||||||
|
set +a
|
||||||
|
export CONTAINER_RUNTIME=apple
|
||||||
|
export APPLE_CONTAINER_CLI="$(command -v container)"
|
||||||
|
export APP_DATA_DIR="$local_dir/dashboard"
|
||||||
|
export MC_DATA_DIR="$local_dir/mc"
|
||||||
|
export BACKUPS_DIR="$local_dir/backups"
|
||||||
|
export PROJECT_DIR="$local_dir/project"
|
||||||
|
export RESTORE_HELPER="$project_dir/dashboard/scripts/restore_data.py"
|
||||||
|
export RESTORE_WORK_DIR="$local_dir/recovery"
|
||||||
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
export PORT="${CRAFTDECK_LOCAL_PORT:-8081}"
|
||||||
|
echo "Native Apple runtime panel: http://localhost:$PORT/network"
|
||||||
|
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $local_dir/panel.env"
|
||||||
|
cd "$project_dir/dashboard"
|
||||||
|
exec npm run dev -- --hostname 127.0.0.1 --port "$PORT"
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build and run the ARM64 panel using Apple's container CLI, without Docker Desktop.
|
||||||
|
# This is a local UI/API preview; Apple container does not expose a Docker Engine API.
|
||||||
|
set -euo pipefail
|
||||||
|
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
preview_dir="$project_dir/data/apple-preview"
|
||||||
|
command -v container >/dev/null || { echo "Install Apple's container CLI first." >&2; exit 1; }
|
||||||
|
container system status >/dev/null
|
||||||
|
mkdir -p "$preview_dir"
|
||||||
|
if [[ ! -f "$preview_dir/preview.env" ]]; then
|
||||||
|
python3 - "$preview_dir/preview.env" <<'PY'
|
||||||
|
import os, secrets, sys
|
||||||
|
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
f.write('PANEL_SESSION_SECRET=' + secrets.token_hex(32) + '\n')
|
||||||
|
f.write('PANEL_ADMIN_PASSWORD=' + secrets.token_urlsafe(20) + '\n')
|
||||||
|
f.write('APP_DATA_DIR=/preview/dashboard\nMC_DATA_DIR=/preview/mc\nBACKUPS_DIR=/preview/backups\nPROJECT_DIR=/preview/project\n')
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
case "${1:-start}" in
|
||||||
|
build) container build --platform linux/arm64 --cpus 4 --memory 4G -t craftdeck:network-local "$project_dir/dashboard" ;;
|
||||||
|
start)
|
||||||
|
if container inspect craftdeck-preview >/dev/null 2>&1; then
|
||||||
|
container start craftdeck-preview
|
||||||
|
else
|
||||||
|
container run -d --name craftdeck-preview --cpus 2 --memory 1G \
|
||||||
|
-p 127.0.0.1:8080:8080 --env-file "$preview_dir/preview.env" \
|
||||||
|
-v "$preview_dir:/preview" craftdeck:network-local
|
||||||
|
fi
|
||||||
|
echo "Panel: http://localhost:8080/network"
|
||||||
|
echo "Login: admin; password is PANEL_ADMIN_PASSWORD in $preview_dir/preview.env"
|
||||||
|
echo "Docker orchestration requires the regular Docker deployment; this is an Apple-container UI/API preview."
|
||||||
|
;;
|
||||||
|
stop) container stop craftdeck-preview ;;
|
||||||
|
*) echo "Usage: $0 {build|start|stop}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
Reference in new issue
Block a user