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:
15 files changed
+191
-82
No files matched your search
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
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.
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "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";
|
||||
|
||||
const QUICK_COMMANDS = [
|
||||
@@ -24,6 +24,9 @@ function colorFor(line: string): string {
|
||||
}
|
||||
|
||||
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 [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
@@ -33,7 +36,9 @@ export default function ConsolePage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
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) => {
|
||||
setLines((xs) => {
|
||||
const next = [...xs, line as string];
|
||||
@@ -60,7 +65,9 @@ export default function ConsolePage() {
|
||||
if (!c) return;
|
||||
setBusy(true);
|
||||
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));
|
||||
setHistIdx(-1);
|
||||
setCommand("");
|
||||
@@ -97,9 +104,12 @@ export default function ConsolePage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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 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
|
||||
placeholder="Filter lines…"
|
||||
value={filter}
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function FilesPage() {
|
||||
const [mkdirOpen, setMkdirOpen] = useState(false);
|
||||
const [mkdirName, setMkdirName] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async (p: string) => {
|
||||
@@ -92,18 +93,18 @@ export default function FilesPage() {
|
||||
};
|
||||
|
||||
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();
|
||||
form.append("dir", path);
|
||||
form.append("file", file);
|
||||
toast(`Uploading ${file.name}…`, "info");
|
||||
form.append("dir", path); form.append("file", file);
|
||||
const res = await fetch("/api/files/upload", { method: "POST", body: form });
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.ok === false) {
|
||||
toast(payload.error ?? "Upload failed", "error");
|
||||
} else {
|
||||
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) throw new Error(payload.error ?? "Upload failed");
|
||||
toast(`${file.name} uploaded`, "success");
|
||||
void load(path);
|
||||
}
|
||||
} catch (err) { toast((err as Error).message, "error"); }
|
||||
finally { setUploading(false); }
|
||||
};
|
||||
|
||||
const crumbs = path ? path.split("/") : [];
|
||||
@@ -126,7 +127,7 @@ export default function FilesPage() {
|
||||
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
|
||||
</Button>
|
||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
|
||||
@@ -728,7 +728,7 @@ function ProfileForm({
|
||||
setP({
|
||||
...p,
|
||||
loader,
|
||||
route: proxyCompatible(loader) ? p.route : "direct",
|
||||
route: proxyCompatible(loader) || p.route === "internal" ? p.route : "direct",
|
||||
modpack: loader === "MODRINTH" ? p.modpack : "",
|
||||
map: ["VANILLA", "MODRINTH"].includes(loader)
|
||||
? "none"
|
||||
@@ -810,16 +810,17 @@ function ProfileForm({
|
||||
Connection
|
||||
<Select
|
||||
value={p.route}
|
||||
onChange={(v) => setP({ ...p, route: v as "proxy" | "direct" })}
|
||||
onChange={(v) => setP({ ...p, route: v as WorldProfile["route"] })}
|
||||
options={[
|
||||
...(proxyCompatible(p.loader)
|
||||
? [{ value: "proxy", label: "Through Velocity" }]
|
||||
: []),
|
||||
{ value: "direct", label: "Direct game port" },
|
||||
{ value: "internal", label: "Private network / existing gateway" },
|
||||
]}
|
||||
/>
|
||||
</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(
|
||||
"hostname",
|
||||
@@ -1048,6 +1049,7 @@ function ServerTools({
|
||||
);
|
||||
}
|
||||
function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
const [uploadDir, setUploadDir] = useState("mods");
|
||||
const [path, setPath] = useState("");
|
||||
const [entries, setEntries] = useState<
|
||||
Array<{ name: string; directory: boolean; link: boolean }>
|
||||
@@ -1146,8 +1148,10 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
))}
|
||||
</div>
|
||||
<label className={fieldStyle}>
|
||||
Upload into /
|
||||
{path || " (open mods, plugins, or world/datapacks first)"}
|
||||
Upload destination (100 MB per file)
|
||||
<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
|
||||
type="file"
|
||||
accept=".jar,.zip"
|
||||
@@ -1155,19 +1159,21 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
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);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("id", id);
|
||||
form.set("path", [path, file.name].filter(Boolean).join("/"));
|
||||
form.set("path", `${uploadDir}/${file.name}`);
|
||||
form.set("file", file);
|
||||
const res = await fetch("/api/network/files", {
|
||||
method: "POST",
|
||||
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");
|
||||
await load(path);
|
||||
await load(uploadDir);
|
||||
toast("Uploaded", "success");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
|
||||
@@ -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 { rcon, stripColors } from "@/server/rcon";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { pushLogLine } from "@/server/bus";
|
||||
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) {
|
||||
return handle(async () => {
|
||||
const { command } = await readJson<{ command?: string }>(req);
|
||||
if (!command?.trim()) return fail("Empty command");
|
||||
const { command, server = "primary" } = await readJson<{ command?: string; server?: string }>(req);
|
||||
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 output = stripColors(await rcon().exec(cmd));
|
||||
const output = stripColors(server === "primary" ? await rcon().exec(cmd) : await profileConsole(server, cmd));
|
||||
logActivity("console", null, cmd);
|
||||
// Echo the exchange into the live console stream so it reads like a terminal.
|
||||
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) {
|
||||
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 dir = (form.get("dir") as string) ?? "";
|
||||
const file = form.get("file");
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
|
||||
const isUpload = req.headers
|
||||
.get("content-type")
|
||||
?.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");
|
||||
const form = isUpload ? await req.formData() : null;
|
||||
const body = form
|
||||
@@ -59,8 +59,8 @@ export async function POST(req: NextRequest) {
|
||||
return fail("Choose a file under 100 MB");
|
||||
const rel = path.relative(networkFilePath(p.id, ""), file);
|
||||
if (
|
||||
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) &&
|
||||
!/^world\/datapacks\/[^/]+\.zip$/.test(rel)
|
||||
!/^(mods|plugins)\/[^/]+\.jar$/i.test(rel) &&
|
||||
!/^world\/datapacks\/[^/]+\.zip$/i.test(rel)
|
||||
)
|
||||
return fail(
|
||||
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
|
||||
|
||||
@@ -1,51 +1,64 @@
|
||||
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 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) {
|
||||
let name: string;
|
||||
try { name = consoleTarget(req.nextUrl.searchParams.get("server")); }
|
||||
catch { return Response.json({ error: "Unknown server" }, { status: 400 }); }
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
let cleanup = () => {};
|
||||
const stream = new ReadableStream({
|
||||
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;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
const state = await inspectContainer(name);
|
||||
if (closed) return;
|
||||
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(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
if (!closed) { try { controller.enqueue(encoder.encode(": keepalive\n\n")); } catch { cleanup(); } }
|
||||
}, 15000);
|
||||
req.signal.addEventListener("abort", () => {
|
||||
cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
bus().off("log", onLog);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
clearTimeout(retry);
|
||||
logs?.stop();
|
||||
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);
|
||||
},
|
||||
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",
|
||||
} });
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export type WorldProfile = {
|
||||
simulationDistance: number;
|
||||
pvp: boolean;
|
||||
hardcore: boolean;
|
||||
route: "proxy" | "direct";
|
||||
route: "proxy" | "direct" | "internal";
|
||||
port: number;
|
||||
hostname: string;
|
||||
mods: string;
|
||||
|
||||
@@ -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,5 @@
|
||||
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 { withServerOperation } from "./operation";
|
||||
@@ -201,31 +202,41 @@ export async function containerStats(name: string): Promise<ContainerStats> {
|
||||
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
||||
* [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 textCarry = "";
|
||||
return (chunk: Buffer) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (buf.length >= 8) {
|
||||
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);
|
||||
}
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let raw = false;
|
||||
const lines = () => {
|
||||
let nl;
|
||||
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
||||
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
||||
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 };
|
||||
@@ -251,7 +262,7 @@ export function streamContainerLogs(
|
||||
}
|
||||
const demux = createDemuxer(onLine);
|
||||
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));
|
||||
});
|
||||
req.on("error", (e) => {
|
||||
@@ -293,7 +304,7 @@ export async function execInContainer(
|
||||
text += line + "\n";
|
||||
});
|
||||
res.on("data", (c: Buffer) => demux(c));
|
||||
res.on("end", () => resolve(text));
|
||||
res.on("end", () => { demux.end(); resolve(text); });
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ export function validateProfile(
|
||||
),
|
||||
pvp: bool(v.pvp, "PVP"),
|
||||
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),
|
||||
hostname: string(v.hostname, "hostname", 253).toLowerCase(),
|
||||
mods: string(v.mods, "mods", 4000),
|
||||
|
||||
@@ -273,7 +273,7 @@ export async function deployProfile(id: string, progress: (s: string) => void) {
|
||||
"Container started but Minecraft readiness is unverified; check its console",
|
||||
);
|
||||
const routing = await refreshAppleGateway();
|
||||
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : `Connect on port ${p.port}.`)}`;
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,13 @@ describe("isolated server profiles", () => {
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user