Merge pull request #2 from geneticglitch1/codex/repair-craftdeck-console-uploads

Repair uploads and let the console follow any registered server
This commit is contained in:
Aryan Singh authored and GitHub committed 2026-10-16 20:41:43 -05:00 Unverified
commit e4ef413ffe
15 files changed
+193 -84

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 },
}; };
+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}
+12 -11
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) => {
const form = new FormData(); if (file.size > 256 * 1024 * 1024) { toast("File too large (256 MB max)", "error"); return; }
form.append("dir", path); setUploading(true);
form.append("file", file); try {
toast(`Uploading ${file.name}…`, "info"); const form = new FormData();
const res = await fetch("/api/files/upload", { method: "POST", body: form }); form.append("dir", path); form.append("file", file);
const payload = await res.json(); const res = await fetch("/api/files/upload", { method: "POST", body: form });
if (!res.ok || payload.ok === false) { const payload = await res.json().catch(() => ({ error: res.status === 413 ? "Upload rejected by the web proxy: file is too large" : `Upload failed (${res.status})` }));
toast(payload.error ?? "Upload failed", "error"); if (!res.ok || payload.ok === false) throw new Error(payload.error ?? "Upload failed");
} 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);
+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;
+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);
} }
); );
+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() {
+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 },