22 changed files with 309 additions and 89 deletions

No files matched your search

+2
View File
@@ -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 },
}; };
+30
View File
@@ -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>
+10 -3
View File
@@ -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 &amp; Auth</h1> <h1 className="text-xl font-bold">Access &amp; 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 &lt;password&gt; &lt;password&gt;</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
+15 -5
View File
@@ -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}
+10 -9
View File
@@ -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)}>
+14 -8
View File
@@ -728,7 +728,7 @@ function ProfileForm({
setP({ setP({
...p, ...p,
loader, loader,
route: proxyCompatible(loader) ? p.route : "direct", route: proxyCompatible(loader) || p.route === "internal" ? p.route : "direct",
modpack: loader === "MODRINTH" ? p.modpack : "", modpack: loader === "MODRINTH" ? p.modpack : "",
map: ["VANILLA", "MODRINTH"].includes(loader) map: ["VANILLA", "MODRINTH"].includes(loader)
? "none" ? "none"
@@ -810,16 +810,17 @@ function ProfileForm({
Connection Connection
<Select <Select
value={p.route} value={p.route}
onChange={(v) => setP({ ...p, route: v as "proxy" | "direct" })} onChange={(v) => setP({ ...p, route: v as WorldProfile["route"] })}
options={[ options={[
...(proxyCompatible(p.loader) ...(proxyCompatible(p.loader)
? [{ value: "proxy", label: "Through Velocity" }] ? [{ value: "proxy", label: "Through Velocity" }]
: []), : []),
{ value: "direct", label: "Direct game port" }, { value: "direct", label: "Direct game port" },
{ value: "internal", label: "Private network / existing gateway" },
]} ]}
/> />
</label> </label>
{p.route === "direct" {p.route === "internal" ? <p className="text-xs text-muted">Private Docker network only. Connect through your existing gateway.</p> : p.route === "direct"
? field("port", "Public game port", "number") ? field("port", "Public game port", "number")
: field( : field(
"hostname", "hostname",
@@ -1048,6 +1049,7 @@ function ServerTools({
); );
} }
function ProfileFiles({ id, locked }: { id: string; locked: boolean }) { function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
const [uploadDir, setUploadDir] = useState("mods");
const [path, setPath] = useState(""); const [path, setPath] = useState("");
const [entries, setEntries] = useState< const [entries, setEntries] = useState<
Array<{ name: string; directory: boolean; link: boolean }> Array<{ name: string; directory: boolean; link: boolean }>
@@ -1146,8 +1148,10 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
))} ))}
</div> </div>
<label className={fieldStyle}> <label className={fieldStyle}>
Upload into / Upload destination (100 MB per file)
{path || " (open mods, plugins, or world/datapacks first)"} <select aria-label="Upload destination" value={uploadDir} onChange={e => setUploadDir(e.target.value)}>
<option value="mods">Mods (.jar)</option><option value="plugins">Plugins (.jar)</option><option value="world/datapacks">Datapacks (.zip)</option>
</select>
<input <input
type="file" type="file"
accept=".jar,.zip" accept=".jar,.zip"
@@ -1155,19 +1159,21 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
onChange={async (e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > 100 * 1024 * 1024) { setError("Choose a file under 100 MB"); e.target.value = ""; return; }
if (!(uploadDir === "world/datapacks" ? /\.zip$/i : /\.jar$/i).test(file.name)) { setError("Choose a JAR for mods/plugins or a ZIP for datapacks"); e.target.value = ""; return; }
setBusy(true); setBusy(true);
try { try {
const form = new FormData(); const form = new FormData();
form.set("id", id); form.set("id", id);
form.set("path", [path, file.name].filter(Boolean).join("/")); form.set("path", `${uploadDir}/${file.name}`);
form.set("file", file); form.set("file", file);
const res = await fetch("/api/network/files", { const res = await fetch("/api/network/files", {
method: "POST", method: "POST",
body: form, body: form,
}); });
const result = await res.json(); const result = 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) throw new Error(result.error || "Upload failed"); if (!res.ok) throw new Error(result.error || "Upload failed");
await load(path); await load(uploadDir);
toast("Uploaded", "success"); toast("Uploaded", "success");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
+2 -2
View File
@@ -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() });
}); });
} }
+16 -3
View File
@@ -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");
+3 -3
View File
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
const isUpload = req.headers const isUpload = req.headers
.get("content-type") .get("content-type")
?.includes("multipart/form-data"); ?.includes("multipart/form-data");
if (Number(req.headers.get("content-length") || 0) > 100 * 1024 * 1024) if (Number(req.headers.get("content-length") || 0) > 101 * 1024 * 1024)
return fail("Upload limit is 100 MB"); return fail("Upload limit is 100 MB");
const form = isUpload ? await req.formData() : null; const form = isUpload ? await req.formData() : null;
const body = form const body = form
@@ -59,8 +59,8 @@ export async function POST(req: NextRequest) {
return fail("Choose a file under 100 MB"); return fail("Choose a file under 100 MB");
const rel = path.relative(networkFilePath(p.id, ""), file); const rel = path.relative(networkFilePath(p.id, ""), file);
if ( if (
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) && !/^(mods|plugins)\/[^/]+\.jar$/i.test(rel) &&
!/^world\/datapacks\/[^/]+\.zip$/.test(rel) !/^world\/datapacks\/[^/]+\.zip$/i.test(rel)
) )
return fail( return fail(
"Upload JARs into mods/plugins or ZIPs into world/datapacks", "Upload JARs into mods/plugins or ZIPs into world/datapacks",
+45 -32
View File
@@ -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",
} });
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export type WorldProfile = {
simulationDistance: number; simulationDistance: number;
pvp: boolean; pvp: boolean;
hardcore: boolean; hardcore: boolean;
route: "proxy" | "direct"; route: "proxy" | "direct" | "internal";
port: number; port: number;
hostname: string; hostname: string;
mods: string; mods: string;
+3 -2
View File
@@ -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()
+9
View File
@@ -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);
}
+30 -19
View File
@@ -1,4 +1,5 @@
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 { 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";
@@ -201,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 };
@@ -251,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) => {
@@ -293,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);
} }
); );
+22
View File
@@ -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}`));
} }
+1 -1
View File
@@ -159,7 +159,7 @@ export function validateProfile(
), ),
pvp: bool(v.pvp, "PVP"), pvp: bool(v.pvp, "PVP"),
hardcore: bool(v.hardcore, "hardcore"), hardcore: bool(v.hardcore, "hardcore"),
route: choice(v.route, ["proxy", "direct"], "connection mode"), route: choice(v.route, ["proxy", "direct", "internal"], "connection mode"),
port: integer(v.port, "Game port", 1024, 65535), port: integer(v.port, "Game port", 1024, 65535),
hostname: string(v.hostname, "hostname", 253).toLowerCase(), hostname: string(v.hostname, "hostname", 253).toLowerCase(),
mods: string(v.mods, "mods", 4000), mods: string(v.mods, "mods", 4000),
+1 -1
View File
@@ -273,7 +273,7 @@ export async function deployProfile(id: string, progress: (s: string) => void) {
"Container started but Minecraft readiness is unverified; check its console", "Container started but Minecraft readiness is unverified; check its console",
); );
const routing = await refreshAppleGateway(); const routing = await refreshAppleGateway();
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : `Connect on port ${p.port}.`)}`; 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. */ /** Native VM addresses are ephemeral. Refresh only a gateway the user has started. */
async function refreshAppleGateway() { async function refreshAppleGateway() {
+22
View File
@@ -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();
});
+36
View File
@@ -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();
});
});
+7
View File
@@ -80,6 +80,13 @@ describe("isolated server profiles", () => {
MODRINTH_VERSION: "version123", 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", () => { it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
for (const update of [ for (const update of [
{ memory: 0 }, { memory: 0 },
+29
View File
@@ -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.