Repair uploads and let the console follow any registered server
This commit is contained in:
1 parent
3e24d4a7bb
commit
683cdc4016
15 files changed
+191
-82
No files matched your search
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
} });
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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",
|
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 },
|
||||||
|
|||||||
Reference in new issue
Block a user